Compare commits

...
Author SHA1 Message Date
claude-botandClaude Opus 5.5 286564a0d7 fix(kuma-sync): create when the previous monitor ID belongs to another owner
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: comment
After a Kuma database loss or restore, IDs restart and a resource's
status.monitorID can point at a monitor that now carries another
resource's ownership tag. That proves our monitor is gone, so the
controller creates a new one instead of parking at NotOwned forever.
Only an untagged monitor at the previous ID still blocks the create
(tag removed by hand), as before.

Residual Important finding from the kuma-sync final review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 08:12:45 +02:00
k.renner 1dc5ae7816 Merge pull request 'test(kuma-sync): remove smoke-test KumaMonitor' (#1353) from chore/kuma-sync-smoke-remove into master
Reviewed-on: k.renner/homelab#1353
2026-10-19 06:04:39 +00:00
claude-botandClaude Opus 5.5 ada5b7ea4d test(kuma-sync): remove smoke-test KumaMonitor
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
review-bot skipped: closed
Last smoke-test step: Flux prunes the KumaMonitor and the controller's
finalizer deletes Kuma monitor 9.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 08:02:48 +02:00
k.renner 38c186d424 Merge pull request 'test(kuma-sync): live smoke-test KumaMonitor' (#1351) from feat/kuma-sync-smoke into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#1351
2026-10-19 06:00:17 +00:00
security-bot d659097b8f chore(image-automation): bump gitea.renner.uno/gitea_admin/ci-backup-verify:2026-10-19
Updates managed by image-automation-controller.
2026-10-19 06:00:13 +00:00
security-bot 4baf5aa8be chore(image-automation): bump gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-10-19-0544
Updates managed by image-automation-controller.
2026-10-19 05:58:07 +00:00
claude-botandClaude Opus 5.5 fafc73cb3f test(kuma-sync): live smoke-test KumaMonitor for www.renner.uno
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: comment
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Keyword check on https://www.renner.uno/ (page title "Familie Renner")
with certificate-expiry notification. Exercises create, tagging,
restart adoption and deletion against the live Kuma; removed from git
again as the last smoke-test step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 07:58:00 +02:00
k.renner ab876a1231 Merge pull request 'feat(kuma-sync): deploy controller (Task 7)' (#1350) from feat/kuma-sync-deploy into master
Reviewed-on: k.renner/homelab#1350
2026-10-19 05:56:20 +00:00
claude-botandClaude Opus 5.5 790e258894 feat(kuma-sync): deploy controller
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: comment
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Namespace, CRD (Flux prune disabled so a prune never cascades into
deleting every Kuma monitor via finalizers), cluster RBAC matching the
controller-gen role, Recreate Deployment of the first master build
(2026-10-19-0545) under image automation, and a default-deny
CiliumNetworkPolicy allowing only DNS, the kube-apiserver and
kuma.renner.uno:443 out, kubelet probes in.

The Kuma login comes from its own OpenBao path infrastructure/kuma-sync
(a copy of the single Kuma admin account) rather than reading
infrastructure/uptime-kuma.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 07:51:41 +02:00
security-bot d8d676b711 chore(image-automation): bump gitea.renner.uno/k.renner/shelly-manager:build.1792387459
Updates managed by image-automation-controller.
2026-10-19 05:48:49 +00:00
security-bot 3f43d5fa17 chore(image-automation): bump gitea.renner.uno/gitea_admin/review-bot:2026-10-19-0540
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-10-19 05:45:09 +00:00
security-bot 142a21883d chore(image-automation): bump gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-10-19-0518 docker.gitea.com/gitea-mcp-server:1.10.0
ci/woodpecker/cron/alert-monitor Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
Updates managed by image-automation-controller.
2026-10-19 05:28:07 +00:00
k.renner dc3a299cf6 Merge pull request 'chore(ci): bump ci-claude image to 2.1.303' (#1349) from ci-image/claude-2.1.303 into master
Reviewed-on: k.renner/homelab#1349
2026-10-19 05:24:52 +00:00
k.renner b73e1b5636 Merge pull request 'chore(deps): update helm release openbao to >=0.30.4 <0.31.0' (#1348) from renovate/openbao into master
Reviewed-on: k.renner/homelab#1348
2026-10-19 05:19:50 +00:00
k.renner c8d2d073a5 Merge pull request 'chore(deps): pin dependencies' (#1343) from renovate/pin-image-digests into master
Reviewed-on: k.renner/homelab#1343
2026-10-19 05:19:20 +00:00
claude-bot 8d3b3c4324 chore(ci): bump ci-claude image to 2.1.303
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: comment
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
2026-10-19 05:18:31 +00:00
k.renner 7b1041c188 Merge pull request 'chore(deps): update home-assistant non-major' (#1346) from renovate/home-assistant-minor-patch into master
Reviewed-on: k.renner/homelab#1346
2026-10-19 05:18:13 +00:00
k.renner 9e62fadcb1 Merge pull request 'chore(deps): update ci non-major' (#1345) from renovate/ci-minor-patch into master
ci/woodpecker/push/ci-claude-refresh Pipeline was successful
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: k.renner/homelab#1345
2026-10-19 05:17:04 +00:00
k.renner 5e99ca4370 Merge pull request 'feat(kuma-sync): in-house Uptime Kuma controller (Tasks 1-6)' (#1342) from feat/kuma-sync into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: k.renner/homelab#1342
2026-10-19 05:09:52 +00:00
renovate-bot 94090c68c3 chore(deps): update helm release openbao to >=0.30.4 <0.31.0
review-bot reviewed: comment
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
2026-10-19 02:02:09 +00:00
renovate-bot b555277634 chore(deps): update home-assistant non-major
review-bot reviewed: comment
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
2026-10-19 02:02:00 +00:00
renovate-bot f8b98907e8 chore(deps): update ci non-major
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: comment
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
2026-10-19 02:01:57 +00:00
renovate-bot d7d99365e8 chore(deps): pin dependencies
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
review-bot reviewed: request changes
2026-10-19 02:01:50 +00:00
claude-botandClaude Opus 5.5 e1c0e73f61 fix(kuma-sync): orphan grace, refuse create beside an untagged previous monitor, drop push
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: comment
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 00:47:16 +02:00
claude-botandClaude Opus 5.5 59ccd74a89 test(kuma-sync): race-free drift test, restart proves adoption
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 00:30:13 +02:00
claude-botandClaude Opus 5.5 15a4e2a0dc test(kuma-sync): end-to-end integration suite
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 00:23:14 +02:00
claude-botandClaude Opus 5.5 2af1d880e4 fix(kuma-sync): confirm orphan deletes against the API server
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 00:16:21 +02:00
claude-botandClaude Opus 5.5 18ef262d93 feat(kuma-sync): orphan GC, manager and image build
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 00:12:24 +02:00
claude-botandClaude Opus 5.5 185738bd2c fix(kuma-sync): never create on an incomplete list; typed owner conflicts; duplicate owners
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 00:02:04 +02:00
claude-botandClaude Opus 5.5 0c487656e8 feat(kuma-sync): KumaMonitor reconciler
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:54:48 +02:00
claude-botandClaude Opus 5.5 b3f68753d1 fix(kuma-sync): flag unknown-outcome creates as possible orphans
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:50:53 +02:00
claude-botandClaude Opus 5.5 1968e91516 fix(kuma-sync): never create after a failed owner lookup
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:47:54 +02:00
claude-botandClaude Opus 5.5 4f0c7c414e fix(kuma-sync): reconnect, no orphan on create timeout, safe timeout, owner-tag integrity
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:41:20 +02:00
claude-botandClaude Opus 5.5 734f546d8b feat(kuma-sync): Kuma client on go-uptime-kuma-client
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:29:36 +02:00
claude-botandClaude Opus 5.5 90534f8ce7 fix(kuma-sync): deep-copy fake state, sentinel not-found/not-owned errors
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:17:34 +02:00
claude-botandClaude Opus 5.5 3a684d5452 feat(kuma-sync): Kuma client interface and fake
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:15:34 +02:00
claude-botandClaude Opus 5.5 ee0d416b80 fix(kuma-sync): precise validation tests and tighter CRD bounds
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:13:54 +02:00
claude-botandClaude Opus 5.5 fdb381305e feat(kuma-sync): KumaMonitor API types and CRD
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:10:50 +02:00
k.renner 5ebf7a04c4 Merge pull request 'docs(plans): kuma-sync controller design and plan' (#1341) from docs/uptime-kuma-plan into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
ci/woodpecker/cron/update-audit Pipeline was successful
ci/woodpecker/cron/e2e Pipeline failed
Reviewed-on: k.renner/homelab#1341
2026-10-18 21:05:14 +00:00
claude-botandClaude Opus 5.5 7f4c9c60c3 docs(plans): kuma-sync controller design and plan
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot skipped: closed during the review
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:03:26 +02:00
k.renner fde532b6a4 Merge pull request 'chore(autokuma): remove AutoKuma after failed pilot' (#1340) from chore/remove-autokuma into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#1340
2026-10-18 20:53:19 +00:00
claude-botandClaude Opus 5.5 333f331aed chore(autokuma): remove AutoKuma after failed pilot
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: comment
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
AutoKuma 2.0.0 never applied its ownership tag against Uptime Kuma 2.5.6, so a pod restart with empty /data duplicated every managed monitor (pilot step 5). Replaced by an in-house controller under a separate plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:48:31 +02:00
k.renner 1c4f4583fa Merge pull request 'fix(uptime-kuma): dual-stack egress network for Kuma checks' (#1339) from fix/uptime-kuma-ipv6 into master
ci/woodpecker/push/deploy-uptime-kuma Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#1339
2026-10-18 20:43:42 +00:00
k.renner b3a32435a8 Merge pull request 'test(autokuma): pilot step 3 — update interval and expiry_notification' (#1338) from feat/autokuma-pilot-update into master
Reviewed-on: k.renner/homelab#1338
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-10-18 20:43:27 +00:00
k.renner b31ee3189a Merge pull request 'fix(flux-notifications): stop alerting on successful image-automation commits' (#1337) from fix/drop-flux-image-automation-alert into master
Reviewed-on: k.renner/homelab#1337
2026-10-18 20:42:53 +00:00
claude-botandClaude Opus 5.5 64a5281bd0 fix(uptime-kuma): dual-stack egress network for Kuma checks
review-bot reviewed: comment
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:40:57 +02:00
claude-botandClaude Opus 5.5 f7a0719524 test(autokuma): pilot step 3 — update interval and expiry_notification
review-bot reviewed: approve
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:40:51 +02:00
claude-botandClaude Opus 5.5 4a302c2cbe fix(flux-notifications): stop alerting on successful image-automation commits
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: comment
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
The flux-image-automation Alert forwarded every info event from
ImageUpdateAutomation (i.e. each successful bump commit) to Alertmanager,
which emailed it as FluxImageUpdateAutomationSucceeded. A success is not
a problem and must not be an alert; the commits are in git history.

Image-automation failures still alert: flux-failures covers
ImageUpdateAutomation at error severity. The Alertmanager source="flux"
route stays for those; its comment now says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:38:25 +02:00
security-bot 7617a6725b chore(image-automation): bump docker.gitea.com/gitea-mcp-server:1.10.0
ci/woodpecker/cron/issue-fix Pipeline was successful
Updates managed by image-automation-controller.
2026-10-18 20:33:45 +00:00
k.renner c863b9845c Merge pull request 'feat(autokuma): pilot KumaEntities (Task 3)' (#1336) from feat/autokuma-pilot into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#1336
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-10-18 20:25:39 +00:00
claude-botandClaude Opus 5.5 f74239bb40 feat(autokuma): pilot KumaEntities (Task 3)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: approve
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:23:52 +02:00
k.renner d43cd54cba Merge pull request 'docs(plans): replace UptimeRobot with Uptime Kuma' (#1330) from docs/uptime-kuma-plan into master
Reviewed-on: k.renner/homelab#1330
2026-10-18 20:23:00 +00:00
k.renner 81f0adce5d Merge pull request 'docs(plan): move homelab to konairius/homelab and rename master to main' (#1328) from claude/plan-homelab-konairius-main into master
Reviewed-on: k.renner/homelab#1328
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-10-18 20:22:38 +00:00
k.renner 1d99975267 Merge pull request 'feat(autokuma): KumaEntity controller for Uptime Kuma' (#1333) from feat/autokuma into master
Reviewed-on: k.renner/homelab#1333
2026-10-18 20:20:34 +00:00
claude-botandClaude Opus 5.5 ee5f4894a5 feat(autokuma): restrict egress to kuma.renner.uno
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: comment
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Replace world:443 with toFQDNs kuma.renner.uno:443; the kube-dns rule gains
rules.dns so Cilium learns the name-to-IP mapping.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:14:13 +02:00
k.renner 2503b353b7 Merge pull request 'fix(uptime-kuma): guard the setup fail check against an undefined rc' (#1335) from fix/uptime-kuma-fail-guard into master
ci/woodpecker/push/deploy-uptime-kuma Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#1335
2026-10-18 20:10:39 +00:00
claude-botandClaude Opus 5.5 b136362fa8 fix(uptime-kuma): guard the setup fail check against an undefined rc
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: comment
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:09:37 +02:00
k.renner b7d26ec869 Merge pull request 'fix(uptime-kuma): start setup after Kuma's info event; surface setup errors' (#1334) from fix/uptime-kuma-setup-diagnostics into master
ci/woodpecker/push/deploy-uptime-kuma Pipeline was successful
Reviewed-on: k.renner/homelab#1334
2026-10-18 20:07:15 +00:00
claude-botandClaude Opus 5.5 8ef8486b2e fix(uptime-kuma): start setup after Kuma's info event
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: comment
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:04:34 +02:00
claude-botandClaude Opus 5.5 811171646e fix(uptime-kuma): surface setup script output on failure
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:03:30 +02:00
k.renner 597541ed7f Merge pull request 'feat(uptime-kuma): Uptime Kuma on the NetBird VPS' (#1332) from feat/uptime-kuma-vps into master
ci/woodpecker/push/deploy-uptime-kuma Pipeline failed
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#1332
2026-10-18 20:00:28 +00:00
claude-botandClaude Opus 5.5 e4f11a1d80 ci(uptime-kuma): use ci-ansible image with hvac
review-bot skipped: draft
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:58:10 +02:00
k.renner e5100fda8e Merge pull request 'ci(ansible-image): add hvac for community.hashi_vault lookups' (#1331) from ci/ansible-image-hvac into master
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: k.renner/homelab#1331
2026-10-18 19:51:17 +00:00
claude-botandClaude Opus 5.5 4aabb1a601 docs(plans): uptime-kuma SMTP secret layout from owner
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: request changes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:49:31 +02:00
claude-botandClaude Opus 5.5 016b5d69eb feat(uptime-kuma): renew the CI OpenBao token from a cron pipeline
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot skipped: draft
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
The CI token is an orphan periodic token (period 768h) created with
-no-default-policy -policy=uptime-kuma-vps; nothing renews it. The policy
now grants auth/token/renew-self, and the pipeline gains a renew-openbao-token
step plus a cron trigger matching only the cron named uptime-kuma-token-renew.
The deploy step is restricted to push and deployment events so cron runs only
renew. -o /dev/null drops the JSON body that contains the token; -sSf keeps
errors visible and fails the step on a 403.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:48:50 +02:00
claude-botandClaude Opus 5.5 5efb1dd8c8 fix(autokuma): preserve unknown fields in spec.config, default-deny ingress
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
review-bot superseded: new head ee5f4894
additionalProperties: true makes the API server prune nested objects under
spec.config to {}; x-kubernetes-preserve-unknown-fields (as in upstream
master) keeps the free-form contract. Add ingress: [{}] so the policy
default-denies ingress as named.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:41:40 +02:00
claude-botandClaude Opus 5.5 4e5a97ae0b feat(autokuma): KumaEntity controller for Uptime Kuma
AutoKuma 2.0.0 (Kubernetes source) syncs KumaEntity objects to Uptime Kuma
at https://kuma.renner.uno. Ships no KumaEntity objects. Image pinned, not
under Flux automation: Kuma's internal API is unofficial, so Kuma and
AutoKuma are bumped together.

ClusterRole is cluster-wide because the controller lists/watches
KumaEntity across all namespaces. The openbao ClusterSecretStore
namespace allowlist gains autokuma. FILES and DOCKER sources are
disabled; /data and /tmp are emptyDirs for readOnlyRootFilesystem.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:38:55 +02:00
claude-botandClaude Opus 5.5 cddf555ba6 fix(uptime-kuma): keep the setup marker inside the data volume
review-bot skipped: draft
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
The marker next to data/ survived a wiped or replaced DB, so the first up -d
exposed a fresh unclaimed Kuma on /setup. Inside data/ it lives and dies with
the DB; it is still written only after a successful setup run and checked
before the first up -d.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:36:07 +02:00
claude-botandClaude Opus 5.5 4b4fc39f30 ci(ansible-image): add hvac for community.hashi_vault lookups
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: comment
The uptime-kuma play reads its credentials from OpenBao via community.hashi_vault, which needs the python hvac library.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:34:22 +02:00
claude-botandClaude Opus 5.5 67ce7daafa feat(uptime-kuma): Uptime Kuma on the NetBird VPS
Role, play and pipeline deploy Uptime Kuma 2.5.6 behind the VPS Traefik.
The admin user is bootstrapped over socket.io (needSetup/setup) with the
route disabled, so /setup is never exposed; a .setup-done marker keeps the
route enabled on later runs. Credentials come from OpenBao
homelab/infrastructure/uptime-kuma via community.hashi_vault; the
uptime-kuma-vps policy grants read on that one path. Only kuma.renner.uno
is routed; uptime.renner.uno stays unrouted until its DNS moves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:32:53 +02:00
claude-botandClaude Opus 5.5 8063bfb7b4 docs(plans): record owner answers for uptime-kuma plan
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: request changes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:30:09 +02:00
claude-botandClaude Opus 5.5 d8cb907aca docs(plans): uptime-kuma groups, json-query pilot checks, latency alerting
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot superseded: new head 8063bfb7
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:28:59 +02:00
claude-botandClaude Opus 5.5 3c0cb24d19 docs(plan): Flux follows the transfer redirect per the documentation
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: approve
source-controller v1.9.5 uses go-git v5.19.1, whose documented RedirectPolicy
default is "initial", mirroring git's http.followRedirects: follow the
redirect on the discovery request and use the redirected URL as the base for
follow-up requests. The break-glass stays as a fallback only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:28:25 +02:00
claude-botandClaude Opus 5.5 16686fcec3 docs(plans): Uptime Kuma replacement for UptimeRobot
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:18:59 +02:00
claude-botandClaude Opus 5.5 95f1854f3b docs(plan): fold in review-bot review 1693
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: approve
- Snapshot collaborators, konairius teams and branch-protection whitelists
  before the transfer and compare after (Gitea drops collaborators who are
  members of the new org).
- Break-glass suspends the flux-system Kustomization so it cannot re-apply the
  old URL.
- Task 1 notes the off-cycle mint of both review-bot PATs and checks the
  k.renner org holds only the two org secrets.
- Prompts move out of the branch PR into a post-rename PR to main, which
  rebuilds the review-bot image (build-ci-images would not fire otherwise).
- Expected side effects of the transfer PR listed; greps widened to the whole
  repo; wiki-maintenance cron deleted (no pipeline uses it); GitHub mirror gets
  its default branch switched before master is deleted.
- Verified facts recorded: transfer collaborator rule, repair copies the
  default branch, repo/cron ids, org secret inventory, repoRenameBranch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:16:59 +02:00
k.renner d765451e8e Merge pull request 'fix(monitoring): resolve KubeJobFailed when a later run succeeds' (#1329) from fix/kubejobfailed-resolve-on-later-success into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#1329
2026-10-18 18:58:57 +00:00
claude-botandClaude Opus 5.5 8ee980755a fix(monitoring): resolve KubeJobFailed when a later run succeeds
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: comment
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
The chart's KubeJobFailed fires while a failed Job object exists, so it
clears only on Job GC (TTL, history limit, Velero's keep-latest), not on
recovery. On 2026-10-18 this held 10 alerts open for runs that had already
been superseded: renovate recovered 2026-10-13 but alerted until its 7d
TTL; registry-bot-secret-rotate-init2 succeeded 9 min after init failed,
the alert started 7 min after that and stayed 11h.

Replace it with a rule that drops a failed Job once a later Job of the
same series completes: series = owning CronJob, or velero.io/repo-name
for kopia maintenance Jobs (exposed via KSM metricLabelsAllowlist).
Failed Jobs without a series alert as before.

KubeContainerWaiting fired for the never-started main container of a pod
whose init container failed. Exclude Failed/Succeeded pods; KubeJobFailed
covers those.

Verified against live Prometheus: the new expression returns nothing now
(chart rule: 2), returns the 30 Velero maintenance failures at 16:40Z
2026-10-18, and returns nothing at the renovate/registry-bot timestamps
after their successful re-runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:54:20 +02:00
claude-botandClaude Opus 5.5 bbd166880b docs(plan): move homelab to konairius/homelab and rename master to main
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: comment
Two cut-overs with their own windows, prep PRs and rollbacks, built on the
2026-10-18 inventory (repo references, live Flux/Woodpecker/rotator state,
workstation consumers) and the pattern proven on the five repos moved the same
day.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:52:39 +02:00
k.renner 32d16fcfc0 Merge pull request 'chore(home-assistant): follow homeassistant to konairius/homeassistant (merge right after the transfer)' (#1327) from claude/homeassistant-konairius into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#1327
2026-10-18 18:38:51 +00:00
claude-botandClaude Opus 5.5 bcb907fc92 chore(home-assistant): follow homeassistant to konairius/homeassistant
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: comment
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Merge right after the repo transfer. Gitea API writes do not survive the
old-path redirect, so the config-sync sidecar (live -> main PR) and review-bot
(posting reviews) must address the new path.

- config-sync GITEA_REPO = konairius/homeassistant (pod restarts, the remote is
  reset on start).
- ha-config-sync rotator verifies against konairius/homeassistant.
- review-bot REPOS lists konairius/homeassistant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:36:19 +02:00
k.renner edc1cccf4d Merge pull request 'feat(home-assistant): config-sync authenticates as ha-config-sync, remote from the manifest' (#1326) from claude/ha-config-sync-identity into master
Reviewed-on: k.renner/homelab#1326
2026-10-18 18:31:43 +00:00
claude-botandClaude Opus 5.5 76a82f7d28 feat(home-assistant): config-sync authenticates as ha-config-sync, remote from the manifest
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: comment
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
The sidecar took its git remote, Gitea API base and token from a URL with an
embedded PAT stored on the config PVC. That state was invisible to git,
unrotated, and would break on the repo transfer to konairius: the live -> main
PR is opened with an API POST, and Gitea API writes do not survive the old-path
redirect (seen with the wiki on notes).

- ha-config-sync: dedicated Gitea account, write collaborator on homeassistant.
  CronJob openbao/ha-config-sync-secret-rotate (quarterly) mints its
  write:repository PAT, verifies pull+push on homeassistant and stores it at
  infrastructure/ha-config-sync#token. Policy/role applied and the path seeded
  on 2026-10-18. Netpol halves gitea-allow-ha-config-sync-rotator and
  rotator-ha-config-sync-egress.
- ExternalSecret home-assistant-config-sync-gitea, mounted as a file (not
  subPath) so a rotation lands in place without restarting Home Assistant.
- config-sync.sh: GITEA_URL/GITEA_REPO/GITEA_USER/GITEA_TOKEN_FILE from the
  manifest; the remote is reset to the credential-free URL on every start; git
  gets the token as an http.extraHeader through GIT_CONFIG_* env, re-read every
  loop; the PR call uses the same token.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:28:15 +02:00
k.renner bbb21482bf Merge pull request 'fix(review-bot): review konairius/authentik-samba-bridge' (#1325) from claude/review-bot-repos-konairius into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#1325
2026-10-18 14:27:31 +00:00
k.renner 74189e7ec7 Merge pull request 'chore(authentik): run the samba-bridge images from konairius/* (window)' (#1324) from claude/authentik-konairius-images into master
Reviewed-on: k.renner/homelab#1324
2026-10-18 14:26:58 +00:00
claude-botandClaude Opus 5.5 21d7d181f4 fix(review-bot): review konairius/authentik-samba-bridge
review-bot reviewed: comment
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
The repo moved to the konairius org. Its webhooks now carry
konairius/authentik-samba-bridge, which REPOS did not list, so the bot
ignored them, and the sweep would post through the old path, where Gitea
API writes do not survive the redirect (seen with the wiki on notes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 16:23:25 +02:00
claude-botandClaude Opus 5.5 413f675bfa chore(authentik): run the samba-bridge images from konairius/*
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: comment
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
authentik-samba-bridge moved to the konairius org and publishes as
registry-bot (konairius/authentik-samba-bridge#7, pipeline #42):
gitea.renner.uno/konairius/authentik-samba-bridge-{server,ldap-outpost}
:2026.8.3-9dd92dd. Between the deployed 872f754 and 9dd92dd only CI, OCI
labels and pyproject URLs changed; the bridge code is the same.

- HelmRelease: server image repository + tag 2026.8.3-9dd92dd.
- LDAP outpost blueprint: container_image 2026.8.3-9dd92dd.
- Repo URLs in the comments follow.

Restarts the authentik server and worker pods and redeploys the LDAP outpost.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 16:22:55 +02:00
k.renner c782dca48c Merge pull request 'chore: point the wiki and Renovate at konairius/notes and konairius/renovate-config' (#1323) from claude/notes-renovate-konairius into master
Reviewed-on: k.renner/homelab#1323
2026-10-18 14:15:49 +00:00
claude-botandClaude Opus 5.5 8e930e17b1 chore: point the wiki and Renovate at konairius/notes and konairius/renovate-config
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: comment
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Both repos moved from k.renner to the konairius org on 2026-10-18. The old
paths still resolve through Gitea's redirect (the preset API read of
k.renner/renovate-config returns 200), so nothing is broken; this removes the
dependency on the redirect.

- wiki: Wiki__NoteStore__Gitea__Repository = konairius/notes.
- renovate.json extends local>konairius/renovate-config; the runner comments follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 16:13:09 +02:00
k.renner 3661dedd19 Merge pull request 'chore(paperless): pull vsftpd from konairius/vsftpd' (#1322) from claude/vsftpd-konairius-image into master
ci/woodpecker/cron/alert-monitor Pipeline failed
Reviewed-on: k.renner/homelab#1322
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-10-18 14:09:08 +00:00
claude-botandClaude Opus 5.5 27fc56278f chore(paperless): pull vsftpd from konairius/vsftpd
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: approve
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
The vsftpd repo moved to the konairius org and its pipeline now publishes as
registry-bot to gitea.renner.uno/konairius/vsftpd (konairius/vsftpd#3,
pipeline #12 pushed build.1792310564, same tags as before). The ImageRepository,
the ftp-scanner image and the renovate ignore list follow. The new path is
anonymously pullable like the old one, so no pull secret is needed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 10:03:55 +02:00
k.renner 98d2533c42 Merge pull request 'fix(openbao): give the registry-bot token read:repository for its pull check' (#1321) from claude/registry-bot-read-scope into master
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline failed
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#1321
2026-10-18 07:48:09 +00:00
claude-botandClaude Opus 5.5 65a22139f6 fix(openbao): give the registry-bot token read:repository for its pull check
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: comment
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
The first run of registry-bot-secret-rotate minted the token but failed
verification: "failed verification (pull=false push=true)". The rotator
checks a new token by git-pulling the private konairius/vsftpd, and a
write:package-only token cannot read code even though the publishers team
grants code read: token scopes cap the account's rights. read:repository is
read-only and is what the pull needs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 09:44:40 +02:00
k.renner 47e6f493d0 Merge pull request 'feat(openbao): rotate a registry-bot token for konairius image publishing' (#1320) from claude/registry-bot into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#1320
2026-10-18 07:37:09 +00:00
security-bot 5eeee9018f chore(image-automation): bump gitea.renner.uno/gitea_admin/review-bot:2026-10-18-0706
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-10-18 07:15:06 +00:00
k.renner ac33b0a9f2 Merge pull request 'chore(ci): bump ci-claude image to 2.1.302' (#1318) from ci-image/claude-2.1.302 into master
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: k.renner/homelab#1318
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-10-18 07:06:23 +00:00
k.renner 7090f130f8 Merge branch 'master' into ci-image/claude-2.1.302
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: approve
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
2026-10-18 06:56:43 +00:00
claude-bot 4cba950ef2 chore(ci): bump ci-claude image to 2.1.302
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
review-bot reviewed: approve
2026-10-18 00:01:26 +00:00
95 changed files with 7532 additions and 83 deletions
+1 -1
View File
@@ -101,7 +101,7 @@ steps:
bash scripts/ci/make-diag-kubeconfig.sh
- name: alert-triage-agent
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.296
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.303
environment:
CLAUDE_CODE_OAUTH_TOKEN:
from_secret: claude_code_oauth_token
+26
View File
@@ -428,6 +428,32 @@ steps:
--destination gitea.renner.uno/gitea_admin/authentik-gate-sync:$TAG \
--destination gitea.renner.uno/gitea_admin/authentik-gate-sync:latest
# 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
directory: ci/images/kuma-sync
when:
- event: deployment
- event: push
path: ["ci/images/kuma-sync/**", ".woodpecker/build-ci-images.yml"]
environment:
GITEA_USER: gitea_admin
GITEA_TOKEN:
from_secret: ci_images_token
commands:
- mkdir -p /kaniko/.docker
- AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n')
- printf '{"auths":{"gitea.renner.uno":{"auth":"%s"}}}' "$AUTH" > /kaniko/.docker/config.json
# YYYY-MM-DD-HHMM: the image-automation ImagePolicy picks the newest.
- TAG=$(date +%Y-%m-%d-%H%M)
- |
/kaniko/executor \
--context . \
--dockerfile Containerfile \
--destination gitea.renner.uno/gitea_admin/kuma-sync:$TAG \
--destination gitea.renner.uno/gitea_admin/kuma-sync:latest
# bird: BGP daemon for the pod-network mesh over pod0 (#576). Runtime
# artifact like heating-can; same registry so the audit tracks it.
- name: build-bird
+4 -4
View File
@@ -35,25 +35,25 @@ when:
steps:
- name: model-selection-tests
depends_on: []
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.296
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.303
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.296
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.303
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.296
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.303
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.296
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.303
commands:
- sh scripts/ci/test-yaml-citations.sh
+47
View File
@@ -0,0 +1,47 @@
when:
# Deploy only on master (merge) + manual — never auto-deploy from a branch.
- event: push
branch: master
path:
- "ansible/roles/uptime-kuma/**"
- "ansible/plays/install-uptime-kuma.yml"
- ".woodpecker/deploy-uptime-kuma.yml"
- event: deployment
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "deploy-uptime-kuma"'
- event: cron
cron: uptime-kuma-token-renew
timeout: 30
steps:
- name: renew-openbao-token
image: gitea.renner.uno/gitea_admin/ci-ansible:2026-10-18
environment:
VAULT_TOKEN:
from_secret: openbao_uptime_kuma_token
commands:
- 'curl -sSf -X POST -H "X-Vault-Token: $VAULT_TOKEN" https://openbao.renner.uno/v1/auth/token/renew-self -o /dev/null'
- name: deploy-uptime-kuma
image: gitea.renner.uno/gitea_admin/ci-ansible:2026-10-18
when:
- event: [push, deployment]
environment:
ANSIBLE_FORCE_COLOR: "true"
ANSIBLE_HOST_KEY_CHECKING: "false"
ANSIBLE_ROLES_PATH: "../roles:./roles"
SSH_PRIVATE_KEY:
from_secret: ssh_private_key
VAULT_PASS:
from_secret: vault_pass
VAULT_TOKEN:
from_secret: openbao_uptime_kuma_token
commands:
- mkdir -p ~/.ssh
- echo "$SSH_PRIVATE_KEY" | base64 -d > ~/.ssh/id_ed25519
- chmod 600 ~/.ssh/id_ed25519
- printf "Host *\n\tStrictHostKeyChecking no\n\tUserKnownHostsFile /dev/null\n" > ~/.ssh/config
- cd ansible
- echo "$VAULT_PASS" > .vault_pass && chmod 600 .vault_pass
- export ANSIBLE_VAULT_PASSWORD_FILE=.vault_pass
- ansible-playbook -i inventory/ plays/install-uptime-kuma.yml -v
+1 -1
View File
@@ -26,7 +26,7 @@ steps:
# fetch-models.sh response mapping. No cluster, no secrets, no network — the
# ci-claude image just provides sh + jq. Keeps the flag-injection regression
# test from rotting (it exercises the exact injection shapes).
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.296
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.303
commands:
- sh scripts/ci/test-model-selection.sh
+1 -1
View File
@@ -62,7 +62,7 @@ steps:
- bash scripts/ci/make-diag-kubeconfig.sh
- name: issue-fix-agent
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.296
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.303
environment:
CLAUDE_CODE_OAUTH_TOKEN:
from_secret: claude_code_oauth_token
+3 -3
View File
@@ -18,12 +18,12 @@ when:
steps:
- name: iac-scan
image: aquasec/trivy:latest@sha256:af6acf9a6b85dfe389a1941505c0ce9efef52a4719635e1a962f022a3d855daa
image: aquasec/trivy:latest@sha256:2416d1f74045b57274df1a340eee6b510cacd73c66a6ccb8da092b117eb7d222
commands:
- trivy fs --format json -o trivy-iac.json --severity CRITICAL,HIGH --ignore-unfixed .
- name: image-scans
image: aquasec/trivy:latest@sha256:af6acf9a6b85dfe389a1941505c0ce9efef52a4719635e1a962f022a3d855daa
image: aquasec/trivy:latest@sha256:2416d1f74045b57274df1a340eee6b510cacd73c66a6ccb8da092b117eb7d222
commands:
- trivy image --download-db-only
- |
@@ -70,7 +70,7 @@ steps:
# this step is skipped — otherwise we'd fork-bomb PRs.
when:
- branch: master
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.296
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.303
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.296
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.303
environment:
CLAUDE_CODE_OAUTH_TOKEN:
from_secret: claude_code_oauth_token
+15
View File
@@ -0,0 +1,15 @@
---
# Deploy Uptime Kuma to the NetBird VPS
# Usage: ansible-playbook -i inventory/ plays/install-uptime-kuma.yml
#
# Prerequisites:
# - netbird-server play applied (docker, Traefik on netbird_netbird)
# - OpenBao homelab/infrastructure/uptime-kuma with keys username, password
# - VAULT_TOKEN in the environment (policy: uptime-kuma-vps)
# - DNS for kuma.renner.uno pointing at the VPS
- name: Deploy Uptime Kuma
hosts: netbird_server
become: true
roles:
- uptime-kuma
@@ -0,0 +1,11 @@
---
uptime_kuma_version: "2.5.6"
uptime_kuma_host: kuma.renner.uno
# Not routed yet; reserved for the public status page.
uptime_kuma_status_host: uptime.renner.uno
uptime_kuma_dir: /opt/uptime-kuma
uptime_kuma_traefik_network: netbird_netbird
uptime_kuma_openbao_url: https://openbao.renner.uno
uptime_kuma_openbao_mount: homelab
uptime_kuma_openbao_path: infrastructure/uptime-kuma
@@ -0,0 +1,46 @@
// Creates the Uptime Kuma admin user on first start; exits 0 if already set up.
// Credentials come from KUMA_SETUP_USERNAME / KUMA_SETUP_PASSWORD only.
const { io } = require("socket.io-client");
const username = process.env.KUMA_SETUP_USERNAME;
const password = process.env.KUMA_SETUP_PASSWORD;
if (!username || !password) {
console.error("KUMA_SETUP_USERNAME and KUMA_SETUP_PASSWORD must be set");
process.exit(2);
}
const socket = io("http://127.0.0.1:3001", { transports: ["websocket"], reconnection: false });
const timer = setTimeout(() => {
console.error("timeout: no response from Uptime Kuma within 30s");
process.exit(1);
}, 30000);
let finished = false;
function done(code, msg) {
if (finished) return;
finished = true;
clearTimeout(timer);
(code === 0 ? console.log : console.error)(msg);
socket.close();
process.exit(code);
}
socket.on("connect_error", (err) => done(1, `connect_error: ${err.message}`));
socket.on("disconnect", (reason) => done(1, `disconnected before setup finished: ${reason}`));
// Kuma registers socket handlers only after emitting info.
socket.once("info", () => {
socket.emit("needSetup", (needSetup) => {
if (!needSetup) {
done(0, "already set up");
return;
}
socket.emit("setup", username, password, (res) => {
if (res && res.ok) {
done(0, "admin user created");
} else {
done(1, `setup failed: ${res && res.msg ? res.msg : JSON.stringify(res)}`);
}
});
});
});
+103
View File
@@ -0,0 +1,103 @@
---
# Admin bootstrap runs with the Traefik route disabled so /setup is never
# reachable from outside; the route is enabled once the admin exists.
- name: Read admin credentials from OpenBao
community.hashi_vault.vault_kv2_get:
url: "{{ uptime_kuma_openbao_url }}"
engine_mount_point: "{{ uptime_kuma_openbao_mount }}"
path: "{{ uptime_kuma_openbao_path }}"
auth_method: token
delegate_to: localhost
become: false
run_once: true
register: _uptime_kuma_secret
no_log: true
- name: Ensure Uptime Kuma directories exist
ansible.builtin.file:
path: "{{ item }}"
state: directory
mode: "0755"
loop:
- "{{ uptime_kuma_dir }}"
- "{{ uptime_kuma_dir }}/data"
- name: Deploy kuma-setup.js
ansible.builtin.copy:
src: kuma-setup.js
dest: "{{ uptime_kuma_dir }}/kuma-setup.js"
mode: "0644"
- name: Check for setup marker
ansible.builtin.stat:
path: "{{ uptime_kuma_dir }}/data/.setup-done"
register: _uptime_kuma_marker
- name: Deploy docker-compose.yml
ansible.builtin.template:
src: docker-compose.yml.j2
dest: "{{ uptime_kuma_dir }}/docker-compose.yml"
mode: "0644"
vars:
uptime_kuma_route_enabled: "{{ _uptime_kuma_marker.stat.exists }}"
- name: Pull Uptime Kuma image
ansible.builtin.command: docker compose pull
args:
chdir: "{{ uptime_kuma_dir }}"
changed_when: false
- name: Start Uptime Kuma
ansible.builtin.command: docker compose up -d
args:
chdir: "{{ uptime_kuma_dir }}"
changed_when: false
- name: Wait for Uptime Kuma on loopback
ansible.builtin.uri:
url: http://127.0.0.1:3001
status_code: 200
register: _uptime_kuma_up
until: _uptime_kuma_up.status == 200
retries: 30
delay: 5
- name: Create the admin user if Uptime Kuma is not set up
ansible.builtin.command: docker compose exec -T -e KUMA_SETUP_USERNAME -e KUMA_SETUP_PASSWORD uptime-kuma node /app/kuma-setup.js
args:
chdir: "{{ uptime_kuma_dir }}"
environment:
KUMA_SETUP_USERNAME: "{{ _uptime_kuma_secret.secret.username }}"
KUMA_SETUP_PASSWORD: "{{ _uptime_kuma_secret.secret.password }}"
register: _uptime_kuma_setup
changed_when: "'admin user created' in _uptime_kuma_setup.stdout"
failed_when: false
no_log: true
- name: Fail if Uptime Kuma setup failed
ansible.builtin.fail:
msg: "kuma-setup.js failed: rc={{ _uptime_kuma_setup.rc | default('undefined') }} stdout={{ _uptime_kuma_setup.stdout | default('') }} stderr={{ _uptime_kuma_setup.stderr | default('') }}"
when: (_uptime_kuma_setup.rc | default(1)) != 0
- name: Record setup completion
ansible.builtin.copy:
content: ""
dest: "{{ uptime_kuma_dir }}/data/.setup-done"
mode: "0644"
force: false
- name: Deploy docker-compose.yml with the route enabled
ansible.builtin.template:
src: docker-compose.yml.j2
dest: "{{ uptime_kuma_dir }}/docker-compose.yml"
mode: "0644"
vars:
uptime_kuma_route_enabled: true
- name: Start Uptime Kuma with the route enabled
ansible.builtin.command: docker compose up -d
args:
chdir: "{{ uptime_kuma_dir }}"
register: _uptime_kuma_route
changed_when: "'Recreate' in _uptime_kuma_route.stderr or 'Started' in _uptime_kuma_route.stderr"
@@ -0,0 +1,38 @@
services:
uptime-kuma:
image: louislam/uptime-kuma:{{ uptime_kuma_version }}-slim
container_name: uptime-kuma
restart: unless-stopped
environment:
- UPTIME_KUMA_DB_TYPE=sqlite
ports:
- "127.0.0.1:3001:3001"
volumes:
- {{ uptime_kuma_dir }}/data:/app/data
- {{ uptime_kuma_dir }}/kuma-setup.js:/app/kuma-setup.js:ro
labels:
- "traefik.enable={{ uptime_kuma_route_enabled | lower }}"
- "traefik.http.routers.uptime-kuma.rule=Host(`{{ uptime_kuma_host }}`)"
- "traefik.http.routers.uptime-kuma.entrypoints=websecure"
- "traefik.http.routers.uptime-kuma.tls=true"
- "traefik.http.routers.uptime-kuma.tls.certresolver=letsencrypt"
- "traefik.http.routers.uptime-kuma.service=uptime-kuma"
- "traefik.http.services.uptime-kuma.loadbalancer.server.port=3001"
- "traefik.docker.network={{ uptime_kuma_traefik_network }}"
networks:
kuma:
gw_priority: 100
traefik:
gw_priority: 0
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "2"
networks:
kuma:
enable_ipv6: true
traefik:
external: true
name: {{ uptime_kuma_traefik_network }}
+1
View File
@@ -18,6 +18,7 @@ RUN apk add --no-cache \
sshpass
RUN pip3 install --break-system-packages --no-cache-dir \
hvac \
proxmoxer \
requests
+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.14-slim@sha256:a2b82f3c48559aa0a8446d9af49826b6e2b2016f4cd2afabfe6013ec53729170 AS test
FROM python:3.15-slim@sha256:2da676046630d7bf14c6764ae1143dc7fa0377a53f94bf5a642f0b214aca5bce AS test
WORKDIR /src
COPY controller.py test_controller.py ./
RUN python3 -B -m unittest -v
FROM python:3.14-slim@sha256:a2b82f3c48559aa0a8446d9af49826b6e2b2016f4cd2afabfe6013ec53729170
FROM python:3.15-slim@sha256:2da676046630d7bf14c6764ae1143dc7fa0377a53f94bf5a642f0b214aca5bce
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:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1
FROM node:24-alpine@sha256:44fce696f38ba8c987c4db89954051000bbd05ffd8d8659ba15ed4a8602e937f
# 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
@@ -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.14-slim@sha256:a2b82f3c48559aa0a8446d9af49826b6e2b2016f4cd2afabfe6013ec53729170 AS build
FROM python:3.15-slim@sha256:2da676046630d7bf14c6764ae1143dc7fa0377a53f94bf5a642f0b214aca5bce 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.14-slim@sha256:a2b82f3c48559aa0a8446d9af49826b6e2b2016f4cd2afabfe6013ec53729170
FROM python:3.15-slim@sha256:2da676046630d7bf14c6764ae1143dc7fa0377a53f94bf5a642f0b214aca5bce
ARG OPEN3E_REF=6553c10453a6bae1210908f38a5af2481a46a3e5
ARG E3ONCAN_REF=461dcbdf97e8d72d2edb9929c63b39382af6c3a1
+1
View File
@@ -0,0 +1 @@
bin/
+1
View File
@@ -0,0 +1 @@
/bin/
+15
View File
@@ -0,0 +1,15 @@
# kuma-sync: reconciles KumaMonitor resources into Uptime Kuma. The unit tests
# (no tags, so no Docker and no envtest) gate the image: the final stage copies
# the binary out of this stage, so a failing test fails the build.
FROM golang:1.27@sha256:820153096fda4de6ac1b7194d12e416b7dd8f4e9a33d17e0345b111b945162d8 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go test ./...
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/kuma-sync ./cmd/kuma-sync
FROM gcr.io/distroless/static:nonroot@sha256:e2e927ec666bae08560abb3c55d0659eceabb657f56b6782ab500a9fc7f555e3
COPY --from=build /out/kuma-sync /kuma-sync
USER 65532
ENTRYPOINT ["/kuma-sync"]
+29
View File
@@ -0,0 +1,29 @@
CONTROLLER_GEN_VERSION := v0.23.0
ENVTEST_VERSION := v0.25.2
ENVTEST_K8S_VERSION := 1.37.0
BIN := $(CURDIR)/bin
CONTROLLER_GEN := $(BIN)/controller-gen
SETUP_ENVTEST := $(BIN)/setup-envtest
.PHONY: generate test test-envtest test-integration
$(CONTROLLER_GEN):
GOBIN=$(BIN) go install sigs.k8s.io/controller-tools/cmd/controller-gen@$(CONTROLLER_GEN_VERSION)
$(SETUP_ENVTEST):
GOBIN=$(BIN) go install sigs.k8s.io/controller-runtime/tools/setup-envtest@$(ENVTEST_VERSION)
generate: $(CONTROLLER_GEN)
$(CONTROLLER_GEN) object paths=./api/...
$(CONTROLLER_GEN) crd paths=./api/... output:crd:artifacts:config=config/crd
$(CONTROLLER_GEN) rbac:roleName=kuma-sync paths=./internal/... output:rbac:artifacts:config=config/rbac
test:
go test ./...
test-integration: $(SETUP_ENVTEST)
KUBEBUILDER_ASSETS="$$($(SETUP_ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(BIN)/k8s -p path)" go test -tags integration ./...
test-envtest:$(SETUP_ENVTEST)
KUBEBUILDER_ASSETS="$$($(SETUP_ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(BIN)/k8s -p path)" go test -tags envtest ./...
@@ -0,0 +1,15 @@
// +kubebuilder:object:generate=true
// +groupName=uptime.renner.uno
package v1alpha1
import (
"k8s.io/apimachinery/pkg/runtime/schema"
"sigs.k8s.io/controller-runtime/pkg/scheme"
)
var (
GroupVersion = schema.GroupVersion{Group: "uptime.renner.uno", Version: "v1alpha1"}
SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion}
AddToScheme = SchemeBuilder.AddToScheme
)
@@ -0,0 +1,132 @@
package v1alpha1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
const ConditionReady = "Ready"
// LocalRef references a KumaMonitor in the same namespace.
type LocalRef struct {
Name string `json:"name"`
}
// HTTPSpec configures http, keyword and json-query monitors.
type HTTPSpec struct {
// +kubebuilder:validation:MinLength=1
URL string `json:"url"`
// +kubebuilder:default=GET
Method string `json:"method,omitempty"`
// +kubebuilder:default={"200-299"}
AcceptedStatusCodes []string `json:"acceptedStatusCodes,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
Body string `json:"body,omitempty"`
MaxRedirects *int32 `json:"maxRedirects,omitempty"`
IgnoreTLS bool `json:"ignoreTLS,omitempty"`
ExpiryNotification bool `json:"expiryNotification,omitempty"`
}
type KeywordSpec struct {
// +kubebuilder:validation:MinLength=1
Keyword string `json:"keyword"`
Invert bool `json:"invert,omitempty"`
}
type JSONQuerySpec struct {
// JSONata expression.
// +kubebuilder:validation:MinLength=1
Expression string `json:"expression"`
// +kubebuilder:validation:Enum="==";"!=";"<";"<=";">";">=";contains
Operator string `json:"operator"`
Expected string `json:"expected"`
}
type DNSSpec struct {
// +kubebuilder:validation:MinLength=1
Hostname string `json:"hostname"`
// +kubebuilder:validation:Enum=A;AAAA;CNAME;MX;NS;PTR;SOA;SRV;TXT;CAA
ResolveType string `json:"resolveType"`
// +kubebuilder:default="1.1.1.1"
ResolveServer string `json:"resolveServer,omitempty"`
// +kubebuilder:validation:Minimum=1
// +kubebuilder:validation:Maximum=65535
// +kubebuilder:default=53
Port int32 `json:"port,omitempty"`
}
type PortSpec struct {
// +kubebuilder:validation:MinLength=1
Hostname string `json:"hostname"`
// +kubebuilder:validation:Minimum=1
// +kubebuilder:validation:Maximum=65535
Port int32 `json:"port"`
}
// +kubebuilder:validation:XValidation:rule="(self.type in ['http','keyword','json-query']) == has(self.http)",message="http is required for types http, keyword and json-query, and forbidden otherwise"
// +kubebuilder:validation:XValidation:rule="(self.type == 'keyword') == has(self.keyword)",message="keyword is required for type keyword and forbidden otherwise"
// +kubebuilder:validation:XValidation:rule="(self.type == 'json-query') == has(self.jsonQuery)",message="jsonQuery is required for type json-query and forbidden otherwise"
// +kubebuilder:validation:XValidation:rule="(self.type == 'dns') == has(self.dns)",message="dns is required for type dns and forbidden otherwise"
// +kubebuilder:validation:XValidation:rule="(self.type == 'port') == has(self.port)",message="port is required for type port and forbidden otherwise"
// +kubebuilder:validation:XValidation:rule="self.type != 'group' || !(has(self.http) || has(self.keyword) || has(self.jsonQuery) || has(self.dns) || has(self.port))",message="type group takes no type blocks"
type KumaMonitorSpec struct {
// +kubebuilder:validation:Enum=group;http;keyword;json-query;dns;port
Type string `json:"type"`
// +kubebuilder:validation:MinLength=1
DisplayName string `json:"displayName"`
ParentRef *LocalRef `json:"parentRef,omitempty"`
// Seconds.
// +kubebuilder:validation:Minimum=20
// +kubebuilder:default=60
Interval int32 `json:"interval,omitempty"`
// Seconds.
// +kubebuilder:validation:Minimum=20
// +kubebuilder:default=60
RetryInterval int32 `json:"retryInterval,omitempty"`
// +kubebuilder:validation:Minimum=0
// +kubebuilder:default=0
MaxRetries int32 `json:"maxRetries,omitempty"`
// Seconds.
// +kubebuilder:validation:Minimum=1
Timeout *int32 `json:"timeout,omitempty"`
Paused bool `json:"paused,omitempty"`
Description string `json:"description,omitempty"`
HTTP *HTTPSpec `json:"http,omitempty"`
Keyword *KeywordSpec `json:"keyword,omitempty"`
JSONQuery *JSONQuerySpec `json:"jsonQuery,omitempty"`
DNS *DNSSpec `json:"dns,omitempty"`
Port *PortSpec `json:"port,omitempty"`
}
type KumaMonitorStatus struct {
MonitorID int64 `json:"monitorID,omitempty"`
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
// +listType=map
// +listMapKey=type
Conditions []metav1.Condition `json:"conditions,omitempty"`
}
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:resource:path=kumamonitors,scope=Namespaced
// +kubebuilder:printcolumn:name="Type",type=string,JSONPath=`.spec.type`
// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status`
type KumaMonitor struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec KumaMonitorSpec `json:"spec"`
Status KumaMonitorStatus `json:"status,omitempty"`
}
// +kubebuilder:object:root=true
type KumaMonitorList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []KumaMonitor `json:"items"`
}
func init() {
SchemeBuilder.Register(&KumaMonitor{}, &KumaMonitorList{})
}
@@ -0,0 +1,198 @@
//go:build envtest
package v1alpha1
import (
"context"
"path/filepath"
"strings"
"testing"
"k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/envtest"
)
var k8s client.Client
func TestMain(m *testing.M) {
env := &envtest.Environment{
CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd")},
ErrorIfCRDPathMissing: true,
}
cfg, err := env.Start()
if err != nil {
panic(err)
}
scheme := runtime.NewScheme()
if err := AddToScheme(scheme); err != nil {
panic(err)
}
k8s, err = client.New(cfg, client.Options{Scheme: scheme})
if err != nil {
panic(err)
}
code := m.Run()
if err := env.Stop(); err != nil {
panic(err)
}
if code != 0 {
panic("tests failed")
}
}
func monitor(name string, spec KumaMonitorSpec) *KumaMonitor {
return &KumaMonitor{
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default"},
Spec: spec,
}
}
func TestValidKeyword(t *testing.T) {
m := monitor("valid-keyword", KumaMonitorSpec{
Type: "keyword",
DisplayName: "kw",
HTTP: &HTTPSpec{URL: "https://example.com"},
Keyword: &KeywordSpec{Keyword: "ok"},
})
if err := k8s.Create(context.Background(), m); err != nil {
t.Fatalf("expected accept, got %v", err)
}
}
func TestKeywordWithoutBlock(t *testing.T) {
m := monitor("kw-no-block", KumaMonitorSpec{
Type: "keyword",
DisplayName: "kw",
HTTP: &HTTPSpec{URL: "https://example.com"},
})
expectInvalid(t, m, "keyword is required for type keyword")
}
func expectInvalid(t *testing.T, m *KumaMonitor, wants ...string) {
t.Helper()
err := k8s.Create(context.Background(), m)
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 TestGroupWithHTTP(t *testing.T) {
m := monitor("group-http", KumaMonitorSpec{
Type: "group",
DisplayName: "g",
HTTP: &HTTPSpec{URL: "https://example.com"},
})
expectInvalid(t, m, "type group takes no type blocks")
}
func TestDNSWithHTTP(t *testing.T) {
m := monitor("dns-http", KumaMonitorSpec{
Type: "dns",
DisplayName: "d",
HTTP: &HTTPSpec{URL: "https://example.com"},
DNS: &DNSSpec{Hostname: "example.com", ResolveType: "A"},
})
expectInvalid(t, m, "http is required for types http, keyword and json-query")
}
func TestPushTypeRejected(t *testing.T) {
m := monitor("push", KumaMonitorSpec{Type: "push", DisplayName: "p"})
expectInvalid(t, m, "spec.type", "Unsupported value")
}
func TestEmptyURLRejected(t *testing.T) {
m := monitor("empty-url", KumaMonitorSpec{
Type: "http",
DisplayName: "h",
HTTP: &HTTPSpec{URL: ""},
})
expectInvalid(t, m, "spec.http.url")
}
func TestNegativeMaxRetriesRejected(t *testing.T) {
m := monitor("neg-retries", KumaMonitorSpec{
Type: "http",
DisplayName: "h",
MaxRetries: -1,
HTTP: &HTTPSpec{URL: "https://example.com"},
})
expectInvalid(t, m, "spec.maxRetries")
}
func TestJSONQueryBadOperator(t *testing.T) {
m := monitor("jq-bad-op", KumaMonitorSpec{
Type: "json-query",
DisplayName: "jq",
HTTP: &HTTPSpec{URL: "https://example.com"},
JSONQuery: &JSONQuerySpec{Expression: "a", Operator: "=~", Expected: "b"},
})
expectInvalid(t, m, "spec.jsonQuery.operator", "Unsupported value")
}
func TestIntervalBelowMin(t *testing.T) {
m := monitor("interval-low", KumaMonitorSpec{
Type: "http",
DisplayName: "h",
Interval: 5,
HTTP: &HTTPSpec{URL: "https://example.com"},
})
expectInvalid(t, m, "spec.interval")
}
func TestDefaults(t *testing.T) {
ctx := context.Background()
m := monitor("defaults", KumaMonitorSpec{
Type: "http",
DisplayName: "h",
HTTP: &HTTPSpec{URL: "https://example.com"},
})
if err := k8s.Create(ctx, m); err != nil {
t.Fatal(err)
}
got := &KumaMonitor{}
if err := k8s.Get(ctx, client.ObjectKeyFromObject(m), got); err != nil {
t.Fatal(err)
}
if got.Spec.Interval != 60 {
t.Fatalf("interval = %v, want 60", got.Spec.Interval)
}
codes := got.Spec.HTTP.AcceptedStatusCodes
if len(codes) != 1 || codes[0] != "200-299" {
t.Fatalf("acceptedStatusCodes = %v", codes)
}
}
// The controller ignores updates that leave metadata.generation unchanged, so
// deleting a resource that has a finalizer must bump it.
func TestDeletionBumpsGeneration(t *testing.T) {
ctx := context.Background()
m := monitor("deletion-gen", KumaMonitorSpec{
Type: "http", DisplayName: "h", HTTP: &HTTPSpec{URL: "https://example.com"},
})
m.Finalizers = []string{"test/finalizer"}
if err := k8s.Create(ctx, m); err != nil {
t.Fatal(err)
}
before := m.Generation
if err := k8s.Delete(ctx, m); err != nil {
t.Fatal(err)
}
got := &KumaMonitor{}
if err := k8s.Get(ctx, client.ObjectKeyFromObject(m), got); err != nil {
t.Fatal(err)
}
if got.DeletionTimestamp.IsZero() {
t.Fatal("expected deletionTimestamp to be set")
}
if got.Generation <= before {
t.Fatalf("generation %d did not increase past %d on deletion", got.Generation, before)
}
}
@@ -0,0 +1,248 @@
//go:build !ignore_autogenerated
// Code generated by controller-gen. DO NOT EDIT.
package v1alpha1
import (
"k8s.io/apimachinery/pkg/apis/meta/v1"
runtime "k8s.io/apimachinery/pkg/runtime"
)
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *DNSSpec) DeepCopyInto(out *DNSSpec) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSSpec.
func (in *DNSSpec) DeepCopy() *DNSSpec {
if in == nil {
return nil
}
out := new(DNSSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *HTTPSpec) DeepCopyInto(out *HTTPSpec) {
*out = *in
if in.AcceptedStatusCodes != nil {
in, out := &in.AcceptedStatusCodes, &out.AcceptedStatusCodes
*out = make([]string, len(*in))
copy(*out, *in)
}
if in.Headers != nil {
in, out := &in.Headers, &out.Headers
*out = make(map[string]string, len(*in))
for key, val := range *in {
(*out)[key] = val
}
}
if in.MaxRedirects != nil {
in, out := &in.MaxRedirects, &out.MaxRedirects
*out = new(int32)
**out = **in
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HTTPSpec.
func (in *HTTPSpec) DeepCopy() *HTTPSpec {
if in == nil {
return nil
}
out := new(HTTPSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *JSONQuerySpec) DeepCopyInto(out *JSONQuerySpec) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new JSONQuerySpec.
func (in *JSONQuerySpec) DeepCopy() *JSONQuerySpec {
if in == nil {
return nil
}
out := new(JSONQuerySpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *KeywordSpec) DeepCopyInto(out *KeywordSpec) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KeywordSpec.
func (in *KeywordSpec) DeepCopy() *KeywordSpec {
if in == nil {
return nil
}
out := new(KeywordSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *KumaMonitor) DeepCopyInto(out *KumaMonitor) {
*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 KumaMonitor.
func (in *KumaMonitor) DeepCopy() *KumaMonitor {
if in == nil {
return nil
}
out := new(KumaMonitor)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *KumaMonitor) 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 *KumaMonitorList) DeepCopyInto(out *KumaMonitorList) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ListMeta.DeepCopyInto(&out.ListMeta)
if in.Items != nil {
in, out := &in.Items, &out.Items
*out = make([]KumaMonitor, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KumaMonitorList.
func (in *KumaMonitorList) DeepCopy() *KumaMonitorList {
if in == nil {
return nil
}
out := new(KumaMonitorList)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *KumaMonitorList) 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 *KumaMonitorSpec) DeepCopyInto(out *KumaMonitorSpec) {
*out = *in
if in.ParentRef != nil {
in, out := &in.ParentRef, &out.ParentRef
*out = new(LocalRef)
**out = **in
}
if in.Timeout != nil {
in, out := &in.Timeout, &out.Timeout
*out = new(int32)
**out = **in
}
if in.HTTP != nil {
in, out := &in.HTTP, &out.HTTP
*out = new(HTTPSpec)
(*in).DeepCopyInto(*out)
}
if in.Keyword != nil {
in, out := &in.Keyword, &out.Keyword
*out = new(KeywordSpec)
**out = **in
}
if in.JSONQuery != nil {
in, out := &in.JSONQuery, &out.JSONQuery
*out = new(JSONQuerySpec)
**out = **in
}
if in.DNS != nil {
in, out := &in.DNS, &out.DNS
*out = new(DNSSpec)
**out = **in
}
if in.Port != nil {
in, out := &in.Port, &out.Port
*out = new(PortSpec)
**out = **in
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KumaMonitorSpec.
func (in *KumaMonitorSpec) DeepCopy() *KumaMonitorSpec {
if in == nil {
return nil
}
out := new(KumaMonitorSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *KumaMonitorStatus) DeepCopyInto(out *KumaMonitorStatus) {
*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 KumaMonitorStatus.
func (in *KumaMonitorStatus) DeepCopy() *KumaMonitorStatus {
if in == nil {
return nil
}
out := new(KumaMonitorStatus)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *LocalRef) DeepCopyInto(out *LocalRef) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LocalRef.
func (in *LocalRef) DeepCopy() *LocalRef {
if in == nil {
return nil
}
out := new(LocalRef)
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
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PortSpec.
func (in *PortSpec) DeepCopy() *PortSpec {
if in == nil {
return nil
}
out := new(PortSpec)
in.DeepCopyInto(out)
return out
}
@@ -0,0 +1,32 @@
package main
import (
"errors"
"fmt"
)
type config struct {
URL string
Username string
Password string
}
// loadConfig reads the Kuma connection settings from the environment. It
// names every missing variable.
func loadConfig(getenv func(string) string) (config, error) {
var cfg config
var missing []error
for _, v := range []struct {
name string
dst *string
}{
{"KUMA_URL", &cfg.URL},
{"KUMA_USERNAME", &cfg.Username},
{"KUMA_PASSWORD", &cfg.Password},
} {
if *v.dst = getenv(v.name); *v.dst == "" {
missing = append(missing, fmt.Errorf("missing required environment variable %s", v.name))
}
}
return cfg, errors.Join(missing...)
}
@@ -0,0 +1,38 @@
package main
import (
"strings"
"testing"
)
func TestLoadConfig(t *testing.T) {
env := func(m map[string]string) func(string) string {
return func(k string) string { return m[k] }
}
full := map[string]string{"KUMA_URL": "http://kuma", "KUMA_USERNAME": "u", "KUMA_PASSWORD": "p"}
cfg, err := loadConfig(env(full))
if err != nil || cfg != (config{URL: "http://kuma", Username: "u", Password: "p"}) {
t.Fatalf("cfg=%+v err=%v", cfg, err)
}
for _, name := range []string{"KUMA_URL", "KUMA_USERNAME", "KUMA_PASSWORD"} {
m := map[string]string{}
for k, v := range full {
if k != name {
m[k] = v
}
}
_, err := loadConfig(env(m))
if err == nil || !strings.Contains(err.Error(), name) {
t.Errorf("missing %s: err = %v, want it to name the variable", name, err)
}
}
_, err = loadConfig(env(nil))
for _, name := range []string{"KUMA_URL", "KUMA_USERNAME", "KUMA_PASSWORD"} {
if err == nil || !strings.Contains(err.Error(), name) {
t.Errorf("all missing: err = %v, want %s named", err, name)
}
}
}
+56
View File
@@ -0,0 +1,56 @@
// Command kuma-sync reconciles KumaMonitor resources into Uptime Kuma.
package main
import (
"fmt"
"io"
"os"
"time"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/log/zap"
"gitea.renner.uno/k.renner/homelab/kuma-sync/internal/app"
"gitea.renner.uno/k.renner/homelab/kuma-sync/internal/kumaclient"
)
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "kuma-sync:", err)
os.Exit(1)
}
}
func run() error {
cfg, err := loadConfig(os.Getenv)
if err != nil {
return err
}
ctrl.SetLogger(zap.New())
// The Kuma session lives as long as this context, so it is the signal
// context of the process.
ctx := ctrl.SetupSignalHandler()
kuma, err := kumaclient.NewBreml(ctx, cfg.URL, cfg.Username, cfg.Password)
if err != nil {
return fmt.Errorf("connect to Kuma: %w", err)
}
if c, ok := kuma.(io.Closer); ok {
defer c.Close()
}
mgr, err := app.NewManager(app.Options{
Config: ctrl.GetConfigOrDie(),
Kuma: kuma,
MetricsAddr: ":8080",
ProbeAddr: ":8081",
GCInterval: 5 * time.Minute,
OrphanGrace: time.Hour,
Resync: 5 * time.Minute,
})
if err != nil {
return err
}
return mgr.Start(ctx)
}
@@ -0,0 +1,295 @@
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.23.0
name: kumamonitors.uptime.renner.uno
spec:
group: uptime.renner.uno
names:
kind: KumaMonitor
listKind: KumaMonitorList
plural: kumamonitors
singular: kumamonitor
scope: Namespaced
versions:
- additionalPrinterColumns:
- jsonPath: .spec.type
name: Type
type: string
- jsonPath: .status.conditions[?(@.type=="Ready")].status
name: Ready
type: string
name: v1alpha1
schema:
openAPIV3Schema:
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:
description:
type: string
displayName:
minLength: 1
type: string
dns:
properties:
hostname:
minLength: 1
type: string
port:
default: 53
format: int32
maximum: 65535
minimum: 1
type: integer
resolveServer:
default: 1.1.1.1
type: string
resolveType:
enum:
- A
- AAAA
- CNAME
- MX
- NS
- PTR
- SOA
- SRV
- TXT
- CAA
type: string
required:
- hostname
- resolveType
type: object
http:
description: HTTPSpec configures http, keyword and json-query monitors.
properties:
acceptedStatusCodes:
default:
- 200-299
items:
type: string
type: array
body:
type: string
expiryNotification:
type: boolean
headers:
additionalProperties:
type: string
type: object
ignoreTLS:
type: boolean
maxRedirects:
format: int32
type: integer
method:
default: GET
type: string
url:
minLength: 1
type: string
required:
- url
type: object
interval:
default: 60
description: Seconds.
format: int32
minimum: 20
type: integer
jsonQuery:
properties:
expected:
type: string
expression:
description: JSONata expression.
minLength: 1
type: string
operator:
enum:
- ==
- '!='
- <
- <=
- '>'
- '>='
- contains
type: string
required:
- expected
- expression
- operator
type: object
keyword:
properties:
invert:
type: boolean
keyword:
minLength: 1
type: string
required:
- keyword
type: object
maxRetries:
default: 0
format: int32
minimum: 0
type: integer
parentRef:
description: LocalRef references a KumaMonitor in the same namespace.
properties:
name:
type: string
required:
- name
type: object
paused:
type: boolean
port:
properties:
hostname:
minLength: 1
type: string
port:
format: int32
maximum: 65535
minimum: 1
type: integer
required:
- hostname
- port
type: object
retryInterval:
default: 60
description: Seconds.
format: int32
minimum: 20
type: integer
timeout:
description: Seconds.
format: int32
minimum: 1
type: integer
type:
enum:
- group
- http
- keyword
- json-query
- dns
- port
type: string
required:
- displayName
- type
type: object
x-kubernetes-validations:
- message: http is required for types http, keyword and json-query, and
forbidden otherwise
rule: (self.type in ['http','keyword','json-query']) == has(self.http)
- message: keyword is required for type keyword and forbidden otherwise
rule: (self.type == 'keyword') == has(self.keyword)
- message: jsonQuery is required for type json-query and forbidden otherwise
rule: (self.type == 'json-query') == has(self.jsonQuery)
- message: dns is required for type dns and forbidden otherwise
rule: (self.type == 'dns') == has(self.dns)
- message: port is required for type port and forbidden otherwise
rule: (self.type == 'port') == has(self.port)
- message: type group takes no type blocks
rule: self.type != 'group' || !(has(self.http) || has(self.keyword)
|| has(self.jsonQuery) || has(self.dns) || has(self.port))
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
monitorID:
format: int64
type: integer
observedGeneration:
format: int64
type: integer
type: object
required:
- spec
type: object
served: true
storage: true
subresources:
status: {}
+43
View File
@@ -0,0 +1,43 @@
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: kuma-sync
rules:
- apiGroups:
- ""
resources:
- events
verbs:
- create
- patch
- apiGroups:
- ""
resources:
- namespaces
verbs:
- get
- apiGroups:
- uptime.renner.uno
resources:
- kumamonitors
verbs:
- get
- list
- patch
- update
- watch
- apiGroups:
- uptime.renner.uno
resources:
- kumamonitors/finalizers
verbs:
- update
- apiGroups:
- uptime.renner.uno
resources:
- kumamonitors/status
verbs:
- get
- patch
- update
+123
View File
@@ -0,0 +1,123 @@
module gitea.renner.uno/k.renner/homelab/kuma-sync
go 1.27
require (
github.com/breml/go-uptime-kuma-client v0.6.2
github.com/testcontainers/testcontainers-go v0.44.0
k8s.io/api v0.37.1
k8s.io/apimachinery v0.37.1
k8s.io/client-go v0.37.1
k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3
sigs.k8s.io/controller-runtime v0.25.2
)
require (
dario.cat/mergo v1.0.2 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v0.2.1 // indirect
github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.7.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/ebitengine/purego v0.10.1 // indirect
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
github.com/evanphx/json-patch/v5 v5.9.11 // indirect
github.com/felixge/httpsnoop v1.1.0 // indirect
github.com/fsnotify/fsnotify v1.10.1 // indirect
github.com/fxamacker/cbor/v2 v2.9.1 // indirect
github.com/go-logr/logr v1.4.4 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-logr/zapr v1.3.0 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-openapi/jsonpointer v1.0.0 // indirect
github.com/go-openapi/jsonreference v1.0.0 // indirect
github.com/go-openapi/swag v0.27.1 // indirect
github.com/go-openapi/swag/cmdutils v0.27.1 // indirect
github.com/go-openapi/swag/conv v0.27.1 // indirect
github.com/go-openapi/swag/fileutils v0.27.1 // indirect
github.com/go-openapi/swag/jsonutils v0.27.1 // indirect
github.com/go-openapi/swag/loading v0.27.1 // indirect
github.com/go-openapi/swag/mangling v0.27.1 // indirect
github.com/go-openapi/swag/netutils v0.27.1 // indirect
github.com/go-openapi/swag/pools v0.27.1 // indirect
github.com/go-openapi/swag/stringutils v0.27.1 // indirect
github.com/go-openapi/swag/typeutils v0.27.1 // indirect
github.com/go-openapi/swag/yamlutils v0.27.1 // indirect
github.com/google/gnostic-models v0.7.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.19.2 // indirect
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/maldikhan/go.socket.io v0.1.1 // indirect
github.com/maniartech/signals v1.3.1 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.2.0 // indirect
github.com/moby/moby/api v1.55.0 // indirect
github.com/moby/moby/client v0.5.0 // indirect
github.com/moby/patternmatcher v0.6.1 // indirect
github.com/moby/sys/sequential v0.7.0 // indirect
github.com/moby/sys/user v0.4.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/pquerna/otp v1.5.0 // indirect
github.com/prometheus/client_golang v1.24.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.70.0 // indirect
github.com/prometheus/procfs v0.21.1 // indirect
github.com/shirou/gopsutil/v4 v4.26.6 // indirect
github.com/sirupsen/logrus v1.10.1 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/stretchr/testify v1.12.1 // indirect
github.com/tklauser/go-sysconf v0.4.0 // indirect
github.com/tklauser/numcpus v0.12.0 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 // indirect
go.opentelemetry.io/otel v1.45.0 // indirect
go.opentelemetry.io/otel/metric v1.45.0 // indirect
go.opentelemetry.io/otel/trace v1.45.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.28.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/crypto v0.55.0 // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/term v0.45.0 // indirect
golang.org/x/text v0.41.0 // indirect
golang.org/x/time v0.15.0 // indirect
gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
k8s.io/apiextensions-apiserver v0.37.1 // indirect
k8s.io/klog/v2 v2.140.0 // indirect
k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad // indirect
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
sigs.k8s.io/randfill v1.0.0 // indirect
sigs.k8s.io/structured-merge-diff/v6 v6.4.2 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
)
replace github.com/maldikhan/go.socket.io => github.com/breml/go.socket.io v0.0.0-20260516193936-e70410c8cd31
+312
View File
@@ -0,0 +1,312 @@
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw=
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/breml/go-uptime-kuma-client v0.6.2 h1:C2Z56OFLNCHHeEkQ6T8B7Nuqkf8SYtPy3Nb1YhFMCUA=
github.com/breml/go-uptime-kuma-client v0.6.2/go.mod h1:LQkdwVs3TzTQZ92WvPIGgHoAC6rUEJkn+8ULkc51uJ8=
github.com/breml/go.socket.io v0.0.0-20260516193936-e70410c8cd31 h1:l5fVxSzbJHJ1jcLxmcOLVY0uxZFfB1yuztPJVtMfLRg=
github.com/breml/go.socket.io v0.0.0-20260516193936-e70410c8cd31/go.mod h1:H1EoWDJvqfV2WryM8F9og6ZkH7NsZDlHr0BRkKb58tY=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/containerd/continuity v0.4.5 h1:ZRoN1sXq9u7V6QoHMcVWGhOwDFqZ4B9i5H6un1Wh0x4=
github.com/containerd/continuity v0.4.5/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/cli v27.4.1+incompatible h1:VzPiUlRJ/xh+otB75gva3r05isHMo5wXDfPRi5/b4hI=
github.com/docker/cli v27.4.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/docker v27.1.1+incompatible h1:hO/M4MtV36kzKldqnA37IWhebRA+LnqqcqDja6kVaKY=
github.com/docker/docker v27.1.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k=
github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ=
github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU=
github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM=
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ=
github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ=
github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s=
github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y=
github.com/go-openapi/jsonreference v1.0.0 h1:jlmTr6torcd1YgDQvSfNmRtKzYDO4FGBkrAdlAVWnpY=
github.com/go-openapi/jsonreference v1.0.0/go.mod h1:jtwdyGbJk0Xhe5Y+rwtglQP6Sb1WZST4rT32LWB+sv0=
github.com/go-openapi/swag v0.27.1 h1:VotvOLWW8q/EAxB0YdsBBGC8XYyeL1YwBj2ungAGPNg=
github.com/go-openapi/swag v0.27.1/go.mod h1:GTkJPwHfhJp6MWr4/rCh64HVI3Ofu+tcsbfjfHmTxpE=
github.com/go-openapi/swag/cmdutils v0.27.1 h1:I7sYqaWVl5mq0NEmNQkAmFDyNin9ufvMX/p2zwtQaOE=
github.com/go-openapi/swag/cmdutils v0.27.1/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM=
github.com/go-openapi/swag/conv v0.27.1 h1:8wi9ZG+olmY1wXphl93EWniPtbSPkXM/feH7FgjsvrU=
github.com/go-openapi/swag/conv v0.27.1/go.mod h1:QbqMivkpKhC3g1B1GGGOJ6ANewI3S62dbzYu3Duowqs=
github.com/go-openapi/swag/fileutils v0.27.1 h1:QQqBSoi5mW4XpU85nS0mLcA+zAE6vLzrb0QkmLKf9oM=
github.com/go-openapi/swag/fileutils v0.27.1/go.mod h1:VvJFZLTZS0AI854gEQz5tk7dBESdLjiNUMSZ/th2ry8=
github.com/go-openapi/swag/jsonutils v0.27.1 h1:SVgK3i4USzCU5mibOOS/l4ea2h9UQXy7J7RNLTjuXjU=
github.com/go-openapi/swag/jsonutils v0.27.1/go.mod h1:tdlEpZqdcQ17uj6J4YdK9vd8It5qWMwjWXOs0tjpRlk=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.1 h1:mJu3COL9WEaZVp/Kf2PRMi7tPszPEJfSr/OO75ynCs8=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.1/go.mod h1:mofwUWx70wvskwESqRJ//k/9kURmCgyJl5m5Ppoh5kY=
github.com/go-openapi/swag/loading v0.27.1 h1:/DxUgDXKbBX4bcn7r9uEXfJyzN5XpiJmZplzQTjrRCY=
github.com/go-openapi/swag/loading v0.27.1/go.mod h1:jvGh3iA2+zyUUycB5fgJWzeHnhrpvGnJJM0RVE9ZShE=
github.com/go-openapi/swag/mangling v0.27.1 h1:yC9D0HyUE8gbP+BfmGx9+AA89ikwZTMjESK3OnnoaqA=
github.com/go-openapi/swag/mangling v0.27.1/go.mod h1:jtBE2+V+3pILxOR7Vgce+Cwp6A2PgZbvVqfNntbVs0w=
github.com/go-openapi/swag/netutils v0.27.1 h1:mICMFoS82F5TZ4Zy3cqmcQk+BFeCp3Uyq3Np7GI0/qU=
github.com/go-openapi/swag/netutils v0.27.1/go.mod h1:J+WYyFMLtvtCGqa6jLv+YNUmIKI3ZRQRrvfNDMoQoEQ=
github.com/go-openapi/swag/pools v0.27.1 h1:9LeadcMyb2GJCbXX5hVQDbZ2Lq9TL4dCs/nx1j5DO0E=
github.com/go-openapi/swag/pools v0.27.1/go.mod h1:kVQefhSK5RWuRe7BXsL8htgBPAMpN7HDGpGEknqugeE=
github.com/go-openapi/swag/stringutils v0.27.1 h1:ZXePZ0r2p1qSjo8tD3Un4vFj8+FqlCkczxDrJIhYUp8=
github.com/go-openapi/swag/stringutils v0.27.1/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM=
github.com/go-openapi/swag/typeutils v0.27.1 h1:KSTdFlfnse4r6dP9IrEnwMldjE+zs71UeEB3//PtVXc=
github.com/go-openapi/swag/typeutils v0.27.1/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ=
github.com/go-openapi/swag/yamlutils v0.27.1 h1:ftxv6xvXb1E3zohUc+okZ9nSqNb9StQX/FXnKZ98sQA=
github.com/go-openapi/swag/yamlutils v0.27.1/go.mod h1:bnxFIB1qewGRiZHypXGZ3fNgf13/0HfRgnS/iZBDrOo=
github.com/go-openapi/testify/enable/yaml/v2 v2.6.0 h1:gGHwAJ0R/5jU8BEGDbfRNR3hL68dAVi84WuOApp29B0=
github.com/go-openapi/testify/enable/yaml/v2 v2.6.0/go.mod h1:tY+St1SGq4NFl0QIqdTY4aEdbChAHxhyB77XQi9iJCo=
github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug=
github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak=
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/maniartech/signals v1.3.1 h1:pT3dK6x5Un+B6L3ZLAKygEe+L49TClPreyT08vOoHXY=
github.com/maniartech/signals v1.3.1/go.mod h1:AbE8Yy9ZjKCWNU/VhQ+0Ea9KOaTWHp6aOfdLBe5m1iM=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc=
github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/moby/moby/client v0.5.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc=
github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s=
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/sequential v0.7.0 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31zj/8=
github.com/moby/sys/sequential v0.7.0/go.mod h1:NfSTAp6V3fw4tmkD62PEcOKeZKquXT8VKCkf7aVR79o=
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/onsi/ginkgo/v2 v2.27.4 h1:fcEcQW/A++6aZAZQNUmNjvA9PSOzefMJBerHJ4t8v8Y=
github.com/onsi/ginkgo/v2 v2.27.4/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
github.com/onsi/gomega v1.39.0 h1:y2ROC3hKFmQZJNFeGAMeHZKkjBL65mIZcvrLQBF9k6Q=
github.com/onsi/gomega v1.39.0/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/opencontainers/runc v1.2.3 h1:fxE7amCzfZflJO2lHXf4y/y8M1BoAqp+FVmG19oYB80=
github.com/opencontainers/runc v1.2.3/go.mod h1:nSxcWUydXrsBZVYNSkTjoQ/N6rcyTtn+1SD5D4+kRIM=
github.com/ory/dockertest/v3 v3.12.0 h1:3oV9d0sDzlSQfHtIaB5k6ghUCVMVLpAY8hwrqoCyRCw=
github.com/ory/dockertest/v3 v3.12.0/go.mod h1:aKNDTva3cp8dwOWwb9cWuX84aH5akkxXRvO7KCwWVjE=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/prometheus/client_golang v1.24.0 h1:5XStIklKuAtJSNpdD3s8XJj/Yv78IQmE1kbNk87JrAI=
github.com/prometheus/client_golang v1.24.0/go.mod h1:QcsNdotprC2nS4BTM2ucbcqxd2CeXTEa9jW7zHO9iDE=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.70.0 h1:bcpru3tWPVnxGnETLgOV5jbp/JRXgYEyv65CuBLAMMI=
github.com/prometheus/common v0.70.0/go.mod h1:S/SFasQmgGiYH6C81LKCtYa8QACgthGg5zxL2udV7SY=
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs=
github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/sirupsen/logrus v1.10.1 h1:xi4336Zh11WpU14fXR6I67V3yaTPQYwRx2WEtHbRg4Q=
github.com/sirupsen/logrus v1.10.1/go.mod h1:vsQHnG7xzNsxk3NrwboUiWPnIC3dmbjcGPykD7+tiHk=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/testcontainers/testcontainers-go v0.44.0 h1:/Fwh6HY1mIikhnm9e7HwoxGycx0lzRAE0f5VQpjFxzI=
github.com/testcontainers/testcontainers-go v0.44.0/go.mod h1:IcnwQrYTO86xHXu5bvMaBH7ATlbS3Qn1M1QWW3c66rE=
github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU=
github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI=
github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4=
github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo=
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 h1:LMuyCAyfalSjDyjdC65nK6N0zoTT63+E/u95X0JovZI=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0/go.mod h1:085m8qbm4hgc8rZWGDEa4vmyyo2c3nPxUslYUKUIU04=
go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU=
go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ=
go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M=
go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s=
go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw=
go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA=
go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o=
go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA=
go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag=
go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw=
gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY=
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI=
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo=
gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
k8s.io/api v0.37.1 h1:l6N77U7tjwB5L056bgrBTJIEdevac/naBZ3iSvDNfpM=
k8s.io/api v0.37.1/go.mod h1:zSlbB1YpJ1YQlFVQy20UYll81UJSJJUMLhkhvg6Z78M=
k8s.io/apiextensions-apiserver v0.37.1 h1:7fIQG8eThDSTVYBWg/DOpI8v5wYfKCo0N8/TDMjj+zY=
k8s.io/apiextensions-apiserver v0.37.1/go.mod h1:1Q3ujWwHgnhBoGsdmGORflwK1sDFmuaqH1OsMDWk2Gs=
k8s.io/apimachinery v0.37.1 h1:hGCYyvKHCwtwMitj2vU4vYx0Z16N9GyZk9BBnz0wDAE=
k8s.io/apimachinery v0.37.1/go.mod h1:jF84AyUi/IRIXRot5f+lm6MpxoWI+F1XgjaMmwCdTFw=
k8s.io/client-go v0.37.1 h1:QTv/5ha4jAHtW9qxxVBkQVFBRDb4jHfFopQqqMdc+wM=
k8s.io/client-go v0.37.1/go.mod h1:dnAPtTnCNY38Ho04D2KdY1F4IKausa9UbqaAZKl60SY=
k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc=
k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0=
k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad h1:oXImqH8mQNk7PmvzKhmN3ddJoY6OnyM225MXwGHPm0A=
k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad/go.mod h1:0/mqHCVhlumdJ3BhCfnjSZQE037nAhNodh1/hK0T8/I=
k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 h1:jVkFFVfXdXP74B/zbO3hM3hpSFD0xvhQ5U686DPurkE=
k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3/go.mod h1:M2s5JB1lIYP3jzZdorPLHXIPJzt9vv2muW5a6L9DtNM=
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
sigs.k8s.io/controller-runtime v0.25.2 h1:bEkK3PVOIVK9X8QWLGVhJgmFc++47vfT6wakSzAOLsQ=
sigs.k8s.io/controller-runtime v0.25.2/go.mod h1:4QqLdT6z/L6Olj8JJCtvztid4/fnIiYsfaTFScegctc=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
sigs.k8s.io/structured-merge-diff/v6 v6.4.2 h1:qdOxHwrl2Kaag1aQEarlYcOA9vSyGCp3CIki3aW8c4Q=
sigs.k8s.io/structured-merge-diff/v6 v6.4.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
+103
View File
@@ -0,0 +1,103 @@
// Package app wires the controller-runtime manager shared by the binary and
// the end-to-end tests.
package app
import (
"context"
"errors"
"fmt"
"net/http"
"time"
"k8s.io/apimachinery/pkg/runtime"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/rest"
"k8s.io/utils/ptr"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/config"
"sigs.k8s.io/controller-runtime/pkg/healthz"
"sigs.k8s.io/controller-runtime/pkg/manager"
metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
v1 "gitea.renner.uno/k.renner/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/k.renner/homelab/kuma-sync/internal/controller"
"gitea.renner.uno/k.renner/homelab/kuma-sync/internal/kumaclient"
)
// Options configures NewManager.
type Options struct {
Config *rest.Config
Kuma kumaclient.Client
// MetricsAddr and ProbeAddr are bind addresses; "0" and "" disable them.
MetricsAddr string
ProbeAddr string
// GCInterval is the orphan collection period; 0 means 5 minutes.
GCInterval time.Duration
// OrphanGrace is how long a monitor must be seen without its resource
// before the GC deletes it. Production uses 1h; 0 only requires two runs.
OrphanGrace time.Duration
// 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
// SkipNameValidation allows several managers in one process, as in tests
// that restart the manager.
SkipNameValidation bool
}
// NewManager builds the manager with the reconciler and the orphan GC.
// There is no leader election: a single replica runs.
func NewManager(o Options) (manager.Manager, error) {
scheme := runtime.NewScheme()
// Core types: the GC reads Namespaces.
if err := clientgoscheme.AddToScheme(scheme); err != nil {
return nil, err
}
if err := v1.AddToScheme(scheme); err != nil {
return nil, err
}
mopts := ctrl.Options{
Scheme: scheme,
Metrics: metricsserver.Options{BindAddress: o.MetricsAddr},
HealthProbeBindAddress: o.ProbeAddr,
LeaderElection: false,
}
if o.SkipNameValidation {
mopts.Controller = config.Controller{SkipNameValidation: ptr.To(true)}
}
mgr, err := ctrl.NewManager(o.Config, mopts)
if err != nil {
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 {
return nil, fmt.Errorf("set up reconciler: %w", err)
}
if err := mgr.Add(&controller.GC{
Client: mgr.GetClient(),
Kuma: o.Kuma,
Interval: o.GCInterval,
OrphanGrace: o.OrphanGrace,
Cache: mgr.GetCache(),
APIReader: mgr.GetAPIReader(),
}); err != nil {
return nil, fmt.Errorf("add GC: %w", err)
}
if err := mgr.AddHealthzCheck("ping", healthz.Ping); err != nil {
return nil, err
}
if err := mgr.AddReadyzCheck("cache-synced", func(req *http.Request) error {
ctx, cancel := context.WithTimeout(req.Context(), time.Second)
defer cancel()
if !mgr.GetCache().WaitForCacheSync(ctx) {
return errors.New("cache not synced")
}
return nil
}); err != nil {
return nil, err
}
return mgr, nil
}
@@ -0,0 +1,458 @@
//go:build integration
package controller_test
import (
"context"
"fmt"
"io"
"path/filepath"
"sync"
"testing"
"time"
kuma "github.com/breml/go-uptime-kuma-client"
"github.com/breml/go-uptime-kuma-client/monitor"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/util/retry"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/envtest"
v1 "gitea.renner.uno/k.renner/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/k.renner/homelab/kuma-sync/internal/app"
"gitea.renner.uno/k.renner/homelab/kuma-sync/internal/kumaclient"
)
const (
user = "admin"
pass = "Sup3r-Secret-Passw0rd!"
gcInterval = 2 * time.Second
resync = 2 * time.Second
)
// e2e is a real API server (envtest, CRD installed) next to a real Kuma.
type e2e struct {
t *testing.T
k8s client.Client
url string
raw *kuma.Client // library session used to change Kuma behind the controller's back
census kumaclient.Client // separate session used only to count monitors
}
func startKuma(t *testing.T) string {
t.Helper()
ctx := context.Background()
ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: testcontainers.ContainerRequest{
Image: "louislam/uptime-kuma:2.5.6-slim",
ExposedPorts: []string{"3001/tcp"},
Env: map[string]string{"UPTIME_KUMA_DB_TYPE": "sqlite"},
WaitingFor: wait.ForHTTP("/api/entry-page").WithPort("3001/tcp").
WithStatusCodeMatcher(func(s int) bool { return s == 200 }).
WithStartupTimeout(3 * time.Minute),
},
Started: true,
})
if err != nil {
t.Fatalf("start kuma: %v", err)
}
t.Cleanup(func() { _ = ctr.Terminate(context.Background()) })
url, err := ctr.PortEndpoint(ctx, "3001/tcp", "http")
if err != nil {
t.Fatal(err)
}
return url
}
func newE2E(t *testing.T) (*e2e, *envtest.Environment) {
t.Helper()
env := &envtest.Environment{
CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd")},
ErrorIfCRDPathMissing: true,
}
cfg, err := env.Start()
if err != nil {
t.Fatalf("start envtest: %v", err)
}
t.Cleanup(func() { _ = env.Stop() })
scheme := runtime.NewScheme()
if err := v1.AddToScheme(scheme); err != nil {
t.Fatal(err)
}
k8s, err := client.New(cfg, client.Options{Scheme: scheme})
if err != nil {
t.Fatal(err)
}
url := startKuma(t)
// The first login creates the admin account.
raw, err := kuma.New(t.Context(), url, user, pass, kuma.WithAutosetup(), kuma.WithConnectTimeout(2*time.Minute))
if err != nil {
t.Fatalf("kuma setup: %v", err)
}
t.Cleanup(func() { _ = raw.Disconnect() })
census, err := kumaclient.NewBreml(t.Context(), url, user, pass)
if err != nil {
t.Fatal(err)
}
if c, ok := census.(io.Closer); ok {
t.Cleanup(func() { _ = c.Close() })
}
return &e2e{t: t, k8s: k8s, url: url, raw: raw, census: census}, env
}
// manager is one run of the controller, built from scratch like a pod start.
type running struct {
cancel context.CancelFunc
done chan error
kuma io.Closer
once sync.Once
}
func (f *e2e) start(env *envtest.Environment) *running {
f.t.Helper()
ctx, cancel := context.WithCancel(f.t.Context())
kc, err := kumaclient.NewBreml(ctx, f.url, user, pass)
if err != nil {
cancel()
f.t.Fatalf("new kuma client: %v", err)
}
mgr, err := app.NewManager(app.Options{
Config: env.Config,
Kuma: kc,
MetricsAddr: "0",
GCInterval: gcInterval,
OrphanGrace: 3 * time.Second,
Resync: resync,
SkipNameValidation: true,
})
if err != nil {
cancel()
f.t.Fatalf("new manager: %v", err)
}
r := &running{cancel: cancel, done: make(chan error, 1)}
if c, ok := kc.(io.Closer); ok {
r.kuma = c
}
go func() { r.done <- mgr.Start(ctx) }()
// Runs before the envtest and Kuma teardown, also after an early Fatal.
f.t.Cleanup(func() { f.stop(r) })
return r
}
// stop cancels the manager and waits for it to return. It is idempotent.
func (f *e2e) stop(r *running) {
f.t.Helper()
r.once.Do(func() {
r.cancel()
select {
case err := <-r.done:
if err != nil {
f.t.Errorf("manager returned %v", err)
}
case <-time.After(30 * time.Second):
f.t.Errorf("manager did not stop")
}
if r.kuma != nil {
_ = r.kuma.Close()
}
})
}
func eventually(t *testing.T, timeout time.Duration, what string, cond func() (bool, string)) {
t.Helper()
deadline := time.Now().Add(timeout)
for {
ok, detail := cond()
if ok {
return
}
if time.Now().After(deadline) {
t.Fatalf("timed out after %v waiting for %s: %s", timeout, what, detail)
}
time.Sleep(100 * time.Millisecond)
}
}
func (f *e2e) owned() []kumaclient.Monitor {
f.t.Helper()
list, err := f.census.ListOwned(f.t.Context())
if err != nil {
f.t.Fatalf("census list: %v", err)
}
return list
}
func byKey(list []kumaclient.Monitor) map[string]kumaclient.Monitor {
out := map[string]kumaclient.Monitor{}
for _, m := range list {
out[m.OwnerKey] = m
}
return out
}
func group(name, parent string) *v1.KumaMonitor {
km := &v1.KumaMonitor{
ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: name},
Spec: v1.KumaMonitorSpec{Type: "group", DisplayName: name},
}
if parent != "" {
km.Spec.ParentRef = &v1.LocalRef{Name: parent}
}
return km
}
func dns(name, parent string) *v1.KumaMonitor {
km := &v1.KumaMonitor{
ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: name},
Spec: v1.KumaMonitorSpec{
Type: "dns", DisplayName: name,
DNS: &v1.DNSSpec{Hostname: "example.com", ResolveType: "A"},
},
}
if parent != "" {
km.Spec.ParentRef = &v1.LocalRef{Name: parent}
}
return km
}
func (f *e2e) apply(objs ...*v1.KumaMonitor) {
f.t.Helper()
for _, o := range objs {
if err := f.k8s.Create(f.t.Context(), o); err != nil {
f.t.Fatalf("create %s: %v", o.Name, err)
}
}
}
func TestE2EGroupNesting(t *testing.T) {
f, env := newE2E(t)
f.start(env)
f.apply(group("outer", ""), group("inner", "outer"), dns("probe", "inner"))
eventually(t, 60*time.Second, "outer > inner > probe in Kuma", func() (bool, string) {
m := byKey(f.owned())
outer, inner, probe := m["default/outer"], m["default/inner"], m["default/probe"]
if outer.ID == 0 || inner.ID == 0 || probe.ID == 0 {
return false, "not all three monitors exist yet"
}
if outer.Parent != nil {
return false, "outer must have no parent"
}
if inner.Parent == nil || *inner.Parent != outer.ID {
return false, "inner is not under outer"
}
if probe.Parent == nil || *probe.Parent != inner.ID {
return false, "probe is not under inner"
}
return true, ""
})
}
func TestE2ERestartNoDuplicates(t *testing.T) {
f, env := newE2E(t)
r := f.start(env)
f.apply(group("g", ""), dns("probe", "g"))
eventually(t, 30*time.Second, "both monitors in Kuma", func() (bool, string) {
return len(f.owned()) == 2, "monitors missing"
})
before := byKey(f.owned())
f.stop(r)
f.start(env)
// A spec change after the restart must land on the same Kuma monitor: the
// new manager reconciled and adopted it instead of creating another.
key := types.NamespacedName{Namespace: "default", Name: "g"}
if err := retry.RetryOnConflict(retry.DefaultRetry, func() error {
km := &v1.KumaMonitor{}
if err := f.k8s.Get(t.Context(), key, km); err != nil {
return err
}
km.Spec.DisplayName = "g renamed"
return f.k8s.Update(t.Context(), km)
}); err != nil {
t.Fatal(err)
}
eventually(t, 30*time.Second, "rename reflected on the original monitor", func() (bool, string) {
m := byKey(f.owned())["default/g"]
if m.ID != before["default/g"].ID {
return false, fmt.Sprintf("default/g has ID %d, want %d", m.ID, before["default/g"].ID)
}
return m.Name == "g renamed", "name is " + m.Name
})
// Two GC intervals and then some, checked continuously rather than once.
deadline := time.Now().Add(2*gcInterval + 3*time.Second)
for time.Now().Before(deadline) {
after := byKey(f.owned())
if len(after) != 2 {
t.Fatalf("monitor count changed after restart: %d", len(after))
}
for k, m := range before {
if after[k].ID != m.ID {
t.Fatalf("%s changed ID %d -> %d", k, m.ID, after[k].ID)
}
}
time.Sleep(500 * time.Millisecond)
}
// Both resources must still be reconciled by the new manager.
for _, name := range []string{"g", "probe"} {
km := &v1.KumaMonitor{}
if err := f.k8s.Get(t.Context(), types.NamespacedName{Namespace: "default", Name: name}, km); err != nil {
t.Fatal(err)
}
if km.Status.MonitorID != before["default/"+name].ID {
t.Errorf("%s status.monitorID = %d, want %d", name, km.Status.MonitorID, before["default/"+name].ID)
}
}
}
func TestDeleteWhileDownIsCollected(t *testing.T) {
f, env := newE2E(t)
r := f.start(env)
f.apply(dns("doomed", ""), dns("keeper", ""))
eventually(t, 30*time.Second, "both monitors in Kuma", func() (bool, string) {
return len(f.owned()) == 2, "monitors missing"
})
f.stop(r)
// With the controller down, drop the finalizer and delete the resource.
doomed := &v1.KumaMonitor{}
key := types.NamespacedName{Namespace: "default", Name: "doomed"}
if err := f.k8s.Get(t.Context(), key, doomed); err != nil {
t.Fatal(err)
}
doomed.Finalizers = nil
if err := f.k8s.Update(t.Context(), doomed); err != nil {
t.Fatal(err)
}
if err := f.k8s.Delete(t.Context(), doomed); err != nil {
t.Fatal(err)
}
f.start(env)
eventually(t, 30*time.Second, "orphan collected by GC", func() (bool, string) {
m := byKey(f.owned())
_, doomedLeft := m["default/doomed"]
_, keeperLeft := m["default/keeper"]
if doomedLeft {
return false, "orphan still in Kuma"
}
if !keeperLeft {
t.Fatal("GC deleted the monitor of a live resource")
}
return true, ""
})
}
func TestE2EDriftRepaired(t *testing.T) {
f, env := newE2E(t)
f.start(env)
f.apply(dns("probe", ""))
var id int64
eventually(t, 30*time.Second, "monitor in Kuma", func() (bool, string) {
m, ok := byKey(f.owned())["default/probe"]
id = m.ID
return ok, "monitor missing"
})
want := int64(byKey(f.owned())["default/probe"].Interval)
var live monitor.DNS
if err := f.raw.GetMonitorAs(t.Context(), id, &live); err != nil {
t.Fatal(err)
}
live.Interval = want + 60
if err := f.raw.UpdateMonitor(t.Context(), &live); err != nil {
t.Fatal(err)
}
// The controller may repair the drift at any moment, so observe both
// states in order instead of asserting either on its own.
sawDrift := false
eventually(t, 30*time.Second, "drift seen, then repaired by a resync", func() (bool, string) {
var cur monitor.DNS
if err := f.raw.GetMonitorAs(t.Context(), id, &cur); err != nil {
return false, err.Error()
}
switch {
case !sawDrift && cur.Interval == want+60:
sawDrift = true
case !sawDrift:
t.Fatalf("interval is %d: the drift was repaired before it was ever observed", cur.Interval)
case cur.Interval == want:
return true, ""
}
return false, fmt.Sprintf("interval is %d, want %d (drift seen: %v)", cur.Interval, want, sawDrift)
})
}
// 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) {
f, env := newE2E(t)
r := f.start(env)
f.apply(dns("probe", ""))
var id int64
eventually(t, 30*time.Second, "monitor in Kuma", func() (bool, string) {
m, ok := byKey(f.owned())["default/probe"]
id = m.ID
return ok, "monitor missing"
})
eventually(t, 30*time.Second, "status.monitorID recorded", func() (bool, string) {
km := &v1.KumaMonitor{}
if err := f.k8s.Get(t.Context(), types.NamespacedName{Namespace: "default", Name: "probe"}, km); err != nil {
return false, err.Error()
}
return km.Status.MonitorID == id, "status.monitorID not set"
})
f.stop(r)
tags, err := f.raw.GetTags(t.Context())
if err != nil {
t.Fatal(err)
}
var tagID int64
for _, tg := range tags {
if tg.Name == "kuma-sync" && (tagID == 0 || tg.ID < tagID) {
tagID = tg.ID
}
}
if err := f.raw.DeleteMonitorTagWithValue(t.Context(), tagID, id, "default/probe"); err != nil {
t.Fatal(err)
}
f.start(env)
eventually(t, 30*time.Second, "Ready=False/NotOwned", func() (bool, string) {
km := &v1.KumaMonitor{}
if err := f.k8s.Get(t.Context(), types.NamespacedName{Namespace: "default", Name: "probe"}, km); err != nil {
return false, err.Error()
}
c := meta.FindStatusCondition(km.Status.Conditions, v1.ConditionReady)
if c == nil {
return false, "no Ready condition"
}
return c.Status == metav1.ConditionFalse && c.Reason == "NotOwned", "Ready is " + string(c.Status) + "/" + c.Reason
})
// No twin appears, and the GC leaves the untagged monitor alone.
deadline := time.Now().Add(2*gcInterval + 3*time.Second)
for time.Now().Before(deadline) {
all, err := f.raw.GetMonitors(t.Context())
if err != nil {
t.Fatal(err)
}
if len(all) != 1 || all[0].ID != id {
t.Fatalf("Kuma holds %d monitors, want only the untagged original %d", len(all), id)
}
time.Sleep(500 * time.Millisecond)
}
}
@@ -0,0 +1,246 @@
package controller
import (
"context"
"errors"
"fmt"
"slices"
"strings"
"sync"
"time"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/manager"
v1 "gitea.renner.uno/k.renner/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/k.renner/homelab/kuma-sync/internal/kumaclient"
)
// +kubebuilder:rbac:groups="",resources=namespaces,verbs=get
// CacheSyncer is satisfied by the manager's cache.
type CacheSyncer interface {
WaitForCacheSync(ctx context.Context) bool
}
// errListFailed marks a run that stopped before deleting anything because a
// list failed.
var errListFailed = errors.New("list failed, nothing deleted")
// minOrphanRuns is how many consecutive runs must see a monitor orphaned
// before it is deleted, whatever the grace period.
const minOrphanRuns = 2
// GC deletes owned Kuma monitors that no KumaMonitor accounts for, and
// duplicates of one owner key beyond the lowest ID.
//
// A run deletes nothing unless both lists succeeded completely: any error from
// ListOwned (conflicts and partial results included) or from listing
// KumaMonitors aborts it. Start waits for Cache to sync first, so an empty
// cache at startup never looks like "every resource was deleted".
//
// An orphan, confirmed absent on the API server, is deleted only after it was
// seen orphaned in at least two consecutive runs spanning OrphanGrace; this
// keeps a cluster rebuild, where the resources are applied long after the
// controller starts, from wiping their monitors. The first-seen times live in
// memory, so a restart starts the grace period over. Orphans in a namespace
// that does not exist are never deleted. Duplicate extras are deleted at once.
type GC struct {
Client client.Client
Kuma kumaclient.Client
Interval time.Duration
// OrphanGrace is how long a monitor must have been seen orphaned.
OrphanGrace time.Duration
// Cache is waited on before the first run; nil skips the wait.
Cache CacheSyncer
// APIReader reads straight from the API server. Every orphan is confirmed
// against it before it is deleted.
APIReader client.Reader
// Now is the clock; nil means time.Now.
Now func() time.Time
mu sync.Mutex
pending map[int64]orphanSeen
}
type orphanSeen struct {
since time.Time
runs int
}
var (
_ manager.Runnable = (*GC)(nil)
_ manager.LeaderElectionRunnable = (*GC)(nil)
)
// NeedLeaderElection is false: there is a single replica, no election.
func (g *GC) NeedLeaderElection() bool { return false }
func (g *GC) now() time.Time {
if g.Now != nil {
return g.Now()
}
return time.Now()
}
func (g *GC) Start(ctx context.Context) error {
logger := log.FromContext(ctx).WithName("gc")
if g.Cache != nil && !g.Cache.WaitForCacheSync(ctx) {
if ctx.Err() != nil {
return nil
}
return errors.New("gc: cache did not sync")
}
interval := g.Interval
if interval <= 0 {
interval = 5 * time.Minute
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
if err := g.RunOnce(ctx); err != nil {
if errors.Is(err, errListFailed) {
logger.Error(err, "gc run aborted")
} else {
logger.Error(err, "gc run finished with errors")
}
}
select {
case <-ctx.Done():
return nil
case <-ticker.C:
}
}
}
// existsInAPI asks the API server, not the cache, whether the resource behind
// an owner key exists. A malformed key is an error, never "absent".
func (g *GC) existsInAPI(ctx context.Context, ownerKey string) (bool, error) {
ns, name, ok := strings.Cut(ownerKey, "/")
if !ok || ns == "" || name == "" || strings.Contains(name, "/") {
return false, fmt.Errorf("malformed owner key %q", ownerKey)
}
err := g.APIReader.Get(ctx, types.NamespacedName{Namespace: ns, Name: name}, &v1.KumaMonitor{})
switch {
case err == nil:
return true, nil
case apierrors.IsNotFound(err):
return false, nil
default:
return false, err
}
}
// namespaceExists reports whether the namespace of an owner key exists.
func (g *GC) namespaceExists(ctx context.Context, ownerKey string) (bool, error) {
ns, _, _ := strings.Cut(ownerKey, "/")
err := g.APIReader.Get(ctx, types.NamespacedName{Name: ns}, &corev1.Namespace{})
switch {
case err == nil:
return true, nil
case apierrors.IsNotFound(err):
return false, nil
default:
return false, err
}
}
// RunOnce performs one collection pass.
func (g *GC) RunOnce(ctx context.Context) error {
logger := log.FromContext(ctx).WithName("gc")
if g.APIReader == nil {
return errors.New("gc: APIReader is required to confirm orphans")
}
g.mu.Lock()
defer g.mu.Unlock()
if g.pending == nil {
g.pending = map[int64]orphanSeen{}
}
// Kuma list must precede the cache list.
owned, err := g.Kuma.ListOwned(ctx)
if err != nil {
clear(g.pending)
return fmt.Errorf("%w: list owned monitors: %w", errListFailed, err)
}
var resources v1.KumaMonitorList
if err := g.Client.List(ctx, &resources); err != nil {
clear(g.pending)
return fmt.Errorf("%w: list KumaMonitors: %w", errListFailed, err)
}
live := make(map[string]bool, len(resources.Items))
for i := range resources.Items {
live[OwnerKey(&resources.Items[i])] = true
}
now := g.now()
slices.SortFunc(owned, func(a, b kumaclient.Monitor) int { return int(a.ID - b.ID) })
seen := map[string]bool{}
candidates := map[int64]bool{}
var errs []error
del := func(m kumaclient.Monitor, reason string) bool {
logger.Info("deleting monitor", "monitorID", m.ID, "ownerKey", m.OwnerKey, "reason", reason)
if err := g.Kuma.Delete(ctx, m.ID); err != nil && !errors.Is(err, kumaclient.ErrNotFound) {
errs = append(errs, fmt.Errorf("delete monitor %d (%s): %w", m.ID, m.OwnerKey, err))
return false
}
return true
}
for _, m := range owned {
keep := live[m.OwnerKey] && !seen[m.OwnerKey]
seen[m.OwnerKey] = true
if keep {
continue
}
if live[m.OwnerKey] {
del(m, "duplicate of a lower monitor ID")
continue
}
exists, err := g.existsInAPI(ctx, m.OwnerKey)
if err != nil {
errs = append(errs, fmt.Errorf("confirm orphan monitor %d (%s): %w", m.ID, m.OwnerKey, err))
continue
}
if exists {
logger.Info("KumaMonitor exists on the API server, not an orphan", "monitorID", m.ID, "ownerKey", m.OwnerKey)
continue
}
nsOK, err := g.namespaceExists(ctx, m.OwnerKey)
if err != nil {
errs = append(errs, fmt.Errorf("check namespace of monitor %d (%s): %w", m.ID, m.OwnerKey, err))
continue
}
if !nsOK {
logger.Info("namespace does not exist, leaving the monitor alone", "monitorID", m.ID, "ownerKey", m.OwnerKey)
continue
}
candidates[m.ID] = true
st, ok := g.pending[m.ID]
if !ok {
st.since = now
}
st.runs++
g.pending[m.ID] = st
if st.runs < minOrphanRuns || now.Sub(st.since) < g.OrphanGrace {
logger.Info("orphan candidate, waiting out the grace period", "monitorID", m.ID, "ownerKey", m.OwnerKey,
"runs", st.runs, "orphanFor", now.Sub(st.since).String(), "grace", g.OrphanGrace.String())
continue
}
if del(m, "no matching KumaMonitor") {
delete(g.pending, m.ID)
}
}
// Whatever stopped being an orphan, or vanished, loses its history.
for id := range g.pending {
if !candidates[id] {
delete(g.pending, id)
}
}
return errors.Join(errs...)
}
@@ -0,0 +1,396 @@
package controller
import (
"context"
"errors"
"fmt"
"sync/atomic"
"testing"
"time"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
"sigs.k8s.io/controller-runtime/pkg/client/interceptor"
v1 "gitea.renner.uno/k.renner/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/k.renner/homelab/kuma-sync/internal/kumaclient"
)
type gcEnv struct {
gc *GC
kuma *kumaclient.Fake
cnt *counting
}
func newGCEnv(t *testing.T, funcs *interceptor.Funcs, objs ...client.Object) *gcEnv {
t.Helper()
s := gcScheme(t)
b := fake.NewClientBuilder().WithScheme(s).WithObjects(append(objs, namespace("default"))...)
if funcs != nil {
b = b.WithInterceptorFuncs(*funcs)
}
kf := kumaclient.NewFake()
cnt := &counting{Client: kf}
c := b.Build()
return &gcEnv{gc: &GC{Client: c, APIReader: c, Kuma: cnt, Interval: time.Hour}, kuma: kf, cnt: cnt}
}
func gcScheme(t *testing.T) *runtime.Scheme {
t.Helper()
s := runtime.NewScheme()
if err := v1.AddToScheme(s); err != nil {
t.Fatal(err)
}
if err := corev1.AddToScheme(s); err != nil {
t.Fatal(err)
}
return s
}
func namespace(name string) *corev1.Namespace {
return &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: name}}
}
// apiServerWith is a fake API server that always has the default namespace.
func apiServerWith(t *testing.T, objs ...client.Object) client.Reader {
t.Helper()
return fake.NewClientBuilder().WithScheme(gcScheme(t)).WithObjects(append(objs, namespace("default"))...).Build()
}
// runs calls RunOnce n times and returns the last error.
func (e *gcEnv) runs(t *testing.T, n int) error {
t.Helper()
var err error
for range n {
err = e.gc.RunOnce(t.Context())
}
return err
}
func TestGCConfirmsWithAPIReaderBeforeOrphanDelete(t *testing.T) {
e := newGCEnv(t, nil) // stale cache: knows no resources
id := owned(t, e, "default/a")
e.gc.APIReader = apiServerWith(t, httpMonitor("a"))
if err := e.gc.RunOnce(t.Context()); err != nil {
t.Fatal(err)
}
if e.kuma.Get(id) == nil || e.cnt.deletes != 0 {
t.Error("monitor of a resource that exists on the API server was deleted")
}
// Truly absent on the API server: deleted.
e.gc.APIReader = apiServerWith(t)
if err := e.runs(t, 2); err != nil {
t.Fatal(err)
}
if e.kuma.Get(id) != nil {
t.Error("confirmed orphan survived")
}
}
func TestGCSkipsOnAPIReaderError(t *testing.T) {
e := newGCEnv(t, nil)
id := owned(t, e, "default/gone")
s := runtime.NewScheme()
if err := v1.AddToScheme(s); err != nil {
t.Fatal(err)
}
e.gc.APIReader = fake.NewClientBuilder().WithScheme(s).WithInterceptorFuncs(interceptor.Funcs{
Get: func(context.Context, client.WithWatch, client.ObjectKey, client.Object, ...client.GetOption) error {
return errors.New("apiserver unavailable")
},
}).Build()
if err := e.gc.RunOnce(t.Context()); err == nil {
t.Fatal("expected the API reader error to be recorded")
}
if e.kuma.Get(id) == nil || e.cnt.deletes != 0 {
t.Error("deleted without confirmation")
}
}
func TestGCMalformedOwnerKeyNeverDeleted(t *testing.T) {
e := newGCEnv(t, nil)
var ids []int64
for _, key := range []string{"noslash", "/name", "ns/", "a/b/c"} {
ids = append(ids, owned(t, e, key))
}
if err := e.gc.RunOnce(t.Context()); err == nil {
t.Fatal("expected errors for malformed keys")
}
for _, id := range ids {
if e.kuma.Get(id) == nil {
t.Errorf("monitor %d with a malformed key was deleted", id)
}
}
}
func owned(t *testing.T, e *gcEnv, key string) int64 {
t.Helper()
id, err := e.kuma.Create(t.Context(), kumaclient.Monitor{Type: "group", Name: key, OwnerKey: key})
if err != nil {
t.Fatal(err)
}
return id
}
func TestGCDeletesOrphans(t *testing.T) {
e := newGCEnv(t, nil, httpMonitor("live"))
live := owned(t, e, "default/live")
gone := owned(t, e, "default/gone")
if err := e.runs(t, 2); err != nil {
t.Fatal(err)
}
if e.kuma.Get(gone) != nil {
t.Error("orphan survived")
}
if e.kuma.Get(live) == nil {
t.Error("live monitor deleted")
}
}
func TestGCIgnoresUntagged(t *testing.T) {
e := newGCEnv(t, nil)
foreign := e.kuma.AddForeign(kumaclient.Monitor{Name: "foreign"})
if err := e.gc.RunOnce(t.Context()); err != nil {
t.Fatal(err)
}
if e.kuma.Get(foreign) == nil || e.cnt.deletes != 0 {
t.Errorf("foreign monitor touched: deletes=%d", e.cnt.deletes)
}
}
func TestGCKeepsLiveResources(t *testing.T) {
e := newGCEnv(t, nil, httpMonitor("a"), httpMonitor("b"))
owned(t, e, "default/a")
owned(t, e, "default/b")
if err := e.gc.RunOnce(t.Context()); err != nil {
t.Fatal(err)
}
if e.cnt.deletes != 0 {
t.Errorf("deletes = %d", e.cnt.deletes)
}
}
func TestGCKeepsResourcesBeingDeleted(t *testing.T) {
e := newGCEnv(t, nil, deleting(httpMonitor("a")))
owned(t, e, "default/a")
if err := e.gc.RunOnce(t.Context()); err != nil {
t.Fatal(err)
}
if e.cnt.deletes != 0 {
t.Errorf("the finalizer path owns this deletion, deletes = %d", e.cnt.deletes)
}
}
func TestGCAbortsOnListError(t *testing.T) {
e := newGCEnv(t, &interceptor.Funcs{
List: func(context.Context, client.WithWatch, client.ObjectList, ...client.ListOption) error {
return errors.New("cache not ready")
},
})
owned(t, e, "default/gone")
if err := e.gc.RunOnce(t.Context()); err == nil {
t.Fatal("expected error")
}
if e.cnt.deletes != 0 {
t.Errorf("deletes = %d, want 0", e.cnt.deletes)
}
}
func TestGCAbortsOnKumaListError(t *testing.T) {
e := newGCEnv(t, nil)
owned(t, e, "default/gone")
e.kuma.SetErrs(errors.New("boom"), nil, nil, nil)
if err := e.gc.RunOnce(t.Context()); err == nil {
t.Fatal("expected error")
}
if e.cnt.deletes != 0 {
t.Errorf("deletes = %d, want 0", e.cnt.deletes)
}
}
func TestGCNoDeletesOnPartialList(t *testing.T) {
e := newGCEnv(t, nil)
owned(t, e, "default/gone")
e.kuma.SetListPartial(errors.Join(&kumaclient.ConflictError{MonitorID: 9, Keys: []string{"default/x", "default/y"}}))
if err := e.gc.RunOnce(t.Context()); err == nil {
t.Fatal("expected error")
}
if e.cnt.deletes != 0 {
t.Errorf("deletes = %d, want 0", e.cnt.deletes)
}
}
func TestGCDeletesDuplicateExtras(t *testing.T) {
e := newGCEnv(t, nil, httpMonitor("a"))
first := owned(t, e, "default/a")
second := owned(t, e, "default/a")
third := owned(t, e, "default/a")
if err := e.gc.RunOnce(t.Context()); err != nil {
t.Fatal(err)
}
if e.kuma.Get(first) == nil {
t.Error("lowest ID must stay")
}
if e.kuma.Get(second) != nil || e.kuma.Get(third) != nil {
t.Error("extras must be deleted")
}
}
func TestGCDeleteNotFoundIsSuccess(t *testing.T) {
e := newGCEnv(t, nil)
owned(t, e, "default/gone")
e.kuma.SetErrs(nil, nil, nil, fmt.Errorf("delete: %w", kumaclient.ErrNotFound))
if err := e.runs(t, 2); err != nil {
t.Fatalf("ErrNotFound must count as success: %v", err)
}
}
// waiter is a CacheSyncer whose result the test controls.
type waiter struct {
release chan struct{}
result bool
started atomic.Bool
}
func (w *waiter) WaitForCacheSync(ctx context.Context) bool {
w.started.Store(true)
select {
case <-w.release:
return w.result
case <-ctx.Done():
return false
}
}
func TestGCWaitsForCacheSync(t *testing.T) {
e := newGCEnv(t, nil)
owned(t, e, "default/gone") // would look like an orphan against an empty cache
w := &waiter{release: make(chan struct{}), result: true}
e.gc.Cache = w
e.gc.Interval = 20 * time.Millisecond
ctx, cancel := context.WithCancel(t.Context())
done := make(chan error, 1)
go func() { done <- e.gc.Start(ctx) }()
deadline := time.Now().Add(5 * time.Second)
for !w.started.Load() && time.Now().Before(deadline) {
time.Sleep(5 * time.Millisecond)
}
time.Sleep(100 * time.Millisecond)
if e.kuma.Get(1) == nil {
t.Fatal("GC ran before the cache synced")
}
close(w.release)
deadline = time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) && e.kuma.Get(1) != nil {
time.Sleep(5 * time.Millisecond)
}
if e.kuma.Get(1) != nil {
t.Error("GC did not run after the cache synced")
}
cancel()
if err := <-done; err != nil {
t.Errorf("Start returned %v on shutdown", err)
}
}
func TestGCOrphanGraceDelaysDelete(t *testing.T) {
e := newGCEnv(t, nil)
id := owned(t, e, "default/gone")
e.gc.OrphanGrace = time.Hour
clock := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
e.gc.Now = func() time.Time { return clock }
for _, step := range []struct {
at time.Duration
deleted bool
}{
{0, false}, // first sighting
{30 * time.Minute, false}, // second run, grace not over
{59 * time.Minute, false}, // still inside the grace period
{60 * time.Minute, true}, // third run, a full hour after the first sighting
} {
clock = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC).Add(step.at)
if err := e.gc.RunOnce(t.Context()); err != nil {
t.Fatal(err)
}
if gone := e.kuma.Get(id) == nil; gone != step.deleted {
t.Fatalf("at +%v: deleted = %v, want %v", step.at, gone, step.deleted)
}
}
}
func TestGCOrphanGraceResetsWhenResourceReturns(t *testing.T) {
e := newGCEnv(t, nil)
id := owned(t, e, "default/a")
e.gc.OrphanGrace = time.Hour
base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
var clock time.Time
e.gc.Now = func() time.Time { return clock }
run := func(at time.Duration) {
t.Helper()
clock = base.Add(at)
if err := e.gc.RunOnce(t.Context()); err != nil {
t.Fatal(err)
}
}
run(0) // orphan seen
km := httpMonitor("a")
if err := e.gc.Client.Create(t.Context(), km); err != nil {
t.Fatal(err)
}
run(40 * time.Minute) // resource is back: history dropped
if err := e.gc.Client.Delete(t.Context(), km); err != nil {
t.Fatal(err)
}
run(61 * time.Minute) // orphan again, first sighting of the new streak
run(90 * time.Minute) // only 29 minutes into the new streak
if e.kuma.Get(id) == nil {
t.Fatal("deleted although the grace period restarted when the resource returned")
}
run(122 * time.Minute) // 61 minutes into the new streak
if e.kuma.Get(id) != nil {
t.Error("orphan survived a full grace period")
}
}
func TestGCSkipsMissingNamespace(t *testing.T) {
e := newGCEnv(t, nil)
id := owned(t, e, "ghost/x")
if err := e.runs(t, 3); err != nil {
t.Fatal(err)
}
if e.kuma.Get(id) == nil || e.cnt.deletes != 0 {
t.Fatal("monitor of a missing namespace was deleted")
}
if err := e.gc.Client.Create(t.Context(), namespace("ghost")); err != nil {
t.Fatal(err)
}
if err := e.runs(t, 2); err != nil {
t.Fatal(err)
}
if e.kuma.Get(id) != nil {
t.Error("orphan in an existing namespace survived")
}
}
func TestGCStartFailsWhenCacheNeverSyncs(t *testing.T) {
e := newGCEnv(t, nil)
owned(t, e, "default/gone")
released := make(chan struct{})
close(released)
e.gc.Cache = &waiter{release: released, result: false}
if err := e.gc.Start(t.Context()); err == nil {
t.Fatal("expected an error when the cache does not sync")
}
if e.cnt.deletes != 0 {
t.Error("GC deleted without a synced cache")
}
}
@@ -0,0 +1,328 @@
// Package controller reconciles KumaMonitor resources into Uptime Kuma.
package controller
import (
"context"
"errors"
"fmt"
"reflect"
"slices"
"time"
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/k.renner/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/k.renner/homelab/kuma-sync/internal/kumaclient"
)
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"
parentRetry = 10 * time.Second
resyncPeriod = 5 * time.Minute
maxParentDepth = 10
)
// +kubebuilder:rbac:groups=uptime.renner.uno,resources=kumamonitors,verbs=get;list;watch;update;patch
// +kubebuilder:rbac:groups=uptime.renner.uno,resources=kumamonitors/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=uptime.renner.uno,resources=kumamonitors/finalizers,verbs=update
// +kubebuilder:rbac:groups="",resources=events,verbs=create;patch
// Reconciler syncs KumaMonitor resources to Kuma.
type Reconciler struct {
Client client.Client
Kuma kumaclient.Client
// Resync is how often a healthy resource is reconciled again, which repairs
// drift made in Kuma. 0 means 5 minutes.
Resync time.Duration
}
func (r *Reconciler) resync() time.Duration {
if r.Resync > 0 {
return r.Resync
}
return resyncPeriod
}
// SetupWithManager ignores status-only updates, which keeps failure backoff
// intact. Deletion still reconciles: setting deletionTimestamp bumps
// metadata.generation.
func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&v1.KumaMonitor{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})).
Named("kumamonitor").
Complete(r)
}
// failure is a Ready=False outcome. The message is fixed per reason; the cause
// goes to the log and the returned error only.
type failure struct {
reason string
message string
err error
}
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"
)
func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
km := &v1.KumaMonitor{}
if err := r.Client.Get(ctx, req.NamespacedName, km); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
if !km.DeletionTimestamp.IsZero() {
return r.reconcileDelete(ctx, km)
}
if controllerutil.AddFinalizer(km, Finalizer) {
if err := r.Client.Update(ctx, km); err != nil {
return ctrl.Result{}, err
}
}
desired := Desired(km)
if km.Spec.ParentRef != nil {
p, err := r.resolveParent(ctx, km)
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
}
return ctrl.Result{RequeueAfter: p.requeue}, nil
}
desired.Parent = &p.id
}
res, fail := r.lookup(ctx, desired.OwnerKey)
if fail != nil {
return r.fail(ctx, km, fail)
}
var id int64
if res.mon == nil {
// The monitor this resource synced before may have lost its tag; a
// second one would then sit beside it. An ID now carrying another
// owner's key was reused by Kuma, so ours is gone.
if prev := km.Status.MonitorID; prev != 0 {
found, key, err := r.Kuma.Probe(ctx, prev)
switch {
case err != nil:
return r.fail(ctx, km, &failure{ReasonKumaUnavailable, msgKumaUnavailable, err})
case found && key == "":
log.FromContext(ctx).Error(nil, "previous monitor exists without our ownership tag, not creating another",
"ownerKey", desired.OwnerKey, "monitorID", prev, "tagValue", key)
err := r.setReady(ctx, km, metav1.ConditionFalse, ReasonNotOwned, msgNotOwned, nil)
return ctrl.Result{RequeueAfter: r.resync()}, err
case found && key == desired.OwnerKey:
return r.fail(ctx, km, &failure{ReasonKumaUnavailable, msgKumaUnavailable,
fmt.Errorf("monitor %d carries our key but is missing from the owned list", prev)})
}
}
var err error
id, err = r.Kuma.Create(ctx, desired)
if err != nil {
if errors.Is(err, kumaclient.ErrPossibleOrphan) {
log.FromContext(ctx).Error(err, "create outcome unknown, an untagged monitor may exist in Kuma",
"ownerKey", desired.OwnerKey)
return r.fail(ctx, km, &failure{ReasonPossibleOrphan, msgPossibleOrphan, err})
}
return r.fail(ctx, km, &failure{ReasonKumaUnavailable, msgKumaUnavailable, err})
}
} else {
id = res.mon.ID
desired.ID = id
if !reflect.DeepEqual(desired.Normalized(), *res.mon) {
if err := r.Kuma.Update(ctx, desired); err != nil {
return r.fail(ctx, km, &failure{ReasonKumaUnavailable, msgKumaUnavailable, err})
}
}
}
if len(res.ids) > 1 {
log.FromContext(ctx).Error(nil, "several Kuma monitors carry the same owner key, syncing the lowest ID",
"ownerKey", desired.OwnerKey, "monitorIDs", res.ids)
err := r.setReady(ctx, km, metav1.ConditionFalse, ReasonDuplicateOwner, msgDuplicateOwner, &id)
return ctrl.Result{RequeueAfter: r.resync()}, err
}
if err := r.setReady(ctx, km, metav1.ConditionTrue, ReasonSynced, msgSynced, &id); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{RequeueAfter: r.resync()}, nil
}
func (r *Reconciler) reconcileDelete(ctx context.Context, km *v1.KumaMonitor) (ctrl.Result, error) {
if !controllerutil.ContainsFinalizer(km, Finalizer) {
return ctrl.Result{}, nil
}
res, fail := r.lookup(ctx, OwnerKey(km))
if fail != nil {
return r.fail(ctx, km, fail)
}
// Remove every monitor carrying the key, duplicates included.
for _, id := range res.ids {
if err := r.Kuma.Delete(ctx, id); err != nil && !errors.Is(err, kumaclient.ErrNotFound) {
return r.fail(ctx, km, &failure{ReasonKumaUnavailable, msgKumaUnavailable, err})
}
}
controllerutil.RemoveFinalizer(km, Finalizer)
return ctrl.Result{}, r.Client.Update(ctx, km)
}
type lookupResult struct {
mon *kumaclient.Monitor // lowest ID carrying the key
ids []int64 // all IDs carrying the key, ascending
}
// lookup finds the monitors owned by key. A failed or incomplete list is a
// failure, never "absent": the one exception is a conflict on other monitors
// while the partial list already holds this key, which may be adopted but
// never leads to a create.
func (r *Reconciler) lookup(ctx context.Context, key string) (lookupResult, *failure) {
list, err := r.Kuma.ListOwned(ctx)
var res lookupResult
for i := range list {
if list[i].OwnerKey != key {
continue
}
res.ids = append(res.ids, list[i].ID)
if res.mon == nil || list[i].ID < res.mon.ID {
res.mon = &list[i]
}
}
slices.Sort(res.ids)
if err == nil {
return res, nil
}
if errors.Is(err, kumaclient.ErrOwnerTagMissing) {
return res, &failure{ReasonOwnerTagMissing, msgOwnerTagMissing, err}
}
conflicts, only := kumaclient.Conflicts(err)
for _, c := range conflicts {
if slices.Contains(c.Keys, key) {
return res, &failure{ReasonOwnerConflict, msgOwnerConflict, err}
}
}
if !only {
return res, &failure{ReasonKumaUnavailable, msgKumaUnavailable, err}
}
if res.mon != nil {
log.FromContext(ctx).Error(err, "owner conflict on another monitor, updating the monitor already found",
"ownerKey", key)
return res, nil
}
return res, &failure{ReasonKumaUnavailable, msgListIncomplete, err}
}
type parentOutcome struct {
id int64
reason string // empty: resolved
message string
requeue time.Duration
}
// resolveParent checks the whole parentRef chain: every ancestor must exist
// and be a group, without cycles or more than maxParentDepth levels.
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
}
pending := parentOutcome{reason: ReasonParentPending, message: msgParentPending, requeue: parentRetry}
visited := map[string]bool{km.Name: true}
cur := km
var directID int64
for depth := 0; cur.Spec.ParentRef != nil; depth++ {
if depth >= maxParentDepth {
return invalid(msgParentTooDeep)
}
name := cur.Spec.ParentRef.Name
if visited[name] {
return invalid(msgParentCycle)
}
visited[name] = true
parent := &v1.KumaMonitor{}
err := r.Client.Get(ctx, types.NamespacedName{Namespace: km.Namespace, Name: name}, parent)
if apierrors.IsNotFound(err) {
return pending, nil
}
if err != nil {
return parentOutcome{}, err
}
if parent.Spec.Type != "group" {
return invalid(msgParentNotGroup)
}
if depth == 0 {
directID = parent.Status.MonitorID
}
cur = parent
}
if directID == 0 {
return pending, nil
}
return parentOutcome{id: directID}, nil
}
// fail records a Ready=False outcome and returns its cause so the request is
// requeued with backoff.
func (r *Reconciler) fail(ctx context.Context, km *v1.KumaMonitor, f *failure) (ctrl.Result, error) {
log.FromContext(ctx).Info("reconcile failed", "reason", f.reason, "error", f.err.Error())
if serr := r.setReady(ctx, km, 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 monitor ID when given, through
// the status subresource. It writes nothing if the status is unchanged.
func (r *Reconciler) setReady(
ctx context.Context, km *v1.KumaMonitor, status metav1.ConditionStatus, reason, msg string, monitorID *int64,
) error {
orig := km.Status.DeepCopy()
if monitorID != nil {
km.Status.MonitorID = *monitorID
km.Status.ObservedGeneration = km.Generation
}
meta.SetStatusCondition(&km.Status.Conditions, metav1.Condition{
Type: v1.ConditionReady,
Status: status,
Reason: reason,
Message: msg,
ObservedGeneration: km.Generation,
})
if reflect.DeepEqual(*orig, km.Status) {
return nil
}
return r.Client.Status().Update(ctx, km)
}
@@ -0,0 +1,618 @@
package controller
import (
"context"
"errors"
"fmt"
"testing"
"time"
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/runtime"
"k8s.io/apimachinery/pkg/types"
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/k.renner/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/k.renner/homelab/kuma-sync/internal/kumaclient"
)
// counting records calls on top of the Kuma fake.
type counting struct {
kumaclient.Client
creates, updates, deletes int
}
func (c *counting) Create(ctx context.Context, m kumaclient.Monitor) (int64, error) {
c.creates++
return c.Client.Create(ctx, m)
}
func (c *counting) Update(ctx context.Context, m kumaclient.Monitor) error {
c.updates++
return c.Client.Update(ctx, m)
}
func (c *counting) Delete(ctx context.Context, id int64) error {
c.deletes++
return c.Client.Delete(ctx, id)
}
type env struct {
r *Reconciler
k8s client.Client
kuma *kumaclient.Fake
cnt *counting
}
func newEnv(t *testing.T, objs ...client.Object) *env {
t.Helper()
s := runtime.NewScheme()
if err := v1.AddToScheme(s); err != nil {
t.Fatal(err)
}
k8s := fake.NewClientBuilder().WithScheme(s).WithObjects(objs...).WithStatusSubresource(&v1.KumaMonitor{}).Build()
kf := kumaclient.NewFake()
cnt := &counting{Client: kf}
return &env{r: &Reconciler{Client: k8s, Kuma: cnt}, k8s: k8s, kuma: kf, cnt: cnt}
}
func httpMonitor(name string) *v1.KumaMonitor {
return &v1.KumaMonitor{
ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: name},
Spec: v1.KumaMonitorSpec{
Type: "http", DisplayName: name, Interval: 60, RetryInterval: 60,
HTTP: &v1.HTTPSpec{URL: "https://example.com"},
},
}
}
func (e *env) reconcile(t *testing.T, name string) (ctrl.Result, error) {
t.Helper()
return e.r.Reconcile(t.Context(), ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "default", Name: name}})
}
func (e *env) get(t *testing.T, name string) *v1.KumaMonitor {
t.Helper()
km := &v1.KumaMonitor{}
if err := e.k8s.Get(t.Context(), types.NamespacedName{Namespace: "default", Name: name}, km); err != nil {
t.Fatal(err)
}
return km
}
func ready(t *testing.T, km *v1.KumaMonitor) *metav1.Condition {
t.Helper()
c := meta.FindStatusCondition(km.Status.Conditions, v1.ConditionReady)
if c == nil {
t.Fatal("no Ready condition")
}
return c
}
func TestReconcileCreates(t *testing.T) {
e := newEnv(t, httpMonitor("a"))
res, err := e.reconcile(t, "a")
if err != nil {
t.Fatal(err)
}
if res.RequeueAfter != 5*time.Minute {
t.Errorf("RequeueAfter = %v", res.RequeueAfter)
}
if e.cnt.creates != 1 {
t.Fatalf("creates = %d", e.cnt.creates)
}
km := e.get(t, "a")
if km.Status.MonitorID == 0 || km.Status.ObservedGeneration != km.Generation {
t.Errorf("status = %+v", km.Status)
}
if c := ready(t, km); c.Status != metav1.ConditionTrue {
t.Errorf("Ready = %+v", c)
}
if len(km.Finalizers) != 1 {
t.Errorf("finalizers = %v", km.Finalizers)
}
if got := e.kuma.Get(km.Status.MonitorID); got == nil || got.OwnerKey != "default/a" {
t.Errorf("kuma monitor = %+v", got)
}
}
func TestReconcileAdoptsExisting(t *testing.T) {
km := httpMonitor("a")
e := newEnv(t, km)
existing := Desired(km)
id, err := e.kuma.Create(t.Context(), existing)
if err != nil {
t.Fatal(err)
}
if _, err := e.reconcile(t, "a"); err != nil {
t.Fatal(err)
}
if e.cnt.creates != 0 || e.cnt.updates != 0 {
t.Errorf("creates=%d updates=%d, want 0 and 0", e.cnt.creates, e.cnt.updates)
}
if got := e.get(t, "a").Status.MonitorID; got != id {
t.Errorf("monitorID = %d, want %d", got, id)
}
}
func TestReconcileUpdatesDrift(t *testing.T) {
km := httpMonitor("a")
e := newEnv(t, km)
drifted := Desired(km)
drifted.Interval = 30
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); got.Interval != 60 {
t.Errorf("interval = %d", got.Interval)
}
}
func TestReconcileNoopWhenEqual(t *testing.T) {
km := httpMonitor("a")
e := newEnv(t, km)
e.kuma.Create(t.Context(), Desired(km))
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", e.cnt.updates, e.cnt.creates)
}
}
func TestReconcileNormalizedNoSpuriousUpdate(t *testing.T) {
km := httpMonitor("a")
km.Spec.HTTP.AcceptedStatusCodes = nil
km.Spec.Timeout = nil
e := newEnv(t, km)
for range 2 {
if _, err := e.reconcile(t, "a"); err != nil {
t.Fatal(err)
}
}
if e.cnt.creates != 1 || e.cnt.updates != 0 {
t.Fatalf("creates=%d updates=%d, want 1 and 0", e.cnt.creates, e.cnt.updates)
}
}
func TestReconcileWaitsForParent(t *testing.T) {
group := &v1.KumaMonitor{
ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "grp"},
Spec: v1.KumaMonitorSpec{Type: "group", DisplayName: "grp", Interval: 60, RetryInterval: 60},
}
child := httpMonitor("child")
child.Spec.ParentRef = &v1.LocalRef{Name: "grp"}
e := newEnv(t, group, child)
for _, step := range []string{"parent without monitorID", "missing parent"} {
if step == "missing parent" {
if err := e.k8s.Delete(t.Context(), group); err != nil {
t.Fatal(err)
}
}
res, err := e.reconcile(t, "child")
if err != nil {
t.Fatalf("%s: %v", step, err)
}
if res.RequeueAfter != 10*time.Second {
t.Errorf("%s: RequeueAfter = %v", step, res.RequeueAfter)
}
if c := ready(t, e.get(t, "child")); c.Status != metav1.ConditionFalse || c.Reason != ReasonParentPending {
t.Errorf("%s: Ready = %+v", step, c)
}
}
if e.cnt.creates != 0 {
t.Errorf("creates = %d", e.cnt.creates)
}
}
func TestReconcileResolvesParent(t *testing.T) {
group := &v1.KumaMonitor{
ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "grp"},
Spec: v1.KumaMonitorSpec{Type: "group", DisplayName: "grp", Interval: 60, RetryInterval: 60},
Status: v1.KumaMonitorStatus{MonitorID: 42},
}
child := httpMonitor("child")
child.Spec.ParentRef = &v1.LocalRef{Name: "grp"}
e := newEnv(t, group, child)
if _, err := e.reconcile(t, "child"); err != nil {
t.Fatal(err)
}
got := e.kuma.Get(e.get(t, "child").Status.MonitorID)
if got == nil || got.Parent == nil || *got.Parent != 42 {
t.Fatalf("parent = %+v", got)
}
}
func deleting(km *v1.KumaMonitor) *v1.KumaMonitor {
now := metav1.Now()
km.DeletionTimestamp = &now
km.Finalizers = []string{Finalizer}
return km
}
func TestReconcileDeleteRemovesMonitorAndFinalizer(t *testing.T) {
km := deleting(httpMonitor("a"))
e := newEnv(t, km)
id, _ := e.kuma.Create(t.Context(), Desired(km))
if _, err := e.reconcile(t, "a"); err != nil {
t.Fatal(err)
}
if e.kuma.Get(id) != nil {
t.Error("kuma monitor not deleted")
}
err := e.k8s.Get(t.Context(), client.ObjectKeyFromObject(km), &v1.KumaMonitor{})
if !apierrors.IsNotFound(err) {
t.Errorf("resource should be gone after finalizer removal, got %v", err)
}
}
func TestReconcileDeleteToleratesMissingMonitor(t *testing.T) {
km := deleting(httpMonitor("a"))
e := newEnv(t, km)
if _, err := e.reconcile(t, "a"); err != nil {
t.Fatal(err)
}
if e.cnt.deletes != 0 {
t.Errorf("deletes = %d", e.cnt.deletes)
}
err := e.k8s.Get(t.Context(), client.ObjectKeyFromObject(km), &v1.KumaMonitor{})
if !apierrors.IsNotFound(err) {
t.Errorf("finalizer not removed: %v", err)
}
}
func TestReconcileDeleteToleratesNotFoundFromKuma(t *testing.T) {
km := deleting(httpMonitor("a"))
e := newEnv(t, km)
e.kuma.Create(t.Context(), Desired(km))
e.kuma.SetErrs(nil, nil, nil, fmt.Errorf("delete 1: %w", kumaclient.ErrNotFound))
if _, err := e.reconcile(t, "a"); err != nil {
t.Fatalf("ErrNotFound from Delete must count as success: %v", err)
}
if err := e.k8s.Get(t.Context(), client.ObjectKeyFromObject(km), &v1.KumaMonitor{}); !apierrors.IsNotFound(err) {
t.Errorf("finalizer not removed: %v", err)
}
}
func TestReconcileDeleteNotOwnedIsError(t *testing.T) {
km := deleting(httpMonitor("a"))
e := newEnv(t, km)
e.kuma.Create(t.Context(), Desired(km))
e.kuma.SetErrs(nil, nil, nil, fmt.Errorf("delete 1: %w", kumaclient.ErrNotOwned))
if _, err := e.reconcile(t, "a"); !errors.Is(err, kumaclient.ErrNotOwned) {
t.Fatalf("want ErrNotOwned, got %v", err)
}
if err := e.k8s.Get(t.Context(), client.ObjectKeyFromObject(km), &v1.KumaMonitor{}); err != nil {
t.Errorf("finalizer must stay: %v", err)
}
}
func TestReconcileListErrorNoCreate(t *testing.T) {
e := newEnv(t, httpMonitor("a"))
e.kuma.SetErrs(errors.New("boom"), nil, nil, nil)
if _, err := e.reconcile(t, "a"); err == nil {
t.Fatal("expected error")
}
if e.cnt.creates != 0 || e.cnt.updates != 0 {
t.Errorf("creates=%d updates=%d", e.cnt.creates, e.cnt.updates)
}
if c := ready(t, e.get(t, "a")); c.Status != metav1.ConditionFalse || c.Reason != ReasonKumaUnavailable {
t.Errorf("Ready = %+v", c)
}
}
func TestReconcileOwnerTagMissing(t *testing.T) {
e := newEnv(t, httpMonitor("a"))
e.kuma.SetErrs(kumaclient.ErrOwnerTagMissing, nil, nil, nil)
if _, err := e.reconcile(t, "a"); !errors.Is(err, kumaclient.ErrOwnerTagMissing) {
t.Fatalf("err = %v", err)
}
if e.cnt.creates != 0 {
t.Errorf("creates = %d", e.cnt.creates)
}
if c := ready(t, e.get(t, "a")); c.Reason != ReasonOwnerTagMissing {
t.Errorf("Ready = %+v", c)
}
}
func TestReconcileOwnerConflict(t *testing.T) {
e := newEnv(t, httpMonitor("a"))
// Three keys, ours last: a first-two-values message would miss it.
e.kuma.SetListPartial(errors.Join(&kumaclient.ConflictError{MonitorID: 3, Keys: []string{"default/other", "default/x", "default/a"}}))
if _, err := e.reconcile(t, "a"); !errors.Is(err, kumaclient.ErrOwnerConflict) {
t.Fatalf("err = %v", err)
}
if e.cnt.creates != 0 || e.cnt.updates != 0 {
t.Errorf("creates=%d updates=%d", e.cnt.creates, e.cnt.updates)
}
if c := ready(t, e.get(t, "a")); c.Reason != ReasonOwnerConflict {
t.Errorf("Ready = %+v", c)
}
}
func otherConflict() error {
return errors.Join(&kumaclient.ConflictError{MonitorID: 3, Keys: []string{"default/x", "default/y"}})
}
func TestReconcileConflictOnOtherMonitorNeverCreates(t *testing.T) {
e := newEnv(t, httpMonitor("a"))
e.kuma.SetListPartial(otherConflict())
if _, err := e.reconcile(t, "a"); !errors.Is(err, kumaclient.ErrOwnerConflict) {
t.Fatalf("err = %v", err)
}
if e.cnt.creates != 0 {
t.Errorf("creates = %d, want 0", e.cnt.creates)
}
c := ready(t, e.get(t, "a"))
if c.Status != metav1.ConditionFalse || c.Reason != ReasonKumaUnavailable ||
c.Message != "monitor list incomplete: owner conflict on another monitor" {
t.Errorf("Ready = %+v", c)
}
}
func TestReconcileConflictOnOtherMonitorAdoptsFoundByID(t *testing.T) {
km := httpMonitor("a")
e := newEnv(t, km)
drifted := Desired(km)
drifted.Interval = 30
id, _ := e.kuma.Create(t.Context(), drifted)
e.kuma.SetListPartial(otherConflict())
if _, err := e.reconcile(t, "a"); 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)
}
if got := e.get(t, "a").Status.MonitorID; got != id {
t.Errorf("monitorID = %d, want %d", got, id)
}
}
func TestReconcileFailureMessageIsStable(t *testing.T) {
e := newEnv(t, httpMonitor("a"))
e.kuma.SetErrs(errors.New("boom 1"), nil, nil, nil)
e.reconcile(t, "a")
first := e.get(t, "a")
e.kuma.SetErrs(errors.New("boom 2"), nil, nil, nil)
e.reconcile(t, "a")
second := e.get(t, "a")
if ready(t, first).Message != ready(t, second).Message || first.ResourceVersion != second.ResourceVersion {
t.Errorf("failure rewrote status: %q vs %q (rv %s vs %s)",
ready(t, first).Message, ready(t, second).Message, first.ResourceVersion, second.ResourceVersion)
}
}
func TestReconcileFinalizerSurvivesFailedCreate(t *testing.T) {
e := newEnv(t, httpMonitor("a"))
e.kuma.SetErrs(nil, errors.New("boom"), nil, nil)
if _, err := e.reconcile(t, "a"); err == nil {
t.Fatal("expected error")
}
if got := e.get(t, "a").Finalizers; len(got) != 1 || got[0] != Finalizer {
t.Errorf("finalizers = %v", got)
}
}
func withPreviousMonitor(id int64) *v1.KumaMonitor {
km := httpMonitor("a")
km.Status.MonitorID = id
return km
}
func TestReconcileRefusesCreateBesideUntaggedPrevious(t *testing.T) {
e := newEnv(t, withPreviousMonitor(1))
e.kuma.AddForeign(kumaclient.Monitor{ID: 1, Name: "was ours"})
res, err := e.reconcile(t, "a")
if err != nil {
t.Fatal(err)
}
if e.cnt.creates != 0 {
t.Fatalf("creates = %d, want 0", e.cnt.creates)
}
if res.RequeueAfter != 5*time.Minute {
t.Errorf("RequeueAfter = %v", res.RequeueAfter)
}
c := ready(t, e.get(t, "a"))
if c.Status != metav1.ConditionFalse || c.Reason != ReasonNotOwned ||
c.Message != "previous monitor exists without our ownership tag; restore the tag or delete it in Kuma" {
t.Errorf("Ready = %+v", c)
}
}
func TestReconcileCreatesWhenPreviousIDReusedByOtherOwner(t *testing.T) {
e := newEnv(t, withPreviousMonitor(1))
if _, err := e.kuma.Create(t.Context(), kumaclient.Monitor{Name: "x", OwnerKey: "default/other"}); err != nil {
t.Fatal(err)
}
if _, err := e.reconcile(t, "a"); err != nil {
t.Fatal(err)
}
if e.cnt.creates != 1 {
t.Errorf("creates = %d, want 1", e.cnt.creates)
}
if c := ready(t, e.get(t, "a")); c.Reason != ReasonSynced {
t.Errorf("Ready = %+v", c)
}
if m := e.kuma.Get(1); m == nil || m.OwnerKey != "default/other" {
t.Errorf("other owner's monitor 1 = %+v, want untouched", m)
}
}
func TestReconcileCreatesWhenPreviousMonitorIsGone(t *testing.T) {
e := newEnv(t, withPreviousMonitor(77))
if _, err := e.reconcile(t, "a"); err != nil {
t.Fatal(err)
}
if e.cnt.creates != 1 {
t.Errorf("creates = %d, want 1", e.cnt.creates)
}
}
func TestReconcileProbeErrorNeverCreates(t *testing.T) {
e := newEnv(t, withPreviousMonitor(1))
e.kuma.SetProbeErr(errors.New("boom"))
if _, err := e.reconcile(t, "a"); err == nil {
t.Fatal("expected error")
}
if e.cnt.creates != 0 {
t.Errorf("creates = %d, want 0", e.cnt.creates)
}
if c := ready(t, e.get(t, "a")); c.Reason != ReasonKumaUnavailable {
t.Errorf("Ready = %+v", c)
}
}
func TestReconcileDuplicateOwner(t *testing.T) {
km := httpMonitor("a")
e := newEnv(t, km)
first, _ := e.kuma.Create(t.Context(), Desired(km))
second, _ := e.kuma.Create(t.Context(), Desired(km))
if _, err := e.reconcile(t, "a"); err != nil {
t.Fatal(err)
}
if e.cnt.creates != 0 || e.cnt.deletes != 0 {
t.Errorf("creates=%d deletes=%d, want 0 and 0", e.cnt.creates, e.cnt.deletes)
}
got := e.get(t, "a")
if got.Status.MonitorID != first || first > second {
t.Errorf("monitorID = %d, want lowest %d", got.Status.MonitorID, first)
}
if c := ready(t, got); c.Status != metav1.ConditionFalse || c.Reason != ReasonDuplicateOwner {
t.Errorf("Ready = %+v", c)
}
if e.kuma.Get(second) == nil {
t.Error("extra monitor must not be deleted by the reconciler")
}
}
func TestReconcileDeleteRemovesAllDuplicates(t *testing.T) {
km := deleting(httpMonitor("a"))
e := newEnv(t, km)
first, _ := e.kuma.Create(t.Context(), Desired(km))
second, _ := e.kuma.Create(t.Context(), Desired(km))
if _, err := e.reconcile(t, "a"); err != nil {
t.Fatal(err)
}
if e.kuma.Get(first) != nil || e.kuma.Get(second) != nil {
t.Error("all monitors carrying the key must be deleted")
}
if err := e.k8s.Get(t.Context(), client.ObjectKeyFromObject(km), &v1.KumaMonitor{}); !apierrors.IsNotFound(err) {
t.Errorf("finalizer not removed: %v", err)
}
}
func TestReconcileDeleteFailureIsVisibleInCondition(t *testing.T) {
km := deleting(httpMonitor("a"))
e := newEnv(t, km)
e.kuma.SetErrs(kumaclient.ErrOwnerTagMissing, nil, nil, nil)
if _, err := e.reconcile(t, "a"); !errors.Is(err, kumaclient.ErrOwnerTagMissing) {
t.Fatalf("err = %v", err)
}
got := e.get(t, "a")
if len(got.Finalizers) != 1 {
t.Errorf("finalizer must stay: %v", got.Finalizers)
}
if c := ready(t, got); c.Reason != ReasonOwnerTagMissing {
t.Errorf("Ready = %+v", c)
}
// Other delete failures: incomplete list without our key.
e2 := newEnv(t, deleting(httpMonitor("a")))
e2.kuma.SetListPartial(otherConflict())
if _, err := e2.reconcile(t, "a"); err == nil {
t.Fatal("deleting on an incomplete list must fail")
}
if err := e2.k8s.Get(t.Context(), client.ObjectKeyFromObject(km), &v1.KumaMonitor{}); err != nil {
t.Errorf("finalizer must stay: %v", err)
}
}
func group(name, parent string) *v1.KumaMonitor {
g := &v1.KumaMonitor{
ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: name},
Spec: v1.KumaMonitorSpec{Type: "group", DisplayName: name, Interval: 60, RetryInterval: 60},
Status: v1.KumaMonitorStatus{MonitorID: 100},
}
if parent != "" {
g.Spec.ParentRef = &v1.LocalRef{Name: parent}
}
return g
}
func TestReconcileInvalidParent(t *testing.T) {
notGroup := httpMonitor("web")
notGroup.Status.MonitorID = 5
selfRef := httpMonitor("self")
selfRef.Spec.ParentRef = &v1.LocalRef{Name: "self"}
cycleA, cycleB := group("ga", "gb"), group("gb", "ga")
underCycle := httpMonitor("underCycle")
underCycle.Spec.ParentRef = &v1.LocalRef{Name: "ga"}
// A chain of 12 groups is deeper than allowed.
var chain []client.Object
for i := range 12 {
parent := ""
if i < 11 {
parent = fmt.Sprintf("c%d", i+1)
}
chain = append(chain, group(fmt.Sprintf("c%d", i), parent))
}
deep := httpMonitor("deep")
deep.Spec.ParentRef = &v1.LocalRef{Name: "c0"}
underWeb := httpMonitor("underWeb")
underWeb.Spec.ParentRef = &v1.LocalRef{Name: "web"}
objs := append([]client.Object{notGroup, selfRef, cycleA, cycleB, underCycle, deep, underWeb}, chain...)
e := newEnv(t, objs...)
for _, name := range []string{"underWeb", "self", "underCycle", "deep"} {
res, err := e.reconcile(t, name)
if err != nil {
t.Fatalf("%s: %v", name, err)
}
if res.RequeueAfter != 5*time.Minute {
t.Errorf("%s: RequeueAfter = %v, want 5m", name, res.RequeueAfter)
}
if c := ready(t, e.get(t, name)); c.Status != metav1.ConditionFalse || c.Reason != ReasonInvalidParent {
t.Errorf("%s: Ready = %+v", name, c)
}
}
if e.cnt.creates != 0 {
t.Errorf("creates = %d", e.cnt.creates)
}
}
func TestReconcileNestedGroupsResolve(t *testing.T) {
child := httpMonitor("child")
child.Spec.ParentRef = &v1.LocalRef{Name: "inner"}
e := newEnv(t, group("outer", ""), group("inner", "outer"), child)
if _, err := e.reconcile(t, "child"); err != nil {
t.Fatal(err)
}
if e.cnt.creates != 1 {
t.Errorf("creates = %d", e.cnt.creates)
}
}
func TestReconcilePossibleOrphanStatus(t *testing.T) {
e := newEnv(t, httpMonitor("a"))
e.kuma.SetErrs(nil, fmt.Errorf("create: %w", kumaclient.ErrPossibleOrphan), nil, nil)
if _, err := e.reconcile(t, "a"); !errors.Is(err, kumaclient.ErrPossibleOrphan) {
t.Fatalf("err = %v", err)
}
c := ready(t, e.get(t, "a"))
if c.Status != metav1.ConditionFalse || c.Reason != ReasonPossibleOrphan {
t.Errorf("Ready = %+v", c)
}
}
@@ -0,0 +1,69 @@
package controller
import (
"maps"
"slices"
v1 "gitea.renner.uno/k.renner/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/k.renner/homelab/kuma-sync/internal/kumaclient"
)
// defaultMaxRedirects is Kuma's default for a monitor with no explicit value.
const defaultMaxRedirects = 10
// OwnerKey is the value of the kuma-sync tag for a resource.
func OwnerKey(km *v1.KumaMonitor) string {
return km.Namespace + "/" + km.Name
}
// Desired maps a spec to the monitor Kuma should hold. Parent is resolved by
// the reconciler.
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),
}
if s.Timeout != nil {
m.Timeout = *s.Timeout
}
if h := s.HTTP; h != nil {
m.URL = h.URL
m.Method = h.Method
m.AcceptedStatusCodes = slices.Clone(h.AcceptedStatusCodes)
m.Headers = maps.Clone(h.Headers)
m.Body = h.Body
m.MaxRedirects = defaultMaxRedirects
if h.MaxRedirects != nil {
m.MaxRedirects = *h.MaxRedirects
}
m.IgnoreTLS = h.IgnoreTLS
m.ExpiryNotification = h.ExpiryNotification
}
if k := s.Keyword; k != nil {
m.Keyword = k.Keyword
m.InvertKeyword = k.Invert
}
if q := s.JSONQuery; q != nil {
m.JSONPath = q.Expression
m.JSONPathOperator = q.Operator
m.ExpectedValue = q.Expected
}
if d := s.DNS; d != nil {
m.Hostname = d.Hostname
m.DNSResolveType = d.ResolveType
m.DNSResolveServer = d.ResolveServer
m.Port = d.Port
}
if p := s.Port; p != nil {
m.Hostname = p.Hostname
m.Port = p.Port
}
return m
}
@@ -0,0 +1,98 @@
package controller
import (
"reflect"
"testing"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
v1 "gitea.renner.uno/k.renner/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/k.renner/homelab/kuma-sync/internal/kumaclient"
)
func TestMappingAllTypes(t *testing.T) {
i32 := func(v int32) *int32 { return &v }
base := func(typ string) v1.KumaMonitorSpec {
return v1.KumaMonitorSpec{
Type: typ, DisplayName: "dn", Description: "desc",
Interval: 120, RetryInterval: 30, MaxRetries: 2,
}
}
httpSpec := &v1.HTTPSpec{
URL: "https://e.com", Method: "POST", AcceptedStatusCodes: []string{"200"},
Headers: map[string]string{"a": "b"}, Body: "x", MaxRedirects: i32(3),
IgnoreTLS: true, ExpiryNotification: true,
}
mk := func(s v1.KumaMonitorSpec) *v1.KumaMonitor {
return &v1.KumaMonitor{ObjectMeta: metav1.ObjectMeta{Namespace: "ns", Name: "n"}, Spec: s}
}
common := func(typ string) kumaclient.Monitor {
return kumaclient.Monitor{
Type: typ, Name: "dn", Description: "desc", Active: true,
Interval: 120, RetryInterval: 30, MaxRetries: 2, OwnerKey: "ns/n",
}
}
withHTTP := func(m kumaclient.Monitor) kumaclient.Monitor {
m.URL, m.Method, m.AcceptedStatusCodes = "https://e.com", "POST", []string{"200"}
m.Headers, m.Body, m.MaxRedirects = map[string]string{"a": "b"}, "x", 3
m.IgnoreTLS, m.ExpiryNotification, m.Timeout = true, true, 15
return m
}
group := base("group")
httpS := base("http")
httpS.HTTP, httpS.Timeout = httpSpec, i32(15)
kw := base("keyword")
kw.HTTP, kw.Timeout = httpSpec, i32(15)
kw.Keyword = &v1.KeywordSpec{Keyword: "ok", Invert: true}
jq := base("json-query")
jq.HTTP, jq.Timeout = httpSpec, i32(15)
jq.JSONQuery = &v1.JSONQuerySpec{Expression: "a.b", Operator: "contains", Expected: "z"}
dns := base("dns")
dns.DNS = &v1.DNSSpec{Hostname: "h.com", ResolveType: "AAAA", ResolveServer: "9.9.9.9", Port: 5353}
port := base("port")
port.Port = &v1.PortSpec{Hostname: "p.com", Port: 443}
paused := base("group")
paused.Paused = true
wantKW := withHTTP(common("keyword"))
wantKW.Keyword, wantKW.InvertKeyword = "ok", true
wantJQ := withHTTP(common("json-query"))
wantJQ.JSONPath, wantJQ.JSONPathOperator, wantJQ.ExpectedValue = "a.b", "contains", "z"
wantDNS := common("dns")
wantDNS.Hostname, wantDNS.DNSResolveType, wantDNS.DNSResolveServer, wantDNS.Port = "h.com", "AAAA", "9.9.9.9", 5353
wantPort := common("port")
wantPort.Hostname, wantPort.Port = "p.com", 443
wantPaused := common("group")
wantPaused.Active = false
cases := []struct {
name string
spec v1.KumaMonitorSpec
want kumaclient.Monitor
}{
{"group", group, common("group")},
{"http", httpS, withHTTP(common("http"))},
{"keyword", kw, wantKW},
{"json-query", jq, wantJQ},
{"dns", dns, wantDNS},
{"port", port, wantPort},
{"paused maps to inactive", paused, wantPaused},
}
for _, c := range cases {
got := Desired(mk(c.spec))
if !reflect.DeepEqual(got, c.want) {
t.Errorf("%s:\n got %+v\n want %+v", c.name, got, c.want)
}
}
}
func TestMappingMaxRedirectsDefault(t *testing.T) {
km := &v1.KumaMonitor{
ObjectMeta: metav1.ObjectMeta{Namespace: "ns", Name: "n"},
Spec: v1.KumaMonitorSpec{Type: "http", DisplayName: "d", HTTP: &v1.HTTPSpec{URL: "https://e.com"}},
}
if got := Desired(km).MaxRedirects; got != 10 {
t.Errorf("MaxRedirects = %d, want Kuma's default 10", got)
}
}
@@ -0,0 +1,592 @@
package kumaclient
import (
"context"
"encoding/json"
"errors"
"fmt"
"math"
"slices"
"strings"
"sync"
"time"
kuma "github.com/breml/go-uptime-kuma-client"
"github.com/breml/go-uptime-kuma-client/monitor"
"github.com/breml/go-uptime-kuma-client/tag"
)
const (
ownerTagName = "kuma-sync"
ownerTagColor = "#2563eb"
operationTimeout = 30 * time.Second
probeTimeout = 10 * time.Second
connectTimeout = 30 * time.Second
reconnectAttempts = 6
reconnectMaxDelay = 8 * time.Second
)
// Breml is a Client backed by github.com/breml/go-uptime-kuma-client.
// It serialises all calls on one session. The socket.io transport does not
// reconnect, so when a call fails and a probe shows the session is dead, the
// session is rebuilt (session token first, password as fallback; bounded
// backoff) and the call is retried once. Tag ownership caveats: the kuma-sync
// tag is the lowest-ID tag of that name, and the library clobbers monitor
// conditions and retryOnlyOnStatusCodeFailure on update.
type Breml struct {
mu sync.Mutex
c *kuma.Client
tagID int64
sessCtx context.Context
url string
user string
pass string
opts []kuma.Option
token string
backoff0 time.Duration
}
var _ Client = (*Breml)(nil)
// NewBreml connects and logs in once, then ensures the kuma-sync tag exists.
// ctx bounds the lifetime of the socket session, not just the connect call.
//
// After a Kuma restart or dropped connection, the next call that fails with a
// dead session reconnects and retries once. Create is the exception: it is
// never retried, because its outcome can be unknown. Two windows can leave an
// untagged duplicate in Kuma, each signalled by an error wrapping
// ErrPossibleOrphan (the controller should log it at error level):
// 1. Kuma stored the monitor but the ack was lost.
// 2. Kuma stored the monitor, then tagging and the cleanup delete failed.
//
// In both cases the untagged monitor is never adopted or touched, so it needs
// manual cleanup in the Kuma UI, and the next reconcile may create a tagged
// monitor beside it.
func NewBreml(ctx context.Context, url, username, password string) (Client, error) {
return newBreml(ctx, url, username, password)
}
func newBreml(ctx context.Context, url, username, password string, opts ...kuma.Option) (*Breml, error) {
opts = append([]kuma.Option{kuma.WithOperationTimeout(operationTimeout)}, opts...)
c, err := kuma.New(ctx, url, username, password, opts...)
if err != nil {
return nil, fmt.Errorf("connect to kuma: %w", err)
}
b := &Breml{
c: c, sessCtx: ctx, url: url, user: username, pass: password, opts: opts,
token: c.SessionToken(), backoff0: time.Second,
}
if b.tagID, err = b.ensureTag(ctx); err != nil {
_ = c.Disconnect()
return nil, err
}
return b, nil
}
// Close ends the session.
func (b *Breml) Close() error {
return b.c.Disconnect()
}
// pickOwnerTag returns the lowest-ID tag named kuma-sync. Kuma does not
// enforce unique tag names.
func pickOwnerTag(tags []tag.Tag) (int64, bool) {
var id int64
found := false
for _, t := range tags {
if t.Name == ownerTagName && (!found || t.ID < id) {
id, found = t.ID, true
}
}
return id, found
}
// ownerKeyOf returns the kuma-sync tag value of a monitor. Different values on
// one monitor are a conflict, never resolved by picking one.
// The error is a *ConflictError listing all distinct values.
func ownerKeyOf(monitorID int64, tags []tag.MonitorTag, tagID int64) (string, error) {
var keys []string
for _, t := range tags {
if t.TagID == tagID && t.Value != "" && !slices.Contains(keys, t.Value) {
keys = append(keys, t.Value)
}
}
switch len(keys) {
case 0:
return "", nil
case 1:
return keys[0], nil
}
slices.Sort(keys)
return "", &ConflictError{MonitorID: monitorID, Keys: keys}
}
// createdDespite reports whether a CreateMonitor result means the monitor
// exists. The library returns the ID together with ErrUpdateEventTimeout when
// the monitor was created and only the update event is missing (library
// monitor.go: CreateMonitor doc and the errors.Is(err, ErrUpdateEventTimeout)
// branch).
func createdDespite(id int64, err error) bool {
return err == nil || (id != 0 && errors.Is(err, kuma.ErrUpdateEventTimeout))
}
func (b *Breml) ensureTag(ctx context.Context) (int64, error) {
tags, err := b.c.GetTags(ctx)
if err != nil {
return 0, err
}
if id, ok := pickOwnerTag(tags); ok {
return id, nil
}
return b.c.CreateTag(ctx, tag.Tag{Name: ownerTagName, Color: ownerTagColor})
}
// recoverSession reports whether the session was rebuilt after err, so the
// caller may retry. Application-level errors and a probe on a healthy session
// return false.
func (b *Breml) recoverSession(ctx context.Context, err error) bool {
rebuilt, _ := b.checkSession(ctx, err)
return rebuilt
}
// checkSession probes the session after err. dead reports that the probe
// failed, i.e. err was a transport failure; rebuilt that it was then replaced.
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} {
if errors.Is(err, app) {
return false, false
}
}
pctx, cancel := context.WithTimeout(ctx, probeTimeout)
defer cancel()
if _, perr := b.c.GetTags(pctx); perr == nil {
return false, false
}
return b.reconnect(ctx) == nil, true
}
func (b *Breml) reconnect(ctx context.Context) error {
delay := b.backoff0
var last error
for attempt := 0; attempt < reconnectAttempts; attempt++ {
if attempt > 0 {
select {
case <-time.After(delay):
case <-ctx.Done():
return ctx.Err()
case <-b.sessCtx.Done():
return b.sessCtx.Err()
}
delay = min(delay*2, reconnectMaxDelay)
}
opts := append(slices.Clone(b.opts), kuma.WithConnectTimeout(connectTimeout))
if b.token != "" {
opts = append(opts, kuma.WithSessionToken(b.token))
}
c, err := kuma.New(b.sessCtx, b.url, b.user, b.pass, opts...)
if err != nil {
last = err
continue
}
_ = b.c.Disconnect()
b.c = c
if t := c.SessionToken(); t != "" {
b.token = t
}
return nil
}
return fmt.Errorf("reconnect to kuma: %w", last)
}
// withRecovery runs op and, if it fails on a dead session, retries it once on
// a rebuilt one.
func withRecovery[T any](ctx context.Context, b *Breml, op func() (T, error)) (T, error) {
v, err := op()
if err != nil && b.recoverSession(ctx, err) {
return op()
}
return v, err
}
func (b *Breml) ListOwned(ctx context.Context) ([]Monitor, error) {
b.mu.Lock()
defer b.mu.Unlock()
return withRecovery(ctx, b, func() ([]Monitor, error) { return b.listOwned(ctx) })
}
func (b *Breml) listOwned(ctx context.Context) ([]Monitor, error) {
tags, err := b.c.GetTags(ctx)
if err != nil {
return nil, err
}
if !slices.ContainsFunc(tags, func(t tag.Tag) bool { return t.ID == b.tagID && t.Name == ownerTagName }) {
return nil, ErrOwnerTagMissing
}
bases, err := b.c.GetMonitors(ctx)
if err != nil {
return nil, err
}
var out []Monitor
var conflicts []error
for _, base := range bases {
key, err := ownerKeyOf(base.ID, base.Tags, b.tagID)
if err != nil {
conflicts = append(conflicts, err)
continue
}
if key == "" {
continue
}
m, err := toDomain(base, key)
if err != nil {
return nil, err
}
out = append(out, m.Normalized())
}
return out, errors.Join(conflicts...)
}
// Create is never retried. Two windows can leave an untagged monitor in Kuma,
// both signalled by an error wrapping ErrPossibleOrphan:
// 1. Kuma stored the monitor but the ack was lost (transport or operation
// timeout error, no ID returned).
// 2. Kuma stored the monitor, then tagging failed and removing it failed too.
//
// Either way an untagged duplicate may exist; the controller never touches
// untagged monitors, so it needs manual cleanup in the Kuma UI. The next
// reconcile may create a tagged monitor beside it.
func (b *Breml) Create(ctx context.Context, m Monitor) (int64, error) {
if m.OwnerKey == "" {
return 0, errors.New("create: empty OwnerKey")
}
b.mu.Lock()
defer b.mu.Unlock()
return flagUnknownCreate(
func() (int64, error) { return b.createOnce(ctx, m) },
func(err error) bool { _, dead := b.checkSession(ctx, err); return dead },
)
}
// flagUnknownCreate runs create once. A failure without an ID that is a
// transport or operation-timeout error leaves the outcome unknown, so it is
// wrapped in ErrPossibleOrphan instead of being retried.
func flagUnknownCreate(create func() (int64, error), sessionDead func(error) bool) (int64, error) {
id, err := create()
if err == nil || id != 0 || errors.Is(err, ErrPossibleOrphan) {
return id, err
}
if errors.Is(err, kuma.ErrOperationTimeout) || sessionDead(err) {
return 0, errors.Join(err, fmt.Errorf("create outcome unknown: %w", ErrPossibleOrphan))
}
return 0, err
}
func (b *Breml) createOnce(ctx context.Context, m Monitor) (int64, error) {
wire, err := toWire(m, nil)
if err != nil {
return 0, err
}
id, err := b.c.CreateMonitor(ctx, wire)
if !createdDespite(id, err) {
return 0, err
}
// Here the monitor exists, even if err is an update-event timeout.
if _, terr := b.c.AddMonitorTag(ctx, b.tagID, id, m.OwnerKey); terr != nil {
// An untagged leftover would be invisible to ListOwned.
if derr := b.c.DeleteMonitor(ctx, id); derr != nil {
return 0, errors.Join(terr, fmt.Errorf("remove untagged monitor %d: %w", id, derr),
fmt.Errorf("monitor %d: %w", id, ErrPossibleOrphan))
}
return 0, terr
}
return id, nil
}
// lookupOwned returns the current monitor and verifies the owner tag.
func (b *Breml) lookupOwned(ctx context.Context, op string, id int64) (monitor.Base, string, error) {
bases, err := b.c.GetMonitors(ctx)
if err != nil {
return monitor.Base{}, "", err
}
for _, base := range bases {
if base.ID != id {
continue
}
key, err := ownerKeyOf(base.ID, base.Tags, b.tagID)
if err != nil {
return monitor.Base{}, "", fmt.Errorf("%s %d: %w", op, id, err)
}
if key == "" {
return monitor.Base{}, "", fmt.Errorf("%s %d: %w", op, id, ErrNotOwned)
}
return base, key, nil
}
return monitor.Base{}, "", fmt.Errorf("%s %d: %w", op, id, ErrNotFound)
}
type probeResult struct {
found bool
key string
}
// Probe reports whether a monitor with this ID exists and which owner key it
// carries; an untagged monitor has "". Conflicting tag values return the
// *ConflictError.
func (b *Breml) Probe(ctx context.Context, id int64) (bool, string, error) {
b.mu.Lock()
defer b.mu.Unlock()
res, err := withRecovery(ctx, b, func() (probeResult, error) {
bases, err := b.c.GetMonitors(ctx)
if err != nil {
return probeResult{}, err
}
for _, base := range bases {
if base.ID != id {
continue
}
key, err := ownerKeyOf(base.ID, base.Tags, b.tagID)
return probeResult{found: true, key: key}, err
}
return probeResult{}, nil
})
return res.found, res.key, err
}
func (b *Breml) Update(ctx context.Context, m Monitor) error {
b.mu.Lock()
defer b.mu.Unlock()
_, err := withRecovery(ctx, b, func() (struct{}, error) { return struct{}{}, b.update(ctx, m) })
return err
}
func (b *Breml) update(ctx context.Context, m Monitor) error {
cur, key, err := b.lookupOwned(ctx, "update", m.ID)
if err != nil {
return err
}
if key != m.OwnerKey {
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 (notifications, proxy, resend interval, upside
// down, auth) survive the overwrite.
wire, err := toWire(m, &cur)
if err != nil {
return err
}
if err := b.c.UpdateMonitor(ctx, wire); err != nil {
return err
}
// editMonitor does not change the active state.
switch {
case cur.IsActive && !m.Active:
return b.c.PauseMonitor(ctx, m.ID)
case !cur.IsActive && m.Active:
return b.c.ResumeMonitor(ctx, m.ID)
}
return nil
}
func (b *Breml) Delete(ctx context.Context, id int64) error {
b.mu.Lock()
defer b.mu.Unlock()
_, err := withRecovery(ctx, b, func() (struct{}, error) {
if _, _, err := b.lookupOwned(ctx, "delete", id); err != nil {
return struct{}{}, err
}
return struct{}{}, b.c.DeleteMonitor(ctx, id)
})
return err
}
// toDomain converts a monitor from the Kuma monitor list.
func toDomain(base monitor.Base, ownerKey string) (Monitor, error) {
m := Monitor{
ID: base.ID,
Type: base.Type(),
Name: base.Name,
Parent: base.Parent,
Active: base.IsActive,
Interval: int32(base.Interval),
RetryInterval: int32(base.RetryInterval),
MaxRetries: int32(base.MaxRetries),
OwnerKey: ownerKey,
}
if base.Description != nil {
m.Description = *base.Description
}
switch m.Type {
case "http":
var v monitor.HTTP
if err := base.As(&v); err != nil {
return Monitor{}, err
}
if err := readHTTP(&m, v.HTTPDetails); err != nil {
return Monitor{}, err
}
case "keyword":
var v monitor.HTTPKeyword
if err := base.As(&v); err != nil {
return Monitor{}, err
}
if err := readHTTP(&m, v.HTTPDetails); err != nil {
return Monitor{}, err
}
m.Keyword = v.Keyword
m.InvertKeyword = v.InvertKeyword
case "json-query":
var v monitor.HTTPJSONQuery
if err := base.As(&v); err != nil {
return Monitor{}, err
}
if err := readHTTP(&m, v.HTTPDetails); err != nil {
return Monitor{}, err
}
m.JSONPath = v.JSONPath
m.JSONPathOperator = v.JSONPathOperator
m.ExpectedValue = v.ExpectedValue
case "dns":
var v monitor.DNS
if err := base.As(&v); err != nil {
return Monitor{}, err
}
m.Hostname = v.Hostname
m.Port = int32(v.Port)
m.DNSResolveType = string(v.ResolveType)
m.DNSResolveServer = v.ResolverServer
case "port":
var v monitor.TCPPort
if err := base.As(&v); err != nil {
return Monitor{}, err
}
m.Hostname = v.Hostname
m.Port = int32(v.Port)
}
return m, nil
}
func readHTTP(m *Monitor, d monitor.HTTPDetails) error {
m.URL = d.URL
m.Method = d.Method
m.Body = d.Body
m.AcceptedStatusCodes = slices.Clone(d.AcceptedStatusCodes)
m.MaxRedirects = int32(d.MaxRedirects)
m.IgnoreTLS = d.IgnoreTLS
m.ExpiryNotification = d.ExpiryNotification
m.Timeout = int32(math.Round(d.Timeout))
if strings.TrimSpace(d.Headers) != "" {
if err := json.Unmarshal([]byte(d.Headers), &m.Headers); err != nil {
return fmt.Errorf("monitor %d: parse headers: %w", m.ID, err)
}
}
return nil
}
// toWire builds the library monitor for m. With existing set, the typed
// object is first decoded from it so fields we do not manage are kept.
func toWire(m Monitor, existing *monitor.Base) (monitor.Monitor, error) {
m = m.Normalized()
load := func(target any) error {
if existing == nil {
return nil
}
return existing.As(target)
}
switch m.Type {
case "group":
v := &monitor.Group{}
if err := load(v); err != nil {
return nil, err
}
writeBase(&v.Base, m)
return v, nil
case "http":
v := &monitor.HTTP{}
if err := load(v); err != nil {
return nil, err
}
writeBase(&v.Base, m)
return v, writeHTTP(&v.HTTPDetails, m)
case "keyword":
v := &monitor.HTTPKeyword{}
if err := load(v); err != nil {
return nil, err
}
writeBase(&v.Base, m)
v.Keyword = m.Keyword
v.InvertKeyword = m.InvertKeyword
return v, writeHTTP(&v.HTTPDetails, m)
case "json-query":
v := &monitor.HTTPJSONQuery{}
if err := load(v); err != nil {
return nil, err
}
writeBase(&v.Base, m)
v.JSONPath = m.JSONPath
v.JSONPathOperator = m.JSONPathOperator
v.ExpectedValue = m.ExpectedValue
return v, writeHTTP(&v.HTTPDetails, m)
case "dns":
v := &monitor.DNS{}
if err := load(v); err != nil {
return nil, err
}
writeBase(&v.Base, m)
v.Hostname = m.Hostname
v.Port = int(m.Port)
v.ResolveType = monitor.DNSResolveType(m.DNSResolveType)
v.ResolverServer = m.DNSResolveServer
return v, nil
case "port":
v := &monitor.TCPPort{}
if err := load(v); err != nil {
return nil, err
}
writeBase(&v.Base, m)
v.Hostname = m.Hostname
v.Port = int(m.Port)
return v, nil
}
return nil, fmt.Errorf("unsupported monitor type %q", m.Type)
}
func writeBase(b *monitor.Base, m Monitor) {
b.ID = m.ID
b.Name = m.Name
desc := m.Description
b.Description = &desc
b.Parent = m.clone().Parent
b.Interval = int64(m.Interval)
b.RetryInterval = int64(m.RetryInterval)
b.MaxRetries = int64(m.MaxRetries)
b.IsActive = m.Active
}
// writeHTTP expects m to be normalized.
func writeHTTP(d *monitor.HTTPDetails, m Monitor) error {
d.URL = m.URL
d.Timeout = float64(m.Timeout)
d.ExpiryNotification = m.ExpiryNotification
d.IgnoreTLS = m.IgnoreTLS
d.MaxRedirects = int(m.MaxRedirects)
d.AcceptedStatusCodes = slices.Clone(m.AcceptedStatusCodes)
d.Method = m.Method
d.Body = m.Body
if d.HTTPBodyEncoding == "" {
d.HTTPBodyEncoding = "json"
}
d.Headers = ""
if len(m.Headers) > 0 {
raw, err := json.Marshal(m.Headers)
if err != nil {
return fmt.Errorf("encode headers: %w", err)
}
d.Headers = string(raw)
}
return nil
}
@@ -0,0 +1,572 @@
//go:build integration
package kumaclient
import (
"context"
"errors"
"io"
"net"
"reflect"
"testing"
"time"
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/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
)
const (
testUser = "admin"
testPass = "Sup3r-Secret-Passw0rd!"
)
// startKumaCtr runs a fresh Uptime Kuma and returns it with its base URL.
func startKumaCtr(t *testing.T) (testcontainers.Container, string) {
t.Helper()
ctx := context.Background()
ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: testcontainers.ContainerRequest{
Image: "louislam/uptime-kuma:2.5.6-slim",
ExposedPorts: []string{"3001/tcp"},
Env: map[string]string{"UPTIME_KUMA_DB_TYPE": "sqlite"},
WaitingFor: wait.ForHTTP("/api/entry-page").WithPort("3001/tcp").
WithStatusCodeMatcher(func(s int) bool { return s == 200 }).
WithStartupTimeout(3 * time.Minute),
},
Started: true,
})
if err != nil {
t.Fatalf("start kuma: %v", err)
}
t.Cleanup(func() { _ = ctr.Terminate(context.Background()) })
url, err := ctr.PortEndpoint(ctx, "3001/tcp", "http")
if err != nil {
t.Fatal(err)
}
return ctr, url
}
func startKuma(t *testing.T) string {
t.Helper()
_, url := startKumaCtr(t)
return url
}
// startProxy forwards a stable local address to the container's current
// mapped port, which can change when the container restarts.
func startProxy(t *testing.T, ctr testcontainers.Container) string {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = ln.Close() })
go func() {
for {
down, err := ln.Accept()
if err != nil {
return
}
go func() {
target, err := ctr.PortEndpoint(context.Background(), "3001/tcp", "")
if err != nil {
_ = down.Close()
return
}
up, err := net.Dial("tcp", target)
if err != nil {
_ = down.Close()
return
}
go func() { _, _ = io.Copy(up, down); _ = up.Close() }()
_, _ = io.Copy(down, up)
_ = down.Close()
}()
}
}()
return "http://" + ln.Addr().String()
}
// setupClient creates the admin account on first connect.
func setupClient(t *testing.T, url string) *Breml {
t.Helper()
b, err := newBreml(t.Context(), url, testUser, testPass, kuma.WithAutosetup(), kuma.WithConnectTimeout(2*time.Minute))
if err != nil {
t.Fatalf("setup client: %v", err)
}
t.Cleanup(func() { _ = b.Close() })
return b
}
// freshClient logs in again, as a restarted controller would.
func freshClient(t *testing.T, url string) *Breml {
t.Helper()
b, err := newBreml(t.Context(), url, testUser, testPass, kuma.WithConnectTimeout(time.Minute))
if err != nil {
t.Fatalf("fresh client: %v", err)
}
t.Cleanup(func() { _ = b.Close() })
return b
}
func byID(list []Monitor) map[int64]Monitor {
out := map[int64]Monitor{}
for _, m := range list {
out[m.ID] = m
}
return out
}
func TestCreateFieldsIntact(t *testing.T) {
ctx := context.Background()
url := startKuma(t)
b := setupClient(t, url)
group := Monitor{
Type: "group", Name: "grp", Description: "a group", Active: true,
Interval: 60, RetryInterval: 60, MaxRetries: 0, OwnerKey: "default/grp",
}
gid, err := b.Create(ctx, group)
if err != nil {
t.Fatal(err)
}
group.ID = gid
httpFields := func(m Monitor) Monitor {
m.Active = true
m.Interval, m.RetryInterval, m.MaxRetries, m.Timeout = 120, 30, 2, 25
m.URL = "https://example.com/health"
m.Method = "POST"
m.Body = `{"a":1}`
m.Headers = map[string]string{"X-One": "1", "X-Two": "2"}
m.AcceptedStatusCodes = []string{"200-299", "301"}
m.MaxRedirects = 5
m.IgnoreTLS = true
m.ExpiryNotification = true
m.Parent = &gid
return m
}
keyword := httpFields(Monitor{Type: "keyword", Name: "kw", Description: "kw d", OwnerKey: "default/kw"})
keyword.Keyword = "healthy"
keyword.InvertKeyword = true
jq := httpFields(Monitor{Type: "json-query", Name: "jq", OwnerKey: "default/jq"})
jq.JSONPath = "status"
jq.JSONPathOperator = "contains"
jq.ExpectedValue = "ok"
dns := Monitor{
Type: "dns", Name: "dns", Description: "dns d", Active: true,
Interval: 90, RetryInterval: 45, MaxRetries: 1, Parent: &gid, OwnerKey: "default/dns",
Hostname: "example.com", DNSResolveType: "AAAA", DNSResolveServer: "1.1.1.1", Port: 53,
}
port := Monitor{
Type: "port", Name: "port", Active: false,
Interval: 60, RetryInterval: 60, MaxRetries: 3, OwnerKey: "default/port",
Hostname: "example.com", Port: 443,
}
want := map[int64]Monitor{gid: group.Normalized()}
for _, m := range []*Monitor{&keyword, &jq, &dns, &port} {
id, err := b.Create(ctx, *m)
if err != nil {
t.Fatalf("create %s: %v", m.Type, err)
}
m.ID = id
want[id] = m.Normalized()
}
got, err := freshClient(t, url).ListOwned(ctx)
if err != nil {
t.Fatal(err)
}
gotByID := byID(got)
if len(gotByID) != len(want) {
t.Fatalf("got %d monitors, want %d: %+v", len(gotByID), len(want), got)
}
for id, w := range want {
if !reflect.DeepEqual(gotByID[id], w) {
t.Errorf("%s mismatch:\n got %+v\n want %+v", w.Type, gotByID[id], w)
}
}
}
func TestDefaultTimeoutReadsBack(t *testing.T) {
ctx := context.Background()
url := startKuma(t)
b := setupClient(t, url)
kw := Monitor{
Type: "keyword", Name: "kw", Active: true, Interval: 60, RetryInterval: 60,
URL: "https://example.com", Keyword: "ok", OwnerKey: "default/kw",
}
id, err := b.Create(ctx, kw)
if err != nil {
t.Fatal(err)
}
got, err := freshClient(t, url).ListOwned(ctx)
if err != nil {
t.Fatal(err)
}
m := byID(got)[id]
if m.Timeout != 48 || m.Method != "GET" || !reflect.DeepEqual(m.AcceptedStatusCodes, []string{"200-299"}) {
t.Errorf("defaults not applied on read-back: %+v", m)
}
kw.ID = id
if !reflect.DeepEqual(m, kw.Normalized()) {
t.Errorf("read-back differs from Normalized():\n got %+v\n want %+v", m, kw.Normalized())
}
}
func TestUpdateKeepsUnchangedFields(t *testing.T) {
ctx := context.Background()
url := startKuma(t)
b := setupClient(t, url)
gid, err := b.Create(ctx, Monitor{Type: "group", Name: "grp", Active: true, Interval: 60, RetryInterval: 60, OwnerKey: "default/grp"})
if err != nil {
t.Fatal(err)
}
orig := Monitor{
Type: "keyword", Name: "kw", Active: true, Parent: &gid,
Interval: 60, RetryInterval: 60, MaxRetries: 1, Timeout: 30,
URL: "https://example.com", Method: "GET",
AcceptedStatusCodes: []string{"200-299", "404"},
Keyword: "up", ExpiryNotification: true, OwnerKey: "default/kw",
}
id, err := b.Create(ctx, orig)
if err != nil {
t.Fatal(err)
}
orig.ID = id
updated := orig
updated.Interval = 120
if err := b.Update(ctx, updated); err != nil {
t.Fatal(err)
}
got, err := freshClient(t, url).ListOwned(ctx)
if err != nil {
t.Fatal(err)
}
m, ok := byID(got)[id]
if !ok {
t.Fatalf("monitor %d missing after update", id)
}
if m.Interval != 120 {
t.Errorf("interval = %d, want 120", m.Interval)
}
if m.Parent == nil || *m.Parent != gid {
t.Errorf("parent = %v, want %d", m.Parent, gid)
}
if !reflect.DeepEqual(m.AcceptedStatusCodes, orig.AcceptedStatusCodes) {
t.Errorf("acceptedStatusCodes = %v", m.AcceptedStatusCodes)
}
if m.Keyword != "up" {
t.Errorf("keyword = %q", m.Keyword)
}
if !m.ExpiryNotification {
t.Error("expiryNotification lost")
}
if m.OwnerKey != "default/kw" {
t.Errorf("ownerKey = %q", m.OwnerKey)
}
updated.Active = false
if err := b.Update(ctx, updated); err != nil {
t.Fatal(err)
}
got, err = freshClient(t, url).ListOwned(ctx)
if err != nil {
t.Fatal(err)
}
if byID(got)[id].Active {
t.Error("monitor still active after pausing update")
}
}
// setUnmanaged sets state through the raw library that Monitor does not model.
func setUnmanaged(t *testing.T, b *Breml, id int64) (notificationID int64) {
t.Helper()
ctx := context.Background()
nid, err := b.c.CreateNotification(ctx, &notification.Webhook{
Base: notification.Base{Name: "n", IsActive: true},
WebhookDetails: notification.WebhookDetails{
WebhookURL: "http://example.com/hook", WebhookContentType: "application/json",
},
})
if err != nil {
t.Fatalf("create notification: %v", err)
}
var kw monitor.HTTPKeyword
if err := b.c.GetMonitorAs(ctx, id, &kw); err != nil {
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)
}
return nid
}
func assertUnmanaged(t *testing.T, c *Breml, id, nid int64, when string) {
t.Helper()
var base monitor.Base
base, err := c.c.GetMonitor(t.Context(), id)
if err != nil {
t.Fatal(err)
}
if !base.UpsideDown || base.ResendInterval != 7 || !reflect.DeepEqual(base.NotificationIDs, []int64{nid}) {
t.Errorf("%s: unmanaged state lost: upsideDown=%v resend=%d notifications=%v",
when, base.UpsideDown, base.ResendInterval, base.NotificationIDs)
}
}
func TestUpdatePreservesUnmanagedAndClearsManaged(t *testing.T) {
ctx := context.Background()
url := startKuma(t)
b := setupClient(t, url)
gid, err := b.Create(ctx, Monitor{Type: "group", Name: "grp", Active: true, Interval: 60, RetryInterval: 60, OwnerKey: "default/grp"})
if err != nil {
t.Fatal(err)
}
orig := Monitor{
Type: "keyword", Name: "kw", Active: true, Parent: &gid,
Interval: 60, RetryInterval: 60, URL: "https://example.com",
Headers: map[string]string{"A": "1"}, Keyword: "up", OwnerKey: "default/kw",
}
id, err := b.Create(ctx, orig)
if err != nil {
t.Fatal(err)
}
nid := setUnmanaged(t, b, id)
// Clear managed fields.
cleared := orig
cleared.ID = id
cleared.Parent = nil
cleared.Headers = nil
cleared.Keyword = ""
cleared.Interval = 90
if err := b.Update(ctx, cleared); err != nil {
t.Fatal(err)
}
fresh := freshClient(t, url)
got, err := fresh.ListOwned(ctx)
if err != nil {
t.Fatal(err)
}
m := byID(got)[id]
if m.Parent != nil || m.Headers != nil || m.Keyword != "" || m.Interval != 90 {
t.Errorf("managed fields not cleared: %+v", m)
}
assertUnmanaged(t, fresh, id, nid, "after plain update")
// Type change must not wipe unmanaged state either.
changed := cleared
changed.Type = "http"
if err := b.Update(ctx, changed); err != nil {
t.Fatal(err)
}
got, err = fresh.ListOwned(ctx)
if err != nil {
t.Fatal(err)
}
if m := byID(got)[id]; m.Type != "http" {
t.Errorf("type = %q, want http", m.Type)
}
assertUnmanaged(t, fresh, id, nid, "after type change")
}
func TestRestartAdoptsByTag(t *testing.T) {
ctx := context.Background()
url := startKuma(t)
first := setupClient(t, url)
ids := map[int64]string{}
for _, key := range []string{"ns1/a", "ns2/b", "ns1/c"} {
id, err := first.Create(ctx, Monitor{
Type: "port", Name: key, Active: true, Interval: 60, RetryInterval: 60,
Hostname: "example.com", Port: 80, OwnerKey: key,
})
if err != nil {
t.Fatal(err)
}
ids[id] = key
}
if err := first.Close(); err != nil {
t.Fatal(err)
}
got, err := freshClient(t, url).ListOwned(ctx)
if err != nil {
t.Fatal(err)
}
gotKeys := map[int64]string{}
for _, m := range got {
gotKeys[m.ID] = m.OwnerKey
}
if !reflect.DeepEqual(gotKeys, ids) {
t.Fatalf("got %v, want %v", gotKeys, ids)
}
}
func TestUntaggedMonitorUntouched(t *testing.T) {
ctx := context.Background()
url := startKuma(t)
b := setupClient(t, url)
foreign := &monitor.TCPPort{}
foreign.Name = "foreign"
foreign.Interval, foreign.RetryInterval = 60, 60
foreign.IsActive = true
foreign.Hostname, foreign.Port = "example.com", 80
fid, err := b.c.CreateMonitor(ctx, foreign)
if err != nil {
t.Fatal(err)
}
mine, err := b.Create(ctx, Monitor{
Type: "port", Name: "mine", Active: true, Interval: 60, RetryInterval: 60,
Hostname: "example.com", Port: 80, OwnerKey: "default/mine",
})
if err != nil {
t.Fatal(err)
}
if err := b.Update(ctx, Monitor{ID: mine, Type: "port", Name: "x", Hostname: "example.com", Port: 80, OwnerKey: "default/other"}); !errors.Is(err, ErrNotOwned) {
t.Errorf("update with a different owner key: want ErrNotOwned, got %v", err)
}
if found, key, err := b.Probe(ctx, fid); err != nil || !found || key != "" {
t.Errorf("probe untagged: %v %q %v", found, key, err)
}
if found, key, err := b.Probe(ctx, mine); err != nil || !found || key != "default/mine" {
t.Errorf("probe owned: %v %q %v", found, key, err)
}
if found, _, err := b.Probe(ctx, 99999); err != nil || found {
t.Errorf("probe unknown: %v %v", found, err)
}
got, err := b.ListOwned(ctx)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0].Name != "mine" {
t.Fatalf("ListOwned = %+v", got)
}
if err := b.Update(ctx, Monitor{ID: fid, Type: "port", Name: "hijacked", Hostname: "example.com", Port: 80}); !errors.Is(err, ErrNotOwned) {
t.Errorf("update: want ErrNotOwned, got %v", err)
}
if err := b.Delete(ctx, fid); !errors.Is(err, ErrNotOwned) {
t.Errorf("delete: want ErrNotOwned, got %v", err)
}
if err := b.Delete(ctx, 99999); !errors.Is(err, ErrNotFound) {
t.Errorf("delete unknown: want ErrNotFound, got %v", err)
}
if err := b.Update(ctx, Monitor{ID: 99999, Type: "port"}); !errors.Is(err, ErrNotFound) {
t.Errorf("update unknown: want ErrNotFound, got %v", err)
}
still, err := b.c.GetMonitor(ctx, fid)
if err != nil {
t.Fatal(err)
}
if still.Name != "foreign" {
t.Errorf("foreign monitor changed: %q", still.Name)
}
}
func TestOwnerTagMissing(t *testing.T) {
ctx := context.Background()
url := startKuma(t)
b := setupClient(t, url)
if _, err := b.Create(ctx, Monitor{
Type: "port", Name: "mine", Active: true, Interval: 60, RetryInterval: 60,
Hostname: "example.com", Port: 80, OwnerKey: "default/mine",
}); err != nil {
t.Fatal(err)
}
if err := b.c.DeleteTag(ctx, b.tagID); err != nil {
t.Fatal(err)
}
got, err := b.ListOwned(ctx)
if !errors.Is(err, ErrOwnerTagMissing) || got != nil {
t.Fatalf("got %+v, %v; want nil list and ErrOwnerTagMissing", got, err)
}
}
func TestOwnerConflictExcluded(t *testing.T) {
ctx := context.Background()
url := startKuma(t)
b := setupClient(t, url)
create := func(key string) int64 {
id, err := b.Create(ctx, Monitor{
Type: "port", Name: key, Active: true, Interval: 60, RetryInterval: 60,
Hostname: "example.com", Port: 80, OwnerKey: key,
})
if err != nil {
t.Fatal(err)
}
return id
}
good, bad := create("default/good"), create("default/bad")
if _, err := b.c.AddMonitorTag(ctx, b.tagID, bad, "default/other"); err != nil {
t.Fatal(err)
}
got, err := b.ListOwned(ctx)
if !errors.Is(err, ErrOwnerConflict) {
t.Fatalf("want ErrOwnerConflict, got %v", err)
}
if len(got) != 1 || got[0].ID != good {
t.Fatalf("conflicting monitor must be excluded: %+v", got)
}
if err := b.Delete(ctx, bad); !errors.Is(err, ErrOwnerConflict) {
t.Errorf("delete of conflicting monitor: want ErrOwnerConflict, got %v", err)
}
if err := b.Update(ctx, Monitor{ID: bad, Type: "port", Name: "x", Hostname: "example.com", Port: 80}); !errors.Is(err, ErrOwnerConflict) {
t.Errorf("update of conflicting monitor: want ErrOwnerConflict, got %v", err)
}
if _, _, err := b.Probe(ctx, bad); !errors.Is(err, ErrOwnerConflict) {
t.Errorf("probe of conflicting monitor: want ErrOwnerConflict, got %v", err)
}
}
func TestReconnectAfterKumaRestart(t *testing.T) {
ctr, _ := startKumaCtr(t)
url := startProxy(t, ctr)
b := setupClient(t, url)
id, err := b.Create(t.Context(), Monitor{
Type: "port", Name: "survivor", Active: true, Interval: 60, RetryInterval: 60,
Hostname: "example.com", Port: 80, OwnerKey: "default/survivor",
})
if err != nil {
t.Fatal(err)
}
stop := 10 * time.Second
if err := ctr.Stop(t.Context(), &stop); err != nil {
t.Fatal(err)
}
if err := ctr.Start(t.Context()); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(t.Context(), 60*time.Second)
defer cancel()
got, err := b.ListOwned(ctx)
if err != nil {
t.Fatalf("ListOwned after restart: %v", err)
}
if len(got) != 1 || got[0].ID != id {
t.Fatalf("ListOwned = %+v, want monitor %d", got, id)
}
if _, err := b.Create(ctx, Monitor{
Type: "port", Name: "after", Active: true, Interval: 60, RetryInterval: 60,
Hostname: "example.com", Port: 80, OwnerKey: "default/after",
}); err != nil {
t.Fatalf("Create after restart: %v", err)
}
}
@@ -0,0 +1,173 @@
// Package kumaclient is the boundary to Uptime Kuma.
package kumaclient
import (
"context"
"errors"
"fmt"
"maps"
"math"
"slices"
)
var (
ErrNotFound = errors.New("monitor not found")
ErrNotOwned = errors.New("monitor not owned by kuma-sync")
// ErrOwnerTagMissing means the kuma-sync tag no longer exists in Kuma.
ErrOwnerTagMissing = errors.New("kuma-sync tag missing in Kuma")
// ErrOwnerConflict means a monitor carries more than one kuma-sync tag value.
ErrOwnerConflict = errors.New("monitor carries conflicting kuma-sync tag values")
// ErrPossibleOrphan means Create may have left an untagged monitor in Kuma.
ErrPossibleOrphan = errors.New("possible untagged orphan monitor left in Kuma")
)
// ConflictError reports a monitor carrying several different kuma-sync tag
// values. Keys lists all of them, sorted. It matches ErrOwnerConflict.
type ConflictError struct {
MonitorID int64
Keys []string
}
func (e *ConflictError) Error() string {
return fmt.Sprintf("monitor %d carries %d owner keys %q", e.MonitorID, len(e.Keys), e.Keys)
}
func (e *ConflictError) Unwrap() error { return ErrOwnerConflict }
// Conflicts returns every ConflictError inside err, and whether err consists
// of nothing but conflicts. ListOwned joins one per conflicting monitor.
func Conflicts(err error) (conflicts []*ConflictError, only bool) {
only = err != nil
var walk func(error)
walk = func(e error) {
switch x := e.(type) {
case nil:
case *ConflictError:
conflicts = append(conflicts, x)
case interface{ Unwrap() []error }:
for _, s := range x.Unwrap() {
walk(s)
}
default:
if u, ok := e.(interface{ Unwrap() error }); ok && u.Unwrap() != nil {
walk(u.Unwrap())
return
}
only = false
}
}
walk(err)
return conflicts, only && len(conflicts) > 0
}
func (m Monitor) clone() Monitor {
m.Headers = maps.Clone(m.Headers)
m.AcceptedStatusCodes = slices.Clone(m.AcceptedStatusCodes)
if m.Parent != nil {
p := *m.Parent
m.Parent = &p
}
return m
}
func isHTTPFamily(typ string) bool {
return typ == "http" || typ == "keyword" || typ == "json-query"
}
// Normalized returns m with the defaults the real client applies on write, so
// a desired monitor can be compared with an observed one.
//
// For http, keyword and json-query: an empty Method becomes GET, empty
// AcceptedStatusCodes become ["200-299"], and Timeout 0 becomes
// round(0.8*Interval). For all other types Timeout is stored as 0 and the
// HTTP-only slices are nil. Empty Headers become nil. It is idempotent.
func (m Monitor) Normalized() Monitor {
m = m.clone()
if len(m.Headers) == 0 {
m.Headers = nil
}
if len(m.AcceptedStatusCodes) == 0 {
m.AcceptedStatusCodes = nil
}
if !isHTTPFamily(m.Type) {
m.Timeout = 0
return m
}
if m.Method == "" {
m.Method = "GET"
}
if m.AcceptedStatusCodes == nil {
m.AcceptedStatusCodes = []string{"200-299"}
}
if m.Timeout == 0 {
m.Timeout = int32(math.Round(0.8 * float64(m.Interval)))
}
return m
}
// Monitor is the domain view of a Kuma monitor.
type Monitor struct {
ID int64
Type string
Name string
Description string
Parent *int64
Active bool
Interval int32
RetryInterval int32
MaxRetries 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.
Timeout int32
URL string
Method string
Body string
Headers map[string]string
AcceptedStatusCodes []string
MaxRedirects int32
IgnoreTLS bool
ExpiryNotification bool
Keyword string
InvertKeyword bool
JSONPath string
JSONPathOperator string
ExpectedValue string
Hostname string
Port int32
DNSResolveType string
DNSResolveServer string
// OwnerKey is the value of the kuma-sync tag; "" means not ours.
OwnerKey string
}
// Client manages only monitors carrying the kuma-sync tag.
type Client interface {
// ListOwned returns monitors carrying the kuma-sync tag, normalized.
// It returns ErrOwnerTagMissing, with no list, if the tag was deleted in
// Kuma, so callers never mistake that for "no monitors". A monitor with
// conflicting tag values is left out and reported as a *ConflictError (all
// of them joined; see Conflicts) together with the rest of the list. Any
// error means the list is incomplete: callers must not conclude a monitor
// is absent from it.
ListOwned(ctx context.Context) ([]Monitor, error)
// Create creates the monitor and tags it with m.OwnerKey.
Create(ctx context.Context, m Monitor) (int64, error)
// Probe reports whether a monitor with this ID exists, owned or not, and
// the owner key it carries ("" when untagged). A monitor with conflicting
// tag values returns an error that wraps ErrOwnerConflict.
Probe(ctx context.Context, id int64) (found bool, ownerKey string, err error)
// Update overwrites an owned monitor. It wraps ErrNotFound for an unknown ID
// and ErrNotOwned for a monitor without the kuma-sync tag or whose tag
// value differs from m.OwnerKey.
Update(ctx context.Context, m Monitor) error
// 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
}
@@ -0,0 +1,164 @@
package kumaclient
import (
"context"
"fmt"
"sort"
"sync"
)
// Fake is an in-memory Client safe for concurrent use.
// ListErr, CreateErr, UpdateErr and DeleteErr, when set, are returned by the
// matching method; set them via the Set*Err methods while the fake is shared.
type Fake struct {
mu sync.Mutex
monitors map[int64]Monitor
nextID int64
ListErr error
CreateErr error
UpdateErr error
DeleteErr error
ProbeErr error
// ListPartial makes ListOwned return the stored monitors together with
// ListErr, as the real client does for owner conflicts.
ListPartial bool
}
// SetListPartial makes ListOwned return the stored monitors and err.
func (f *Fake) SetListPartial(err error) {
f.mu.Lock()
defer f.mu.Unlock()
f.ListErr, f.ListPartial = err, true
}
var _ Client = (*Fake)(nil)
func NewFake() *Fake {
return &Fake{monitors: map[int64]Monitor{}, nextID: 1}
}
// AddForeign adds an untagged monitor. A zero ID is assigned the next free one.
func (f *Fake) AddForeign(m Monitor) int64 {
f.mu.Lock()
defer f.mu.Unlock()
m.OwnerKey = ""
if m.ID == 0 {
m.ID = f.allocID()
} else if m.ID >= f.nextID {
f.nextID = m.ID + 1
}
f.monitors[m.ID] = m.clone()
return m.ID
}
// Get returns a copy of the monitor with the given ID, owned or not, or nil.
func (f *Fake) Get(id int64) *Monitor {
f.mu.Lock()
defer f.mu.Unlock()
m, ok := f.monitors[id]
if !ok {
return nil
}
c := m.clone()
return &c
}
// SetErrs sets all four injected errors under the lock.
func (f *Fake) SetErrs(list, create, update, del error) {
f.mu.Lock()
defer f.mu.Unlock()
f.ListErr, f.CreateErr, f.UpdateErr, f.DeleteErr = list, create, update, del
f.ListPartial = false
}
// SetProbeErr makes Probe return err.
func (f *Fake) SetProbeErr(err error) {
f.mu.Lock()
defer f.mu.Unlock()
f.ProbeErr = err
}
// Probe reports whether a monitor exists, owned or not, and its owner key.
func (f *Fake) Probe(_ context.Context, id int64) (bool, string, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.ProbeErr != nil {
return false, "", f.ProbeErr
}
m, ok := f.monitors[id]
return ok, m.OwnerKey, nil
}
func (f *Fake) allocID() int64 {
id := f.nextID
f.nextID++
return id
}
func (f *Fake) ListOwned(_ context.Context) ([]Monitor, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.ListErr != nil && !f.ListPartial {
return nil, f.ListErr
}
var out []Monitor
for _, m := range f.monitors {
if m.OwnerKey != "" {
out = append(out, m.clone())
}
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out, f.ListErr
}
func (f *Fake) Create(_ context.Context, m Monitor) (int64, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.CreateErr != nil {
return 0, f.CreateErr
}
if m.OwnerKey == "" {
return 0, fmt.Errorf("create: empty OwnerKey")
}
m.ID = f.allocID()
f.monitors[m.ID] = m.Normalized()
return m.ID, nil
}
func (f *Fake) Update(_ context.Context, m Monitor) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.UpdateErr != nil {
return f.UpdateErr
}
cur, ok := f.monitors[m.ID]
if !ok {
return fmt.Errorf("update %d: %w", m.ID, ErrNotFound)
}
if cur.OwnerKey == "" {
return fmt.Errorf("update %d: %w", m.ID, ErrNotOwned)
}
if m.OwnerKey != cur.OwnerKey {
return fmt.Errorf("update %d: owner key %q, not %q: %w", m.ID, cur.OwnerKey, m.OwnerKey, ErrNotOwned)
}
f.monitors[m.ID] = m.Normalized()
return nil
}
func (f *Fake) Delete(_ context.Context, id int64) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.DeleteErr != nil {
return f.DeleteErr
}
cur, ok := f.monitors[id]
if !ok {
return fmt.Errorf("delete %d: %w", id, ErrNotFound)
}
if cur.OwnerKey == "" {
return fmt.Errorf("delete %d: %w", id, ErrNotOwned)
}
delete(f.monitors, id)
return nil
}
@@ -0,0 +1,223 @@
package kumaclient
import (
"context"
"errors"
"testing"
)
func TestFakeCreateAssignsIDAndTag(t *testing.T) {
f := NewFake()
id, err := f.Create(context.Background(), Monitor{Name: "a", OwnerKey: "ns/a"})
if err != nil {
t.Fatal(err)
}
if id == 0 {
t.Fatal("expected non-zero id")
}
got, err := f.ListOwned(context.Background())
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0].ID != id || got[0].OwnerKey != "ns/a" {
t.Fatalf("unexpected list: %+v", got)
}
id2, err := f.Create(context.Background(), Monitor{Name: "b", OwnerKey: "ns/b"})
if err != nil {
t.Fatal(err)
}
if id2 == id {
t.Fatal("ids must be unique")
}
}
func TestFakeCreateRequiresOwnerKey(t *testing.T) {
f := NewFake()
if _, err := f.Create(context.Background(), Monitor{Name: "a"}); err == nil {
t.Fatal("expected error for empty OwnerKey")
}
}
func TestFakeListOwnedHidesForeign(t *testing.T) {
f := NewFake()
f.AddForeign(Monitor{ID: 99, Name: "foreign"})
if _, err := f.Create(context.Background(), Monitor{Name: "mine", OwnerKey: "ns/m"}); err != nil {
t.Fatal(err)
}
got, err := f.ListOwned(context.Background())
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0].Name != "mine" {
t.Fatalf("unexpected list: %+v", got)
}
}
func TestFakeUpdateRejectsForeign(t *testing.T) {
f := NewFake()
f.AddForeign(Monitor{ID: 99, Name: "foreign"})
if err := f.Update(context.Background(), Monitor{ID: 99, Name: "changed", OwnerKey: "ns/x"}); !errors.Is(err, ErrNotOwned) {
t.Fatalf("expected ErrNotOwned, got %v", err)
}
if got := f.Get(99); got == nil || got.Name != "foreign" {
t.Fatalf("foreign monitor modified: %+v", got)
}
}
func TestFakeUpdateOverwritesOwned(t *testing.T) {
f := NewFake()
id, _ := f.Create(context.Background(), Monitor{Name: "a", URL: "u", OwnerKey: "ns/a"})
if err := f.Update(context.Background(), Monitor{ID: id, Name: "b", OwnerKey: "ns/a"}); err != nil {
t.Fatal(err)
}
got := f.Get(id)
if got.Name != "b" || got.URL != "" {
t.Fatalf("not overwritten: %+v", got)
}
}
func TestFakeDeleteRejectsForeign(t *testing.T) {
f := NewFake()
f.AddForeign(Monitor{ID: 99, Name: "foreign"})
if err := f.Delete(context.Background(), 99); !errors.Is(err, ErrNotOwned) {
t.Fatalf("expected ErrNotOwned, got %v", err)
}
if f.Get(99) == nil {
t.Fatal("foreign monitor deleted")
}
}
func TestFakeDeleteOwned(t *testing.T) {
f := NewFake()
id, _ := f.Create(context.Background(), Monitor{Name: "a", OwnerKey: "ns/a"})
if err := f.Delete(context.Background(), id); err != nil {
t.Fatal(err)
}
if f.Get(id) != nil {
t.Fatal("not deleted")
}
}
func TestFakeInjectedErrors(t *testing.T) {
boom := errors.New("boom")
f := NewFake()
id, _ := f.Create(context.Background(), Monitor{Name: "a", OwnerKey: "ns/a"})
f.ListErr, f.CreateErr, f.UpdateErr, f.DeleteErr = boom, boom, boom, boom
ctx := context.Background()
if _, err := f.ListOwned(ctx); !errors.Is(err, boom) {
t.Fatalf("list: %v", err)
}
if _, err := f.Create(ctx, Monitor{OwnerKey: "x"}); !errors.Is(err, boom) {
t.Fatalf("create: %v", err)
}
if err := f.Update(ctx, Monitor{ID: id, OwnerKey: "ns/a"}); !errors.Is(err, boom) {
t.Fatalf("update: %v", err)
}
if err := f.Delete(ctx, id); !errors.Is(err, boom) {
t.Fatalf("delete: %v", err)
}
}
func TestFakeUpdateRejectsOtherOwnerKey(t *testing.T) {
f := NewFake()
id, _ := f.Create(context.Background(), Monitor{Name: "a", OwnerKey: "ns/a"})
if err := f.Update(context.Background(), Monitor{ID: id, Name: "b", OwnerKey: "ns/other"}); !errors.Is(err, ErrNotOwned) {
t.Fatalf("want ErrNotOwned, got %v", err)
}
if got := f.Get(id); got.Name != "a" {
t.Errorf("monitor changed: %+v", got)
}
}
func TestFakeProbe(t *testing.T) {
f := NewFake()
owned, _ := f.Create(context.Background(), Monitor{Name: "a", OwnerKey: "ns/a"})
foreign := f.AddForeign(Monitor{Name: "x"})
if found, key, err := f.Probe(context.Background(), owned); err != nil || !found || key != "ns/a" {
t.Errorf("owned: %v %q %v", found, key, err)
}
if found, key, err := f.Probe(context.Background(), foreign); err != nil || !found || key != "" {
t.Errorf("foreign: %v %q %v", found, key, err)
}
if found, _, err := f.Probe(context.Background(), 999); err != nil || found {
t.Errorf("missing: %v %v", found, err)
}
f.SetProbeErr(errors.New("boom"))
if _, _, err := f.Probe(context.Background(), owned); err == nil {
t.Error("expected injected error")
}
}
func TestFakeDeleteTwiceNotFound(t *testing.T) {
f := NewFake()
id, _ := f.Create(context.Background(), Monitor{Name: "a", OwnerKey: "ns/a"})
if err := f.Delete(context.Background(), id); err != nil {
t.Fatal(err)
}
if err := f.Delete(context.Background(), id); !errors.Is(err, ErrNotFound) {
t.Fatalf("expected ErrNotFound, got %v", err)
}
}
func TestFakeUpdateUnknownNotFound(t *testing.T) {
f := NewFake()
if err := f.Update(context.Background(), Monitor{ID: 42, OwnerKey: "ns/a"}); !errors.Is(err, ErrNotFound) {
t.Fatalf("expected ErrNotFound, got %v", err)
}
}
func TestFakeDoesNotAlias(t *testing.T) {
ctx := context.Background()
f := NewFake()
parent := int64(7)
in := Monitor{
Name: "a", OwnerKey: "ns/a", Parent: &parent,
Headers: map[string]string{"k": "v"},
AcceptedStatusCodes: []string{"200-299"},
}
id, _ := f.Create(ctx, in)
check := func(m Monitor, where string) {
t.Helper()
if m.Headers["k"] != "v" || m.AcceptedStatusCodes[0] != "200-299" || *m.Parent != 7 {
t.Fatalf("%s: state aliased: %+v parent=%d", where, m, *m.Parent)
}
}
// Mutating the input after Create must not affect stored state.
in.Headers["k"] = "changed"
in.AcceptedStatusCodes[0] = "changed"
parent = 8
check(*f.Get(id), "after create mutation")
// Mutating returned values must not affect stored state.
got := f.Get(id)
got.Headers["k"] = "changed"
got.AcceptedStatusCodes[0] = "changed"
*got.Parent = 9
listed, _ := f.ListOwned(ctx)
listed[0].Headers["k"] = "changed"
listed[0].AcceptedStatusCodes[0] = "changed"
*listed[0].Parent = 9
check(*f.Get(id), "after return mutation")
// Update copies too.
up := Monitor{ID: id, OwnerKey: "ns/a", Parent: new(int64), Headers: map[string]string{"k": "v"}, AcceptedStatusCodes: []string{"200-299"}}
*up.Parent = 7
if err := f.Update(ctx, up); err != nil {
t.Fatal(err)
}
up.Headers["k"] = "changed"
up.AcceptedStatusCodes[0] = "changed"
*up.Parent = 10
check(*f.Get(id), "after update mutation")
// AddForeign copies too.
fm := Monitor{Parent: new(int64), Headers: map[string]string{"k": "v"}, AcceptedStatusCodes: []string{"200-299"}}
*fm.Parent = 7
fid := f.AddForeign(fm)
fm.Headers["k"] = "changed"
fm.AcceptedStatusCodes[0] = "changed"
*fm.Parent = 10
check(*f.Get(fid), "after foreign mutation")
}
@@ -0,0 +1,169 @@
package kumaclient
import (
"errors"
"fmt"
"reflect"
"testing"
kuma "github.com/breml/go-uptime-kuma-client"
"github.com/breml/go-uptime-kuma-client/tag"
)
func TestPickOwnerTagLowestID(t *testing.T) {
tags := []tag.Tag{
{ID: 9, Name: "kuma-sync"},
{ID: 3, Name: "other"},
{ID: 4, Name: "kuma-sync"},
{ID: 7, Name: "kuma-sync"},
}
id, ok := pickOwnerTag(tags)
if !ok || id != 4 {
t.Fatalf("got %d %v, want 4 true", id, ok)
}
if _, ok := pickOwnerTag([]tag.Tag{{ID: 1, Name: "other"}}); ok {
t.Fatal("expected no match")
}
}
func TestOwnerKeyOf(t *testing.T) {
mt := func(tagID int64, v string) tag.MonitorTag { return tag.MonitorTag{TagID: tagID, Value: v} }
cases := []struct {
name string
tags []tag.MonitorTag
want string
conflict bool
}{
{"none", nil, "", false},
{"other tag only", []tag.MonitorTag{mt(2, "x")}, "", false},
{"single", []tag.MonitorTag{mt(1, "ns/a"), mt(2, "x")}, "ns/a", false},
{"same value twice", []tag.MonitorTag{mt(1, "ns/a"), mt(1, "ns/a")}, "ns/a", false},
{"conflict", []tag.MonitorTag{mt(1, "ns/a"), mt(1, "ns/b")}, "", true},
}
for _, c := range cases {
got, err := ownerKeyOf(5, c.tags, 1)
if c.conflict != errors.Is(err, ErrOwnerConflict) {
t.Errorf("%s: err = %v, want conflict=%v", c.name, err, c.conflict)
}
if got != c.want {
t.Errorf("%s: key = %q, want %q", c.name, got, c.want)
}
}
}
func TestConflictErrorListsAllKeys(t *testing.T) {
mt := func(v string) tag.MonitorTag { return tag.MonitorTag{TagID: 1, Value: v} }
_, err := ownerKeyOf(7, []tag.MonitorTag{mt("default/x"), mt("default/y"), mt("default/a"), mt("default/x")}, 1)
var ce *ConflictError
if !errors.As(err, &ce) || !errors.Is(err, ErrOwnerConflict) {
t.Fatalf("err = %v", err)
}
if ce.MonitorID != 7 || !reflect.DeepEqual(ce.Keys, []string{"default/a", "default/x", "default/y"}) {
t.Errorf("conflict = %+v", ce)
}
}
func TestConflicts(t *testing.T) {
a := &ConflictError{MonitorID: 1, Keys: []string{"a", "b"}}
b := &ConflictError{MonitorID: 2, Keys: []string{"c", "d", "e"}}
got, only := Conflicts(errors.Join(a, fmt.Errorf("wrapped: %w", b)))
if !only || len(got) != 2 || got[0] != a || got[1] != b {
t.Errorf("got %v only=%v", got, only)
}
if _, only := Conflicts(errors.Join(a, errors.New("other"))); only {
t.Error("mixed error must not be conflict-only")
}
if got, only := Conflicts(errors.New("plain")); len(got) != 0 || only {
t.Errorf("plain: %v %v", got, only)
}
if _, only := Conflicts(nil); only {
t.Error("nil is not conflict-only")
}
}
func TestCreatedDespite(t *testing.T) {
timeout := fmt.Errorf("create monitor: %w", kuma.ErrUpdateEventTimeout)
if !createdDespite(5, nil) {
t.Error("success must proceed")
}
if !createdDespite(5, timeout) {
t.Error("update-event timeout with an ID must proceed to tagging")
}
if createdDespite(0, timeout) {
t.Error("no ID must not proceed")
}
if createdDespite(0, errors.New("boom")) || createdDespite(5, errors.New("boom")) {
t.Error("other errors must not proceed")
}
}
func TestFlagUnknownCreate(t *testing.T) {
run := func(id int64, err error, dead bool) (int64, error, int) {
calls := 0
gotID, gotErr := flagUnknownCreate(
func() (int64, error) { calls++; return id, err },
func(error) bool { return dead },
)
return gotID, gotErr, calls
}
transport := errors.New("use of closed network connection")
if id, err, calls := run(0, transport, true); !errors.Is(err, ErrPossibleOrphan) || !errors.Is(err, transport) || id != 0 || calls != 1 {
t.Errorf("transport failure: id=%d err=%v calls=%d; want ErrPossibleOrphan and exactly one create", id, err, calls)
}
timeout := fmt.Errorf("add: %w", kuma.ErrOperationTimeout)
if _, err, calls := run(0, timeout, false); !errors.Is(err, ErrPossibleOrphan) || calls != 1 {
t.Errorf("operation timeout: err=%v calls=%d; want ErrPossibleOrphan and exactly one create", err, calls)
}
app := errors.New("add: validation failed")
if _, err, _ := run(0, app, false); errors.Is(err, ErrPossibleOrphan) || !errors.Is(err, app) {
t.Errorf("application error must pass through unflagged: %v", err)
}
if id, err, _ := run(5, nil, false); err != nil || id != 5 {
t.Errorf("success: id=%d err=%v", id, err)
}
already := fmt.Errorf("x: %w", ErrPossibleOrphan)
if _, err, _ := run(0, already, true); !errors.Is(err, ErrPossibleOrphan) {
t.Errorf("existing orphan error lost: %v", err)
}
}
func TestNormalized(t *testing.T) {
in := Monitor{Type: "keyword", Interval: 60, Headers: map[string]string{}}
got := in.Normalized()
if got.Method != "GET" || !reflect.DeepEqual(got.AcceptedStatusCodes, []string{"200-299"}) {
t.Errorf("defaults not applied: %+v", got)
}
if got.Timeout != 48 {
t.Errorf("timeout = %d, want 48", got.Timeout)
}
if got.Headers != nil {
t.Errorf("empty headers must be nil: %v", got.Headers)
}
if !reflect.DeepEqual(got.Normalized(), got) {
t.Error("Normalized is not idempotent")
}
explicit := Monitor{Type: "http", Interval: 60, Timeout: 10, Method: "POST", AcceptedStatusCodes: []string{"301"}}
if e := explicit.Normalized(); e.Timeout != 10 || e.Method != "POST" || e.AcceptedStatusCodes[0] != "301" {
t.Errorf("explicit values changed: %+v", e)
}
dns := Monitor{Type: "dns", Interval: 60, Timeout: 30}.Normalized()
if dns.Timeout != 0 || dns.Method != "" || dns.AcceptedStatusCodes != nil {
t.Errorf("non-http type must not gain http defaults: %+v", dns)
}
if !reflect.DeepEqual(dns.Normalized(), dns) {
t.Error("Normalized is not idempotent for dns")
}
}
func TestFakeStoresNormalized(t *testing.T) {
f := NewFake()
id, err := f.Create(t.Context(), Monitor{Type: "http", Interval: 100, OwnerKey: "a"})
if err != nil {
t.Fatal(err)
}
got := f.Get(id)
if got.Method != "GET" || got.Timeout != 80 {
t.Fatalf("fake did not normalize: %+v", got)
}
}
+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.14-slim@sha256:a2b82f3c48559aa0a8446d9af49826b6e2b2016f4cd2afabfe6013ec53729170
FROM python:3.15-slim@sha256:2da676046630d7bf14c6764ae1143dc7fa0377a53f94bf5a642f0b214aca5bce
RUN useradd --system --no-create-home --shell /usr/sbin/nologin controller
COPY controller.py /opt/controller.py
@@ -0,0 +1,130 @@
# homelab → konairius/homelab, master → main — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Move the Gitea repo `k.renner/homelab` into the `konairius` org and rename its default branch `master` → `main`, with no lost Flux sync, pipeline, cron, secret or review.
**Architecture:** Two separate cut-overs, each with its own window, prep PR and rollback: **Cut-over 1** moves the repo (path changes, branch stays `master`); **Cut-over 2** renames the branch (path stays). Everything that can land beforehand lands beforehand; each cut-over's own PR is merged inside its window. The pattern is the one proven on vsftpd, notes, renovate-config, authentik-samba-bridge and homeassistant on 2026-10-18.
**Tech Stack:** Gitea 1.27 (API, transfer, `repoRenameBranch`), Woodpecker v3.19.0 (move API, crons, org secrets), Flux (GitRepository `flux-system`, ImageUpdateAutomation `homelab`), secret-rotator, OpenBao.
**Spec:** this conversation's decisions, recorded in the wiki raw notes of 2026-10-18 (konairius migration series). No separate spec doc.
## Global Constraints
- All repos live in `konairius` (exception: `k.renner/turm1388` stays).
- The default branch of every repo is `main`.
- Gitea old paths redirect (301) for reads; **Gitea API writes through an old path fail** (wiki on `k.renner/notes`, 2026-10-18). Every writer must address the new path right after the transfer.
- A transferred repo keeps Woodpecker's old org link until `POST /api/repos/{id}/move?to=<owner>/<repo>` (homelab: Woodpecker repo id `1`). Org secrets follow the org link.
- Gitea's `RenameBranch` renames, in one transaction: the branch, the default branch, a protection rule named exactly `master`, and the base/head of unmerged PRs (`models/git/branch.go`). It does **not** keep a `master` ref; anything fetching `master` fails afterwards.
- Renaming the default branch needs repo admin (owner or site admin), not `claude-bot`.
- Gitea's transfer removes collaborators who are **members** of the new org (their access then comes only from their konairius team) and keeps non-members (`services/repository/transfer.go`).
- `woodpecker repo repair` copies the forge's default branch into Woodpecker (`repairRepo` → `Repo.Update` sets `Branch`).
- Checked live 2026-10-18: Woodpecker repo id `1`; cron ids `alert-monitor` 4, `ci-claude-refresh` 7, `daily-security-scan` 2, `issue-fix` 5, `update-audit` 3, `weekly-update` 1, `wiki-maintenance` 8; the `k.renner` org holds only `claude_code_oauth_token` and `review_bot_token`, every other pipeline secret is a repo secret of homelab.
- Gitea API: `PATCH /repos/{owner}/{repo}/branches/{branch}` with `{"name": "<new>"}` (`repoRenameBranch`), present on our Gitea.
- Each service reads only its own OpenBao path; no cross-service key sharing.
- Never force-push; PRs for every repo change; never `2>/dev/null`.
## Review Focus
1. **Flux fetch across the transfer.** Flux's GitRepository fetches `k.renner/homelab.git` until the URL change lands. Documented behaviour says this works: source-controller v1.9.5 uses go-git v5.19.1 (its `go.mod`), whose `RedirectPolicy` "mirror[s] Git's http.followRedirects config" with the zero value `initial` (pkg.go.dev); git documents `initial` as following the redirect on the first request and using "the redirected URL as the base for the follow-up requests" (`git-config`, `http.followRedirects`). Gitea's 301 stays on the same host and scheme, so fetch and the IUA push both reach `konairius/homelab`. Fallback only, should that fail: Task 3 has a declared break-glass (suspend the `flux-system` Kustomization so it cannot re-apply the old URL, patch the GitRepository URL to the value the PR sets, resume once the source is Ready on the merge commit), checked by `flux get source git flux-system`.
6. **Write access lost in the transfer.** Any writer that is a konairius org member loses its collaborator grant and keeps only its team permission (`flux` pushes for image automation; claude-bot, issue-fix-bot, security-bot, review-bot, argus-bot, renovate-bot write PRs/reviews). Expect: Task 3 Step 0 snapshot of collaborators, konairius teams and the branch-protection whitelists, compared after Step 1.
2. **Image automation push between merge and rename (Cut-over 2).** The IUA pushes to `master`; after the Task 5 PR it expects `main`. Expect: IUA suspended for the whole window and resumed only after Flux reports `main`.
3. **Org secrets after the Woodpecker move.** Homelab pipelines use the org secrets `claude_code_oauth_token` and `review_bot_token`; after the move they resolve in `konairius`. Expect: both exist in `konairius` before Cut-over 1 (Task 1 verification).
4. **Crons on a vanished branch.** All 7 crons run on `master`. Expect: each switched to `main` in place (`woodpecker repo cron update --branch main`) inside the Cut-over 2 window, verified by `cron ls`.
5. **Rotations that verify against the old path.** flux-git (`GITEA_REQUIRE_PUSH=true`, verify repo `k.renner/homelab`) would fail every quarter. Expect: verify repo switched in Task 2 and a manual rotator run in Task 3.
---
### Task 1: Org secrets in konairius before the move
**Files:**
- Modify: `kubernetes/infrastructure/openbao/rotation/claude-oauth.yaml` (add a second container `sync-woodpecker-konairius`, same env with `WOODPECKER_ORG=konairius`)
- Modify: `kubernetes/infrastructure/openbao/rotation/review-bot.yaml` (same: a second sync container for `konairius`, `WOODPECKER_SECRET_NAME=review_bot_token`, events `cron,manual,pull_request`)
**Interfaces:** Produces the konairius org secrets `claude_code_oauth_token` and `review_bot_token` that Task 3 relies on. The k.renner containers stay until Task 4.
- [ ] **Step 1:** Add the containers. Both CronJobs reuse their existing OpenBao role (it only reads OpenBao and the woodpecker-bot bootstrap; no policy change).
- [ ] **Step 2:** `kubectl kustomize kubernetes/infrastructure/openbao/rotation` renders.
- [ ] **Step 3:** PR, merge, `flux reconcile kustomization infrastructure`.
- [ ] **Step 4:** Run both once: `kubectl create job -n openbao --from=cronjob/claude-oauth-secret-sync claude-oauth-konairius` and `--from=cronjob/review-bot-secret-rotate review-bot-konairius`. Expected logs: `updated|created woodpecker org secret konairius/<name>`. Note: the review-bot run also executes its init containers `rotate-mint` and `rotate-mint-mcp`, i.e. mints and reaps **both** review-bot PATs off schedule. Keeping the two newest makes one extra run safe; afterwards check `kubectl get externalsecret -n review-bot review-bot-secrets` is SecretSynced before anything else rotates.
- [ ] **Step 5:** `woodpecker org secret ls --organization konairius` lists `claude_code_oauth_token`, `review_bot_token`, `registry_bot_token`; `woodpecker org secret ls --organization k.renner` still lists only `claude_code_oauth_token` and `review_bot_token` (nothing else to carry over).
### Task 2: PR "follow homelab to konairius/homelab" (prepared, merged in Task 3)
**Files (all FUNCTIONAL `k.renner/homelab` references, 2026-10-18 inventory):**
- `kubernetes/clusters/homelab/flux-system/gotk-sync.yaml:14` — GitRepository url → `https://gitea.renner.uno/konairius/homelab.git`
- `kubernetes/infrastructure/openbao/rotation/flux-git.yaml:81` — `GITEA_VERIFY_REPO` → `konairius/homelab` (and the comment at :84)
- `kubernetes/apps/review-bot/deployment.yaml:38` — `REPOS` entry → `konairius/homelab`
- `.woodpecker/alert-monitor.yml:133,241`, `ci-claude-refresh.yml:177,179,200`, `issue-fix.yml:85,166,190,199`, `pr-review.yml:46`, `security-scan.yml:150,160`, `update-audit.yml:114` — API, clone and push URLs
- Header comments with `--repo k.renner/homelab` / `pipeline deploy k.renner/homelab` in the same files and `e2e.yml:10`
- `review-bot/go.mod:1` → `module gitea.renner.uno/konairius/homelab/review-bot` (no Go file imports it) and the test fixtures in `review-bot/*_test.go` (consistency only)
- `ci/images/heating-can/Containerfile:66` — OCI source label
- [ ] **Step 1:** Apply the replacements; leave `docs/`, `PROPOSAL*` and `MIGRATION*` history as is.
- [ ] **Step 2:** `git grep -n 'k.renner/homelab' -- ':!docs'` returns only historical files (`PROPOSAL*`, `MIGRATION*`).
- [ ] **Step 3:** `go test -C review-bot ./...` passes; `kubectl kustomize kubernetes/clusters/homelab` and the touched app directories render.
- [ ] **Step 4:** Open the PR titled "(merge inside Cut-over 1 only)"; do not merge.
### Task 3: Cut-over 1 — transfer (window)
**Preconditions:** Task 1 verified; Task 2 PR green; no deploy pipeline running (`woodpecker pipeline ls k.renner/homelab --limit 5`); record `flux get source git flux-system` revision.
- [ ] **Step 0 (owner, needs admin):** snapshot `tea api --login renner.uno /repos/k.renner/homelab/collaborators`, `tea api --login renner.uno /orgs/konairius/teams` plus each team's members, and the `master` rule's push/merge/approvals whitelists (`tea api --login renner.uno /repos/k.renner/homelab/branch_protections`). Any writer that is a konairius member must get write access through its team, or be re-added as collaborator right after Step 1.
- [ ] **Step 1 (owner):** `tea api --login renner.uno -X POST /repos/k.renner/homelab/transfer -f new_owner=konairius`. Expect `curl -sI https://gitea.renner.uno/k.renner/homelab` → 301.
- [ ] **Step 2 (claude-bot):** Woodpecker move: `POST $WOODPECKER_SERVER/api/repos/1/move?to=konairius/homelab` → 204; `woodpecker repo ls` shows `konairius/homelab`.
- [ ] **Step 3 (owner):** merge the Task 2 PR.
- [ ] **Step 1a:** repeat the Step 0 reads against `konairius/homelab` and compare; re-add any lost writer as collaborator before continuing.
- [ ] **Step 4:** `flux reconcile source git flux-system`. If it fails to fetch: break-glass — `flux suspend kustomization flux-system` (else it re-applies the last good artifact with the old URL), then `kubectl -n flux-system patch gitrepository flux-system --type merge -p '{"spec":{"url":"https://gitea.renner.uno/konairius/homelab.git"}}'` (the value the PR sets), wait for the source to be Ready on the Task 2 merge commit, then `flux resume kustomization flux-system`. Expect: revision = Task 2 merge commit, Ready.
- [ ] **Step 5:** `flux reconcile image update homelab` after the next ImagePolicy change, or check the last IUA push reaches `konairius/homelab` (commit by Flux Image Automation on master).
- [ ] **Step 6:** Run flux-git rotator once (`kubectl create job -n openbao --from=cronjob/flux-git-secret-rotate flux-git-move`); log shows `pull+push verified` against `konairius/homelab`.
- [ ] **Step 6a:** Expected side effects of the Task 2 merge, not failures: `build-ci-images` rebuilds the review-bot image (go.mod, tests) and heating-can (label), and image automation rolls out the new review-bot tag; `ci-claude-refresh` runs (its own file is in its path filter) and may open a ci-claude bump PR.
- [ ] **Step 7:** Verify writers: a PR on konairius/homelab gets a review-bot review; `woodpecker pipeline ls konairius/homelab` shows the merge's push pipeline green; an agent cron (alert-monitor, every 15 min) succeeds; `security-scan`/`issue-fix` open PRs against the new path at their next run.
- [ ] **Step 8:** Check the GitHub push mirror (`konairius/Homelab` on GitHub, restore-test source) still syncs (Gitea repo Settings → Mirror, last sync after the transfer).
**Rollback:** owner transfers back to `k.renner`, Woodpecker move back to `k.renner/homelab`, revert the Task 2 PR.
### Task 4: After Cut-over 1
- [ ] Remove the `k.renner` sync containers from Task 1 (nothing in the `k.renner` Woodpecker org uses them any more; turm1388 uses repo secrets only). PR, reconcile.
- [ ] Workstation: homelab clone remote → `https://gitea.renner.uno/konairius/homelab.git`; `~/source/CLAUDE.md:13` row → `konairius/homelab`.
- [ ] Wiki deposit of the observed Cut-over 1 results.
### Task 5: PR "default branch main" (prepared, merged in Task 6)
**Files (all FUNCTIONAL `master` references):**
- `.woodpecker/build-ci-images.yml:39`, `ci-claude-refresh.yml:71`, `deploy-claude-workstation.yml:4`, `deploy-edge-mqtt-bridge.yml:4`, `deploy-infrastructure.yml:7`, `deploy-netbird-client.yml:4`, `deploy-netbird-proxy.yml:4`, `deploy-netbird-server.yml:4`, `security-scan.yml:72` — `branch: main`
- `.woodpecker/ci-claude-refresh.yml:211`, `issue-fix.yml:202`, `security-scan.yml:155` — PR `base: "main"`
- `kubernetes/clusters/homelab/flux-system/gotk-sync.yaml:11` — `ref.branch: main`
- `kubernetes/infrastructure/image-automation/update-automation.yaml:26,36` — checkout and push branch `main`
- `kubernetes/restore-test/flux-root.yaml:16`, `kubernetes/vmtest-job.yaml:90` — `main`
- `kubernetes/bootstrap.sh:107` default `main`, `:1478-1479` guard on `main`, `:102` example
- **Not** the prompts: `review-bot/prompt.md` is baked into the review-bot image, which `build-ci-images` only rebuilds on a push to the branch its `when:` names. In this PR that filter flips to `main` while the PR merges into `master`, so the prompt edit would not ship. It moves to Task 6 Step 9.
- [ ] **Step 1:** Apply; `git grep -n -w master -- ':!docs' ':!ansible'` returns only historical/runbook text (handled in Task 7), the two prompts, and unrelated words (VRRP, Open3E, Multus `"master"`).
- [ ] **Step 2:** `bash -n kubernetes/bootstrap.sh`; `kubectl kustomize kubernetes/clusters/homelab` renders.
- [ ] **Step 3:** Open the PR titled "(merge inside Cut-over 2 only)"; do not merge.
### Task 6: Cut-over 2 — rename master → main (window)
**Preconditions:** Cut-over 1 done; Task 5 PR green; no deploy pipeline running.
- [ ] **Step 1:** `flux suspend image update homelab`.
- [ ] **Step 2 (owner):** merge the Task 5 PR into `master`. Flux now points at `main`, which does not exist yet; the cluster keeps its last applied state.
- [ ] **Step 3 (owner, immediately):** `tea api --login renner.uno -X PATCH /repos/konairius/homelab/branches/master -f name=main`. Expect: default branch `main`, protection rule now named `main` (`GET /repos/konairius/homelab/branches/main` → `protected: true, required_approvals: 1`), open PRs based on `main`.
- [ ] **Step 4:** `flux reconcile source git flux-system` → Ready on `main@<merge commit>`; `flux get kustomizations` all Ready.
- [ ] **Step 5:** Crons: for each of `alert-monitor`(4), `ci-claude-refresh`(7), `daily-security-scan`(2), `issue-fix`(5), `update-audit`(3), `weekly-update`(1): `woodpecker repo cron update --repository konairius/homelab --id <id> --branch main`. Delete `wiki-maintenance`(8): no pipeline filters on it (`git grep wiki-maintenance -- .woodpecker` is empty), it starts nothing. `cron ls` shows six crons, all on `main`.
- [ ] **Step 6:** `woodpecker repo repair konairius/homelab`; `woodpecker repo show` shows branch `main`.
- [ ] **Step 7:** `flux resume image update homelab`; next IUA commit lands on `main`.
- [ ] **Step 8:** Verify: a small PR into `main` gets reviewed and its merge triggers the push pipelines; the deploy pipelines' `when: branch: main` fire.
- [ ] **Step 9:** PR into `main` updating `review-bot/prompt.md:16` and `ci/agents/pr-review/mistral-prompt.md:16` (all repos use `main`). Its merge is a push to `main` touching `review-bot/**`, so `build-ci-images` rebuilds the review-bot image; image automation rolls it out; review-bot's log shows the Mistral agents re-synced (new prompt hash). This PR doubles as the Step 8 test PR.
- [ ] **Step 10 (owner, GitHub):** the push mirror `konairius/Homelab` on GitHub now carries `main`. GitHub refuses to delete a repo's default branch, so first set its default branch to `main` (repo Settings → Branches), then delete `master` there. `kubernetes/restore-test/flux-root.yaml` already points at `main` (Task 5).
**Rollback:** `PATCH .../branches/main -f name=master`, revert the Task 5 PR, crons back to `master` (re-add `wiki-maintenance` only if wanted), resume IUA.
### Task 7: Doc and memory sweep
- [ ] One PR replacing remaining `k.renner/homelab` and `master` in runbooks and READMEs: `kubernetes/RUNBOOK-cold-rebuild.md` (incl. `flux bootstrap --branch=master` at :282), `RUNBOOK-home-assistant-image-rollback.md`, `kubernetes/infrastructure/image-automation/README.md`, `README.md`, pipeline comments, `ci/agents/pr-review/run.sh`. Leave dated plans/specs and `MIGRATION*` history unchanged.
- [ ] Update the two memory notes that assume `master` (`feedback_image_bumps_via_flux_automation.md`, vican `verify-repo-freshness-before-review.md`).
- [ ] Local clones: `git branch -m master main && git fetch origin && git branch -u origin/main main` (owner's workstation).
- [ ] Wiki deposit of Cut-over 2 results.
@@ -0,0 +1,250 @@
# kuma-sync Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace AutoKuma with an in-house Go controller that reconciles typed `KumaMonitor` resources into Uptime Kuma 2.5.6, with ownership stored as a Kuma tag.
**Architecture:** A controller-runtime manager watches `KumaMonitor` (cluster-wide) and talks to Kuma through `breml/go-uptime-kuma-client` wrapped behind an internal `kumaclient.Client` interface. Each resource maps to one Kuma monitor, found by the tag `kuma-sync=<namespace>/<name>`. Finalizers and a periodic garbage collection keep Kuma free of orphans. The controller has no local state.
**Tech Stack:** Go 1.27, controller-runtime v0.25.2, controller-gen (CRD + deepcopy), `github.com/breml/go-uptime-kuma-client` v0.6.2, envtest, testcontainers-go, kaniko (Woodpecker), Flux (Kustomization + image automation), ESO/OpenBao, Cilium.
**Spec:** `docs/superpowers/specs/2026-10-18-kuma-sync-design.md`
## Global Constraints
- **Latest stable versions everywhere** (owner requirement). As of 2026-10-18 that is:
- Go 1.27 (`go` directive and builder image)
- controller-runtime v0.25.2, with the k8s.io libraries and controller-gen/envtest versions it requires
- go-uptime-kuma-client v0.6.2
- Uptime Kuma 2.5.6 (latest stable; 3.0 is nightly only)
- the latest testcontainers-go
Each task that adds a dependency first checks its latest release (GitHub releases / `go list -m -versions`) and uses that. Before the final commit of each task, run `go list -m -u all` and record that it reports no available updates for direct dependencies. If Kuma ships a newer stable release before Task 7, bump the VPS role and the test image together.
- Module path `gitea.renner.uno/k.renner/homelab/kuma-sync`, source in `ci/images/kuma-sync/`.
- Kuma client: `github.com/breml/go-uptime-kuma-client v0.6.2`, exact version. Copy its `replace github.com/maldikhan/go.socket.io => github.com/breml/go.socket.io <same pseudo-version>` from that release's `go.mod`.
- Kuma under test: `louislam/uptime-kuma:2.5.6-slim`. That is the same image the VPS runs (`ansible/roles/uptime-kuma/defaults/main.yml`).
- CRD: group `uptime.renner.uno`, version `v1alpha1`, kind `KumaMonitor`, plural `kumamonitors`, namespaced.
- Ownership tag: Kuma tag name `kuma-sync`; value `<namespace>/<name>`. Never read, edit or delete a monitor without that tag.
- Finalizer name: `uptime.renner.uno/kuma-sync`.
- Requeue when a parent group does not exist yet: 10 s. Full resync and orphan GC: every 5 min.
- Kuma URL `https://kuma.renner.uno`; credentials from OpenBao KV v2 mount `homelab`, path `infrastructure/uptime-kuma`, keys `username`, `password` (reuse the existing secret; this controller is Kuma's only API client).
- Image `gitea.renner.uno/gitea_admin/kuma-sync`, tag `YYYY-MM-DD-HHMM`, Flux ImagePolicy picks the newest (pattern of `kubernetes/infrastructure/image-automation/authentik-gate-sync.yaml`).
- Unit tests run as a stage of the image build (house pattern for authentik-gate-sync). Integration tests (build tag `integration`) need Docker and run locally, not in CI.
- No `2>/dev/null`. Config comments are terse factual lines. Commits as claude-bot. Every change goes through a PR. No force-push.
## Review Focus
1. **Restart or rebuild duplicates monitors.** That was the AutoKuma failure mode. The adopt-by-tag path is pinned by integration test `TestRestartAdoptsByTag` (Task 3) and reconciler unit test `TestReconcileAdoptsExisting` (Task 4).
2. **Deletion while the controller is down orphans a monitor.** The GC pass is pinned by `TestGCDeletesOrphans` (Task 5) and integration `TestDeleteWhileDownIsCollected` (Task 6).
3. **A hand-made Kuma monitor gets touched.** `TestGCIgnoresUntagged` (Task 5) and integration `TestUntaggedMonitorUntouched` (Task 6).
4. **`editMonitor` is a full overwrite.** Sending a partial object silently clears fields such as `parent` or `accepted_statuscodes`. Pinned by integration `TestUpdateKeepsUnchangedFields` (Task 3).
5. **Children are created before their group.** They must requeue after 10 s, not fail for minutes. Pinned by `TestReconcileWaitsForParent` (Task 4).
---
### Task 1: Module, API types, generated CRD
**Files:**
- Create: `ci/images/kuma-sync/go.mod`, `go.sum`
- Create: `ci/images/kuma-sync/api/v1alpha1/kumamonitor_types.go`, `groupversion_info.go`, generated `zz_generated.deepcopy.go`
- Create: `ci/images/kuma-sync/config/crd/uptime.renner.uno_kumamonitors.yaml` (generated)
- Create: `ci/images/kuma-sync/Makefile` with targets `generate` (controller-gen object + crd), `test`, `test-integration`
- Test: `ci/images/kuma-sync/api/v1alpha1/validation_test.go` (envtest)
**Interfaces:**
- Produces the type `KumaMonitorSpec`:
- `Type` enum: `group|http|keyword|json-query|dns|port|push`.
- `DisplayName string` (required).
- `ParentRef *LocalRef{Name}`.
- `Interval`, `RetryInterval` (`int32` seconds, min 20, defaults 60).
- `MaxRetries` (`int32`, default 0).
- `Timeout` (`int32` seconds, min 1).
- `Paused bool`.
- `Description string`.
- `HTTP *HTTPSpec` holds `URL` (required), `Method` (default GET), `AcceptedStatusCodes []string` (default `["200-299"]`), `Headers map[string]string`, `Body string`, `MaxRedirects *int32`, `IgnoreTLS bool`, `ExpiryNotification bool`. It is used by `http`, `keyword` and `json-query`.
- `Keyword *KeywordSpec{Keyword, Invert bool}`.
- `JSONQuery *JSONQuerySpec`: `Expression string` (JSONata), `Operator` enum `==|!=|<|<=|>|>=|contains`, `Expected string`.
- `DNS *DNSSpec{Hostname, ResolveType enum A|AAAA|CNAME|MX|NS|PTR|SOA|SRV|TXT|CAA, ResolveServer default "1.1.1.1", Port default 53}`.
- `Port *PortSpec{Hostname, Port int32 1-65535}`.
- `Push *PushSpec{}`, an empty marker.
- Produces the type `KumaMonitorStatus{MonitorID int64, ObservedGeneration int64, Conditions []metav1.Condition}`, where `Ready` is the only condition type.
- CEL rules on the spec:
- `type in ['http','keyword','json-query'] == has(self.http)`
- `(type == 'keyword') == has(self.keyword)`
- `(type == 'json-query') == has(self.jsonQuery)`
- `(type == 'dns') == has(self.dns)`
- `(type == 'port') == has(self.port)`
- `(type == 'push') == has(self.push)`
- `type == 'group'` implies none of the blocks are set.
- [ ] **Step 1: Write failing envtest validation tests.** Each test applies a `KumaMonitor` and asserts the API server's answer:
- `TestValidKeyword`: type keyword with http+keyword is accepted.
- `TestKeywordWithoutBlock`: rejected; the error contains `keyword`.
- `TestGroupWithHTTP`: rejected.
- `TestJSONQueryBadOperator`: operator `=~` is rejected.
- `TestIntervalBelowMin`: interval 5 is rejected.
- `TestDefaults`: an applied http monitor reads back `interval 60` and `acceptedStatusCodes ["200-299"]`.
- [ ] **Step 2: Run** `make test`. Expected: FAIL, because the types are missing.
- [ ] **Step 3: Write the types with kubebuilder markers, then run** `make generate`.
- [ ] **Step 4: Run** `make test`. Expected: all 6 PASS.
- [ ] **Step 5: Commit** `feat(kuma-sync): KumaMonitor API types and CRD`.
### Task 2: Kuma client interface and fake
**Files:**
- Create: `ci/images/kuma-sync/internal/kumaclient/client.go` (interface + domain types)
- Create: `ci/images/kuma-sync/internal/kumaclient/fake.go` (in-memory fake used by reconciler tests)
- Test: `ci/images/kuma-sync/internal/kumaclient/fake_test.go`
**Interfaces:**
- Produces:
```go
type Monitor struct { ID int64; Type, Name, Description string; Parent *int64; Active bool
Interval, RetryInterval, MaxRetries, Timeout int32
URL, Method, Body string; Headers map[string]string; AcceptedStatusCodes []string; MaxRedirects int32; IgnoreTLS, ExpiryNotification bool
Keyword string; InvertKeyword bool; JSONPath, JSONPathOperator, ExpectedValue string
Hostname string; Port int32; DNSResolveType, DNSResolveServer string
OwnerKey string } // value of the kuma-sync tag; "" = not ours
type Client interface {
ListOwned(ctx) ([]Monitor, error) // only monitors carrying the kuma-sync tag
Create(ctx, m Monitor) (int64, error) // creates and tags with m.OwnerKey
Update(ctx, m Monitor) error // full overwrite of an owned monitor; errors if not owned
Delete(ctx, id int64) error // errors if not owned
}
```
- The fake also holds untagged monitors (`AddForeign(Monitor)`), so the "never touch untagged" rule can be tested.
- [ ] **Step 1: Write failing tests:**
- `TestFakeListOwnedHidesForeign`
- `TestFakeUpdateRejectsForeign`
- `TestFakeDeleteRejectsForeign`
- `TestFakeCreateAssignsIDAndTag`
- [ ] **Step 2: Run** `go test ./internal/kumaclient/...`. Expected: FAIL.
- [ ] **Step 3: Implement the interface and the fake.**
- [ ] **Step 4: Run** the tests. Expected: PASS.
- [ ] **Step 5: Commit** `feat(kuma-sync): Kuma client interface and fake`.
### Task 3: Real client on breml/go-uptime-kuma-client + integration tests
**Files:**
- Create: `ci/images/kuma-sync/internal/kumaclient/breml.go`
- Test: `ci/images/kuma-sync/internal/kumaclient/breml_integration_test.go` (`//go:build integration`)
**Interfaces:**
- Consumes the `Client` interface and `Monitor` from Task 2.
- Produces `NewBreml(ctx, url, username, password string) (Client, error)`. It logs in once and reuses the session (Kuma rate-limits logins). It ensures the tag `kuma-sync` exists, creating it on first use with color `#2563eb`.
Integration harness: testcontainers-go starts `louislam/uptime-kuma:2.5.6-slim` with `UPTIME_KUMA_DB_TYPE=sqlite` and waits for port 3001. Setup must then create the admin. Use the client's setup call if it has one; otherwise emit `needSetup`/`setup` after the `info` event, exactly as `ansible/roles/uptime-kuma/files/kuma-setup.js` does.
- [ ] **Step 1: Write failing integration tests:**
- `TestCreateFieldsIntact`: creates a keyword, a json-query (operator `contains`), a dns, a port and a group monitor, then reads them back through a fresh client. Every field from Task 2's `Monitor` must equal the input.
- `TestUpdateKeepsUnchangedFields`: updates only `Interval` from 60 to 120. On re-read, `Parent`, `AcceptedStatusCodes`, `Keyword` and `ExpiryNotification` are unchanged.
- `TestRestartAdoptsByTag`: creates monitors, discards the client, builds a new one. `ListOwned` returns the same IDs with the same `OwnerKey`s.
- `TestUntaggedMonitorUntouched`: a monitor created through the raw library without a tag is absent from `ListOwned`, and `Update`/`Delete` on its ID return an error.
- [ ] **Step 2: Run** `go test -tags integration ./internal/kumaclient/...`. Expected: FAIL.
- [ ] **Step 3: Implement `breml.go`.** Map `Monitor` to the library's per-type monitor structs. Tag with `AddMonitorTag(tagID, monitorID, ownerKey)`. For `Update`, read the current full object and overwrite only the fields we manage, as Review Focus 4 requires.
- [ ] **Step 4: Run** the integration tests. Expected: all 4 PASS.
- [ ] **Step 5: Commit** `feat(kuma-sync): Kuma client on go-uptime-kuma-client`.
### Task 4: Reconciler
**Files:**
- Create: `ci/images/kuma-sync/internal/controller/kumamonitor_controller.go`
- Create: `ci/images/kuma-sync/internal/controller/mapping.go` (spec → `kumaclient.Monitor`)
- Test: `ci/images/kuma-sync/internal/controller/kumamonitor_controller_test.go` (controller-runtime fake client + Kuma fake)
- Test: `ci/images/kuma-sync/internal/controller/mapping_test.go`
**Interfaces:**
- Consumes `kumaclient.Client` and the API types.
- Produces `Reconciler{Client client.Client; Kuma kumaclient.Client}` with `Reconcile(ctx, req) (ctrl.Result, error)` and `SetupWithManager(mgr)`.
- Algorithm:
1. Get the resource.
2. If it is being deleted, delete the owned monitor by OwnerKey (absent counts as success) and remove the finalizer.
3. Ensure the finalizer is present.
4. Resolve `parentRef`: the parent `KumaMonitor` must exist and have `status.monitorID`. If not, set `Ready=False` with reason `ParentPending` and requeue after 10 s.
5. Map the spec to the desired `Monitor`, with `OwnerKey = ns/name`.
6. Find the existing monitor via `ListOwned` by OwnerKey. If there is none, `Create`. If it differs on managed fields, `Update`.
7. Set the status: monitorID, observedGeneration, `Ready=True`.
8. Requeue after 5 min.
- [ ] **Step 1: Write failing tests:**
- `TestMappingAllTypes`: a table test, one row per type, showing that spec fields land on the right `Monitor` fields; `Paused` maps to `Active=false`.
- `TestReconcileCreates`
- `TestReconcileAdoptsExisting`: the fake already holds a monitor with the same OwnerKey. Expected: no second create, and status.monitorID equals the existing ID.
- `TestReconcileUpdatesDrift`: the fake monitor's interval differs. Expected: one `Update`.
- `TestReconcileNoopWhenEqual`: zero Update calls.
- `TestReconcileWaitsForParent`: a child whose group has no monitorID gets `RequeueAfter == 10s` and `Ready=False/ParentPending`.
- `TestReconcileDeleteRemovesMonitorAndFinalizer`
- `TestReconcileDeleteToleratesMissingMonitor`
- [ ] **Step 2: Run** `go test ./internal/controller/...`. Expected: FAIL.
- [ ] **Step 3: Implement the mapping and the reconciler.**
- [ ] **Step 4: Run** the tests. Expected: PASS.
- [ ] **Step 5: Commit** `feat(kuma-sync): KumaMonitor reconciler`.
### Task 5: Orphan garbage collection, manager, image
**Files:**
- Create: `ci/images/kuma-sync/internal/controller/gc.go` (a `manager.Runnable` ticking every 5 min)
- Create: `ci/images/kuma-sync/cmd/kuma-sync/main.go`
- Create: `ci/images/kuma-sync/Containerfile`. Stage 1 is `golang` (pinned digest), runs `go test ./...` without tags, then builds a static binary. Stage 2 is distroless static nonroot.
- Modify: `.woodpecker/build-ci-images.yml`. Add a `build-kuma-sync` step that mirrors `build-authentik-gate-sync`, with tag `YYYY-MM-DD-HHMM` plus `latest`.
- Test: `ci/images/kuma-sync/internal/controller/gc_test.go`
**Interfaces:**
- Produces `GC{Client client.Client; Kuma kumaclient.Client; Interval time.Duration}`. Each run deletes every owned monitor whose OwnerKey has no matching `KumaMonitor`. A list error from either side aborts the run, so nothing is deleted on partial information.
- `main.go` reads env `KUMA_URL`, `KUMA_USERNAME` and `KUMA_PASSWORD`. Missing env makes it exit non-zero with a message naming the variable. Health probes run on `:8081`, metrics on `:8080`, and there is no leader election.
- [ ] **Step 1: Write failing tests:**
- `TestGCDeletesOrphans`
- `TestGCIgnoresUntagged`: a foreign monitor in the fake survives.
- `TestGCKeepsLiveResources`
- `TestGCAbortsOnListError`: the fake k8s list fails, and zero deletes happen.
- [ ] **Step 2: Run** the tests. Expected: FAIL.
- [ ] **Step 3: Implement the GC and `main.go`.**
- [ ] **Step 4: Run** `go test ./...` (expected: PASS), then `docker build -f Containerfile .` in `ci/images/kuma-sync` (expected: the build succeeds, with the test stage passing).
- [ ] **Step 5: Commit** `feat(kuma-sync): orphan GC, manager and image build`. Open a PR. After the owner merges it, the controller confirms the first image tag exists in the registry before Task 7.
### Task 6: End-to-end integration test (automated pilot)
**Files:**
- Test: `ci/images/kuma-sync/internal/controller/e2e_integration_test.go` (`//go:build integration`)
The test runs envtest (an API server with the CRD) plus a Kuma 2.5.6 container plus the real manager, with the breml client and GC interval 2 s.
- [ ] **Step 1: Write the tests:**
- `TestE2EGroupNesting`: apply group `outer`, group `inner` (parent outer), and a dns monitor (parent inner), all at once. Within 60 s, all three are in Kuma with the right parent IDs.
- `TestE2ERestartNoDuplicates`: stop the manager, start a new one, wait 2 GC intervals. The monitor count in Kuma is unchanged.
- `TestDeleteWhileDownIsCollected`: stop the manager, remove the finalizer, delete the resource, start the manager. The monitor is gone from Kuma within 2 GC intervals.
- `TestE2EDriftRepaired`: change the interval directly in Kuma via the raw library. The next resync restores the spec value.
- [ ] **Step 2: Run** `go test -tags integration ./internal/controller/...`. Expected: PASS. Any failure is a defect, not something to retry.
- [ ] **Step 3: Commit** `test(kuma-sync): end-to-end integration suite`.
### Task 7: Kubernetes deployment
**Files:**
- Create: `kubernetes/apps/kuma-sync/{crd-kumamonitors.yaml (copied from config/crd), rbac.yaml, externalsecret.yaml, deployment.yaml, kustomization.yaml}`
- Create: `kubernetes/clusters/homelab/kuma-sync.yaml`. It is a Flux Kustomization modeled on the removed `autokuma.yaml`: dependsOn infrastructure and infrastructure-crds, with the CEL ExternalSecret health check.
- Create: `kubernetes/infrastructure/network-policies/kuma-sync-ns.yaml`, the same shape as the removed `autokuma-ns.yaml`: ingress `[{}]`, DNS with `rules.dns`, kube-apiserver, `toFQDNs kuma.renner.uno:443`. Allow kubelet probes from `host`.
- Create: `kubernetes/infrastructure/image-automation/kuma-sync.yaml` (ImageRepository + ImagePolicy).
- Modify: `namespaces.yaml` (add `kuma-sync`), `network-policies/kustomization.yaml`, and the `openbao-clustersecretstore.yaml` allowlist (sorted).
**Interfaces:**
- Deployment: image `gitea.renner.uno/gitea_admin/kuma-sync:<first tag from Task 5>` with the setter `# {"$imagepolicy": "flux-system:kuma-sync"}`. 1 replica, `Recreate`. Security context as in `kubernetes/apps/uptime-monitor/deployment.yaml`, with a read-only root filesystem.
- ClusterRole: `kumamonitors` get/list/watch/update/patch, `kumamonitors/status` get/update/patch, `kumamonitors/finalizers` update, and `events` create/patch.
- [ ] **Step 1: Write the manifests.**
- [ ] **Step 2: Verify:**
- `kubectl kustomize` on every touched directory renders.
- `kubectl apply --dry-run=server -f kubernetes/apps/kuma-sync/crd-kumamonitors.yaml` succeeds.
- [ ] **Step 3: Commit** `feat(kuma-sync): deploy controller` and open a PR. After merge, the controller checks:
- `flux get kustomization kuma-sync` reports Ready.
- The pod is Running.
- The Kuma log shows a login from the controller.
- [ ] **Step 4: Live smoke test** in a follow-up PR: one `KumaMonitor` (keyword on www.renner.uno) in namespace `kuma-sync`. Expected:
- It shows up in Kuma, tagged `kuma-sync=kuma-sync/www-renner-uno`.
- Deleting the pod changes nothing in Kuma.
- Removing it from git deletes the monitor.
## Follow-up (parent plan)
Parent plan `2026-10-18-uptime-kuma.md` Task 3 (pilot) is replaced by Tasks 6 and 7 here. Its Tasks 4 and 5 then use `KumaMonitor` instead of `KumaEntity`.
@@ -0,0 +1,168 @@
# Uptime Kuma (replace UptimeRobot) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace UptimeRobot with a self-hosted Uptime Kuma on the NetBird VPS, with every monitor, notification and the status page declared in this repo.
**Architecture:** Uptime Kuma runs as an Ansible-managed container on `netbird.renner.uno` behind that host's existing Traefik, and checks targets over the public internet (no NetBird dependency). An in-cluster controller turns `KumaEntity` custom resources into Kuma monitors over Kuma's public HTTPS endpoint. The cluster's blackbox exporter probes Kuma itself, so a dead VPS still raises an alarm. Kuma moves to its own VPS later by retargeting the Ansible play and DNS; nothing else changes.
**Tech Stack:** Uptime Kuma 2.5.6 (`louislam/uptime-kuma:2.5.6-slim`, SQLite), AutoKuma 2.0.0 (`ghcr.io/bigboot/autokuma:2.0.0`, Kubernetes source), Ansible + docker compose, Traefik (docker provider), Flux, ESO/OpenBao, Woodpecker.
**Spec:** this conversation (2026-10-18). There is no separate spec file; the decisions are recorded under *Decisions* below.
## Decisions (from the design conversation)
- Kuma lives **off-site**, on the NetBird coordinator VPS for now. It gets its own VPS once the setup is accepted. The NetBird coordinator is not in the ingress path, so sharing the host does not skew what the probes measure.
- Monitors are **declared in the repo** as custom resources and reconciled by a controller. A ready-made controller is used if it passes the pilot gate (Task 3). Otherwise we write our own, under a separate plan.
- Detailed checks (keyword, JSON query, DNS, TCP, certificate expiry) are a goal, not an afterthought. The custom-resource schema must carry them from the first monitor.
- The UptimeRobot IngressMonitorController (`kubernetes/apps/uptime-monitor/`) is removed after cutover.
## Owner answers (2026-10-18)
1. The credential source is OpenBao. The play reads it with `community.hashi_vault`.
2. Public DNS is managed in the Cloudflare UI. The owner creates `kuma.renner.uno` before Task 1 deploys and repoints `uptime.renner.uno` in Task 5.
3. The SMTP sender is `uptime@renner.uno`. Its Proton SMTP settings are stored at OpenBao `homelab/infrastructure/uptime-kuma/smtp` with keys `server`, `port`, `username` and `token`. Task 4 reads all four, and the `uptime-kuma-vps` policy gains read on `homelab/data/infrastructure/uptime-kuma/smtp`.
## Original open questions (kept for reference)
1. **Where the Kuma admin credential lives.** Kuma is single-user. Ansible needs that user to bootstrap the instance, and AutoKuma needs it to log in. Ansible has no OpenBao lookup today (`ansible/` uses Ansible Vault only), so storing it in both places makes two copies. *Recommendation:* OpenBao `infrastructure/uptime-kuma` is the only source, and the play reads it with `community.hashi_vault`, using a Woodpecker secret token that has read access to that one path. This plan assumes that recommendation. If it is rejected, Task 1 Step 2 changes and nothing else does.
2. **Public DNS.** Neither `netbird.renner.uno` nor `uptime.renner.uno` is defined in this repo (`opentofu/dns_renner_uno.tf` holds LAN records only). `uptime.renner.uno` currently points at UptimeRobot's status-page fleet. Where are these records managed? If it is the Cloudflare UI, Task 1 needs `kuma.renner.uno` created there and Task 5 needs `uptime.renner.uno` repointed there.
3. **Alert channel.** Alertmanager sends email through `smtp.protonmail.ch:587` as `cluster@renner.uno`. Kuma needs its own SMTP token, because no service shares another's key. *Recommendation:* create a Proton SMTP token for `uptime@renner.uno` (or another address you pick) and store it at OpenBao `infrastructure/uptime-kuma`, key `smtp_password`. Creating the token is a one-time owner action.
## Global Constraints
- Kuma image: `louislam/uptime-kuma:2.5.6-slim`. Pin it in role defaults, the way `netbird_version` is pinned. The controller drives Kuma's **internal Socket.io API**, which upstream says may change without notice. Kuma and AutoKuma are therefore bumped together, and only after the pilot checks from Task 3 pass against the new pair.
- `UPTIME_KUMA_DB_TYPE=sqlite`. This skips the database setup wizard (`server/setup-database.js:98`).
- Kuma data lives at `/opt/uptime-kuma/data` (container `/app/data`) on local disk. Not NFS.
- Hostnames: `kuma.renner.uno` serves the admin UI and the Socket.io API. `uptime.renner.uno` serves the public status page, through Kuma's custom-domain mapping.
- Nothing in the Kuma alert path may traverse the homelab.
- No `2>/dev/null` anywhere. No inline interpreters in Bash. Commits use the claude-bot identity, every change goes through a PR, and nothing is ever force-pushed.
## Review Focus
1. **Socket.io API drift.** AutoKuma 2.0.0 (2025-11) targets an older Kuma 2.x API than 2.5.6. Silent failures (a monitor created with fields dropped) are worse than loud ones. The Task 3 pilot asserts the field values Kuma actually stored, not just that the monitor exists.
2. **Controller restart duplicates monitors.** AutoKuma tracks ownership by tag and keeps state in `/data`. A pod restart with an empty `/data` must not create a second copy of every monitor. Task 3 Step 5 pins this.
3. **Deleting a `KumaEntity` while the controller is down.** The finalizer `entity.autokuma.bigboot.dev` must hold the object until the controller is back and removes the monitor. Task 3 Step 6 pins this.
4. **Kuma or VPS down goes unnoticed.** The blackbox probe on `https://kuma.renner.uno` plus its alert rule (Task 4) is the dead-man switch. Task 4 Step 4 proves the alert fires.
5. **Unclaimed fresh instance.** Between `docker compose up` and admin setup, the first visitor to `kuma.renner.uno` could claim admin. The bootstrap runs in the same play run, before the Traefik route is enabled (Task 1 Step 4).
---
### Task 1: Uptime Kuma on the NetBird VPS (Ansible role, play, pipeline)
**Files:**
- Create: `ansible/roles/uptime-kuma/defaults/main.yml`
- Create: `ansible/roles/uptime-kuma/tasks/main.yml`
- Create: `ansible/roles/uptime-kuma/templates/docker-compose.yml.j2`
- Create: `ansible/roles/uptime-kuma/files/kuma-setup.js`
- Create: `ansible/plays/install-uptime-kuma.yml`
- Create: `.woodpecker/deploy-uptime-kuma.yml`
**Interfaces:**
- Produces: `https://kuma.renner.uno` (Kuma UI and Socket.io), with the admin user from OpenBao `infrastructure/uptime-kuma` keys `username` and `password`. Task 3 consumes these.
- [ ] **Step 1: Role defaults.** Set `uptime_kuma_version: "2.5.6"`, `uptime_kuma_host: kuma.renner.uno`, `uptime_kuma_status_host: uptime.renner.uno`, `uptime_kuma_dir: /opt/uptime-kuma`, and `uptime_kuma_traefik_network: netbird_netbird`. The last one is the network the NetBird Traefik watches (`ansible/roles/netbird-server/templates/docker-compose.yml.j2`, docker provider).
- [ ] **Step 2: Credentials.** Look up `username` and `password` from OpenBao `infrastructure/uptime-kuma` with `community.hashi_vault`. Add the collection to the CI image only if `ci-ansible:2026-04-29` lacks it; check with `ansible-galaxy collection list` in that image. Store the Woodpecker secret `openbao_uptime_kuma_token`, and give it a policy that reads that one path.
- [ ] **Step 3: Compose template.** One service, `uptime-kuma`, using image `louislam/uptime-kuma:{{ uptime_kuma_version }}-slim`. Environment `UPTIME_KUMA_DB_TYPE=sqlite`, volume `{{ uptime_kuma_dir }}/data:/app/data`, external network `{{ uptime_kuma_traefik_network }}`. Traefik labels: `traefik.enable={{ uptime_kuma_route_enabled }}` and a router on `Host(kuma.renner.uno) || Host(uptime.renner.uno)` with `tls.certresolver=letsencrypt` and server port 3001.
- [ ] **Step 4: Bootstrap with no manual step.** `files/kuma-setup.js` uses `socket.io-client`, which is a production dependency of Kuma 2.5.6 and so present in the image. It connects to `http://localhost:3001`, emits `needSetup`, and if the answer is true emits `setup(username, password)`. If the answer is false it exits 0. It reads the credentials from env vars `KUMA_SETUP_USERNAME` and `KUMA_SETUP_PASSWORD`, never from arguments. The tasks run in this order:
1. Template compose with `uptime_kuma_route_enabled: false`, then `docker compose up -d`.
2. Wait for `GET http://127.0.0.1:3001` (published on loopback only) to return 200.
3. Copy the script in and run it with `docker compose exec -e ... uptime-kuma node /tmp/kuma-setup.js`.
4. Re-template with the route enabled and run `docker compose up -d` again.
- [ ] **Step 5: Play and pipeline.** `plays/install-uptime-kuma.yml` targets hosts `netbird_server` with roles `uptime-kuma`. `docker-install` is already applied by the NetBird play. `.woodpecker/deploy-uptime-kuma.yml` is a copy of `.woodpecker/deploy-netbird-server.yml` with its paths and play swapped, and it adds the OpenBao token secret.
- [ ] **Step 6: Verify.**
- Run `ansible-playbook --syntax-check -i ansible/inventory ansible/plays/install-uptime-kuma.yml`. Expected: no errors.
- After the pipeline runs, `curl -sI https://kuma.renner.uno/` returns `302` to `/dashboard`. A `/setup` response means the bootstrap failed.
- Run the pipeline a second time. Expected: it is green and the script logs "already set up".
- [ ] **Step 7: Commit and open a PR.** Message: `feat(uptime-kuma): Uptime Kuma on the NetBird VPS`.
### Task 2: Controller deployment (AutoKuma, Kubernetes source)
**Files:**
- Create: `kubernetes/apps/autokuma/{crd-kumaentities.yaml,externalsecret.yaml,rbac.yaml,deployment.yaml,kustomization.yaml}`
- Create: `kubernetes/clusters/homelab/autokuma.yaml`
- Create: `kubernetes/infrastructure/network-policies/autokuma-ns.yaml`
- Modify: `kubernetes/infrastructure/namespaces/namespaces.yaml`. Add namespace `autokuma`.
**Interfaces:**
- Consumes: Task 1's `https://kuma.renner.uno` and the OpenBao `infrastructure/uptime-kuma` `username`/`password`.
- Produces: the CRD `kumaentities.autokuma.bigboot.dev/v1` (namespaced, `spec.config` is a free-form object). Monitors live as `KumaEntity` objects in namespace `autokuma`. `metadata.name` is the AutoKuma ID, and `spec.config` uses the static-monitor keys (`type`, `name`, `url`, …).
- [ ] **Step 1: Vendor the CRD.** Copy it verbatim from `https://raw.githubusercontent.com/BigBoot/AutoKuma/v2.0.0/autokuma/kubernetes/crds-autokuma.yml`, with a one-line header naming the source and the tag.
- [ ] **Step 2: RBAC.** AutoKuma lists and watches `Api::<KumaEntity>::all` (`kubernetes_source.rs`, `init`), so a namespaced Role cannot satisfy it. Create a ClusterRole limited to `kumaentities` with `get, list, watch, patch, update`, plus a ClusterRoleBinding to ServiceAccount `autokuma/autokuma`. No other resources.
- [ ] **Step 3: ExternalSecret.** Use ClusterSecretStore `openbao`, key `infrastructure/uptime-kuma`, properties `username` and `password`, rendered into a Secret named `autokuma-kuma`. Copy the CEL health-check pattern from `kubernetes/clusters/homelab/uptime-monitor.yaml`.
- [ ] **Step 4: Deployment.** Image `ghcr.io/bigboot/autokuma:2.0.0`, 1 replica, strategy `Recreate`. Environment:
- `AUTOKUMA__KUMA__URL=https://kuma.renner.uno`
- `AUTOKUMA__KUMA__USERNAME` and `AUTOKUMA__KUMA__PASSWORD` from the Secret
- `AUTOKUMA__KUBERNETES__ENABLED=true`
- `AUTOKUMA__DOCKER__ENABLED=false`
- `AUTOKUMA__TAG_NAME=AutoKuma`
- `AUTOKUMA__ON_DELETE=delete`
Mount an `emptyDir` at `/data`. Add the security context from `kubernetes/apps/uptime-monitor/deployment.yaml`, and the reloader annotation for `autokuma-kuma`.
- [ ] **Step 5: NetworkPolicy.** Copy `uptime-monitor-ns.yaml`: default-deny, DNS to kube-dns, `kube-apiserver`, and `world` on TCP 443.
- [ ] **Step 6: Flux Kustomization.** Path `./kubernetes/apps/autokuma`, `prune: true`, `wait: true`, `dependsOn` `infrastructure` and `infrastructure-crds`. It ships with **no** `KumaEntity` objects.
- [ ] **Step 7: Verify.**
- `kubectl kustomize kubernetes/apps/autokuma` renders.
- After merge, `kubectl -n autokuma logs deploy/autokuma` shows a successful Kuma login and no "CRD is not queryable".
- `flux get kustomization autokuma` reports Ready.
- [ ] **Step 8: Commit and open a PR.** Message: `feat(autokuma): KumaEntity controller for Uptime Kuma`.
### Task 3: Pilot gate — does AutoKuma meet the bar?
**Files:**
- Create: `kubernetes/apps/autokuma/pilot-kumaentities.yaml`. Add it to the kustomization last.
**Interfaces:**
- Consumes: Task 2's CRD and controller.
- Produces: a go/no-go verdict, recorded in the PR description. **No-go stops this plan.** The fallback is our own controller against the same CRD shape, under a separate plan.
- [ ] **Step 1: Pilot entity.** Create `KumaEntity` `www-renner-uno` with `config: {type: keyword, name: "www.renner.uno", url: "https://www.renner.uno/", keyword: "<a string present on the page>", interval: 60, expiry_notification: true}`. Check the field names against `https://autokuma.bigboot.dev/dev/entity-types/keyword/` before writing them.
- [ ] **Step 2: Fields arrive intact.** In the Kuma UI, or with `kuma monitor list` from the AutoKuma CLI, confirm `type=keyword`, the keyword, `interval=60` and certificate-expiry notification are all as declared. Expected: all four match. Any dropped field is a **no-go**.
- [ ] **Step 3: Update.** Change `interval` to 120 in git. Expected: Kuma shows 120 within one reconcile (≤ 5 min), and the monitor ID is unchanged.
- [ ] **Step 4: Detailed types.** Add one `json-query` entity, one `dns` entity, and one `port` entity on `proxy-1.renner.uno:33080`. Expected: all three are created with their type-specific fields intact.
- [ ] **Step 4a: Groups.**
- Add a `group` entity `pilot` and set `parent_name: pilot` on every pilot check. Expected: in Kuma, all checks sit under the group, and the group goes DOWN when one child is pointed at a non-existent path. A group is DOWN when any child is DOWN (`server/monitor-types/group.js`).
- Add a group `pilot-inner` with `parent_name: pilot`, and move one check under it. Record whether nesting works. If it doesn't, Task 5 uses a flat layout with one group per service, which is not a no-go.
- [ ] **Step 4b: JSON query intact.** Read back the `json-query` entity's `json_path`, `json_path_operator` and `expected_value` from Kuma. Use a non-default operator such as `>=`, so that a dropped field shows up as the default `==`. Expected: all three match the values declared in git.
- [ ] **Step 5: Restart without duplicates.** Delete the AutoKuma pod, which empties `/data`. Expected: after it restarts, Kuma still holds exactly one monitor per entity.
- [ ] **Step 6: Deletion while the controller is down.** Scale the controller to 0, delete the `port` entity in git, wait for Flux, then scale back to 1. Expected: the object sits `Terminating` with the finalizer while the controller is down, and the Kuma monitor is removed after scale-up. An orphaned monitor is a **no-go**.
- [ ] **Step 7: Verdict.** If Steps 2–6 all pass, the verdict is **go**: commit the pilot entities and open a PR with the evidence. Otherwise it is **no-go**: revert the pilot and report which step failed.
### Task 4: Notifications, status page, dead-man switch
**Files:**
- Modify: `ansible/roles/uptime-kuma/files/kuma-setup.js`. Add the SMTP notification: host, port, user and password from OpenBao `homelab/infrastructure/uptime-kuma/smtp` keys `server`, `port`, `username` and `token`, sender `uptime@renner.uno`, "default enabled", applied to all monitors. `KumaEntity.spec.config` is plain text in git, so the secret-bearing notification is created by the bootstrap and never by a custom resource.
- Create: `kubernetes/apps/autokuma/status-page.yaml` (a `KumaEntity` status page with slug `renner`, domain `uptime.renner.uno`)
- Modify: `kubernetes/apps/monitoring-rules/probes-blackbox.yaml`. Add `https://kuma.renner.uno` to `blackbox-http`.
- Modify: the alert-rule file that covers `blackbox-http` targets. Find it with Grep for `probe_success` under `kubernetes/apps/monitoring-rules/`.
- [ ] **Step 1: Check the status-page entity.** Check AutoKuma's status-page entity docs for field names and domain support. If AutoKuma cannot set the status-page domain, have `kuma-setup.js` set it instead, keeping the value in the role defaults.
- [ ] **Step 2: Notification.** Extend `kuma-setup.js` so it creates the notification if none named `email` exists, and updates it otherwise. Run the Task 1 pipeline. Expected: in the Kuma UI, *Test* delivers a mail to the owner's address, and a second pipeline run creates no duplicate.
- [ ] **Step 3: Status page.** Apply it. Expected: `curl -s -H 'Host: uptime.renner.uno' https://kuma.renner.uno/` returns the status page HTML, not the dashboard redirect. Turn on Kuma's "Trust Proxy" setting through the same mechanism as the domain.
- [ ] **Step 4: Dead-man switch.** Add the blackbox target and make sure an alert rule covers it. Prove it fires: stop the Kuma container on the VPS through the pipeline (or have the owner do it). Expected: the alert email arrives through Alertmanager. Then start Kuma again.
- [ ] **Step 5: Response-time alerting.** Kuma 2.5.6 has no latency threshold; its states are only UP, DOWN, PENDING and MAINTENANCE. Degradation is therefore alerted from the cluster:
- `kuma-setup.js` creates a Kuma metrics API key named `prometheus` if none exists, and writes it to OpenBao `infrastructure/uptime-kuma-metrics` key `api_key`. The Ansible token gets write access to that one path. No other service's credential is reused.
- Add an ExternalSecret into `monitoring` for that key, and an `additionalScrapeConfigs` job `uptime-kuma` in `kubernetes/apps/monitoring/helmrelease-prometheus.yaml`: target `kuma.renner.uno:443`, `scheme: https`, `basic_auth` with an empty username and the key as `password_file`.
- Check that the `monitoring` namespace egress policy lets Prometheus reach `world:443`, and add the rule if it doesn't.
- Add a PrometheusRule `UptimeKumaSlowResponse` on `monitor_response_time` (label `monitor_name`): `avg_over_time(monitor_response_time[15m]) > 1500` for 15m, severity warning. Check the metric name against a live scrape before writing the rule.
- Expected: `monitor_response_time` series are visible in Prometheus for every Kuma monitor, and the rule loads without errors.
- [ ] **Step 6: Commit and open a PR.** Message: `feat(autokuma): notifications, status page, Kuma dead-man probe, latency alerting`.
### Task 5: Migrate monitors, cutover, decommission UptimeRobot
**Files:**
- Create: `kubernetes/apps/autokuma/monitors-*.yaml`. One file per area, one `KumaEntity` per check.
- Delete (in the decommission PR): `kubernetes/apps/uptime-monitor/`, `kubernetes/clusters/homelab/uptime-monitor.yaml`, `kubernetes/infrastructure/network-policies/uptime-monitor-ns.yaml`, and the `uptime-monitor` namespace entry.
- [ ] **Step 1: Inventory.** Take the 9 hand-made monitors from UptimeRobot's public feed. Its path is in the header of `kubernetes/apps/uptime-monitor/pilot-endpointmonitors.yaml`. Each monitored service becomes one `group` entity with its checks as children, nested or flat depending on Task 3 Step 4a.
- Every check sets `accepted_statuscodes` and a tight `timeout`, so "far too slow" counts as DOWN.
- Where the service has a health or API endpoint, use `json-query` with a JSONata expression that validates the parsed content (example: `$count(components[status != "ok"])` `==` `0`).
- Otherwise use `keyword` on a string that only a working page renders. Keyword matching is a case-sensitive substring, not a regex.
- `expiry_notification: true` on every HTTPS check.
- The 3 third-party monitors move as plain checks.
- [ ] **Step 2: Parallel run.** Merge the monitors and run both systems for 7 days. Expected: Kuma reports every outage UptimeRobot reports.
- [ ] **Step 3: Cutover.** Repoint `uptime.renner.uno` at the VPS (see open question 2). Expected: `curl -sI https://uptime.renner.uno/` is served by Kuma with a Let's Encrypt certificate.
- [ ] **Step 4: Decommission PR.** Remove the IMC files listed above in one PR. Monitors deleted along with the CRD are orphaned in UptimeRobot. That is acceptable, because the account is closed next.
- [ ] **Step 5: Owner action.** Close the UptimeRobot account. Delete the OpenBao key `infrastructure/uptime-robot`.
- [ ] **Step 6: Wiki.** Deposit the final architecture through `wiki-ingest`: the corrected `uptime-kuma-probes` note, the Socket.io-API pinning rule, and the dead-man switch.
@@ -0,0 +1,48 @@
# kuma-sync — in-house Uptime Kuma controller (design)
## Why
AutoKuma failed the pilot gate (plan `2026-10-18-uptime-kuma.md`, Task 3). It never applied its ownership tag against Uptime Kuma 2.5.6, so ownership lived only in the pod's local `/data`, and every restart duplicated every monitor. Its CRD is also untyped (`spec.config: object`), so typos are only noticed when a monitor silently misbehaves. The owner decided to replace it with an in-house controller.
## Goals
- Uptime Kuma monitors are declared in git as **typed** Kubernetes resources, validated by the API server when they are applied.
- **Ownership is stored in Kuma itself**: a tag on each managed monitor. Restart, cluster rebuild or a lost pod state never duplicates or orphans a monitor.
- The controller converges on its own: creation order (groups before children), drift (a monitor edited in the UI is set back), and deletion (including deletion while the controller was down).
- It supports the detailed checks the owner asked for: groups with nesting, HTTP status codes, keyword (with invert), JSON query (JSONata expression + operator + expected value), DNS, TCP port, certificate-expiry notification, and timeouts per check.
## Non-goals
- Notifications and status pages are not managed as resources (Task 4 of the parent plan creates them via the Ansible bootstrap). Monitors reference notifications by name only if Task 4 needs it; not in v1.
- No support for other Kuma versions than the pinned one. Kuma and the client library are bumped together.
- No Ingress-driven auto-monitors. Every monitor is an explicit resource.
- Push monitors (Kuma does not generate push tokens via the API; needs token handling) — later version.
## Anti-goals
- Never touch a Kuma monitor that does not carry this controller's tag (hand-made monitors stay untouched).
- No local state the controller depends on. Restarting with nothing but the cluster and Kuma must give the same result.
## Decisions (owner)
- Language: Go.
- Typed CRD.
- Ownership via a Kuma tag.
## Decisions (my calls, open to review)
- **Name** `kuma-sync`, source in `ci/images/kuma-sync/` (house pattern: `netbird-service-sync`), image `gitea.renner.uno/gitea_admin/kuma-sync`, built by kaniko in `.woodpecker/build-ci-images.yml`, rolled out by Flux image automation.
- **Kuma client**: `github.com/breml/go-uptime-kuma-client` v0.6.2 (MIT, tested against Kuma 2.5.6), pinned, with its `replace` for the go.socket.io fork copied into our `go.mod`. Wrapped behind a small internal interface so the reconciler can be tested against a fake and the library can be swapped.
- **CRD** `KumaMonitor`, group `uptime.renner.uno`, version `v1alpha1`, namespaced, generated by `controller-gen` from Go types with kubebuilder markers. One kind with `spec.type` (enum) and one optional block per type; CEL rules enforce that exactly the block matching `type` is set. Groups are `KumaMonitor`s of type `group`; children reference them with `spec.parentRef.name` (same namespace).
- **Ownership tag**: Kuma tag named `kuma-sync`, value `<namespace>/<name>` of the resource. Not the UID: a rebuilt cluster creates new UIDs and must adopt the existing monitors.
- **Reconcile**: find the monitor by tag value → none: create, then tag → exists: compare the desired fields with the actual ones and edit if they differ (full-object `editMonitor`, as Kuma requires). Parent not created yet → requeue after 10 s, not minutes.
- **Deletion**: a finalizer deletes the Kuma monitor. A periodic garbage collection (every resync) deletes tagged monitors whose resource no longer exists, which also covers deletion while the controller was down.
- **Resync** every 5 minutes, to repair drift.
- **Scope**: watches `KumaMonitor` cluster-wide (apps can declare their own monitors in their namespace). ClusterRole limited to `kumamonitors`, `kumamonitors/status`, `kumamonitors/finalizers` and `events`. Credentials come from a Secret in its own namespace.
- **Network**: egress only to DNS, kube-apiserver and `kuma.renner.uno:443` (FQDN policy, same as the AutoKuma one the owner asked for).
- **Single replica, Recreate, no leader election** (house pattern from the IngressMonitorController deployment).
- **Tests**: unit tests for the reconciler against a fake Kuma; CRD validation tests with envtest; integration tests (build tag `integration`) against a real `louislam/uptime-kuma:2.5.6-slim` container via testcontainers-go. The integration suite reproduces the AutoKuma pilot: fields intact, update, groups + nesting, json-query operator, restart without duplicates, deletion while down.
## Left to the builder
Internal package layout, naming inside the code, log format, metric names.
@@ -450,7 +450,7 @@ data:
# synthesise the entry by creating a service-account user
# whose user.attributes carry the sambaDomain objectClass
# and the required schema fields. The patched LDAP outpost
# (gitea.renner.uno/k.renner/authentik-samba-bridge-ldap-outpost)
# (gitea.renner.uno/konairius/authentik-samba-bridge-ldap-outpost)
# accepts sambaDomain in its hardcoded GetUserOCs() set so the
# search isn't short-circuited. The sambaSID below MUST match
# SAMBA_BRIDGE_DOMAIN_SID in samba-bridge-secret.sops.yaml AND
@@ -711,8 +711,8 @@ data:
# AND (&(sambaDomainName=...)(objectClass=sambaDomain))
# return entries instead of short-circuiting. Built from
# the samba-bridge repo at
# gitea.renner.uno/k.renner/authentik-samba-bridge .
container_image: gitea.renner.uno/k.renner/authentik-samba-bridge-ldap-outpost:2026.8.3-872f754
# gitea.renner.uno/konairius/authentik-samba-bridge .
container_image: gitea.renner.uno/konairius/authentik-samba-bridge-ldap-outpost:2026.8.3-9dd92dd
object_naming_template: "ak-outpost-%(name)s"
kubernetes_namespace: authentik
kubernetes_replicas: 1
+1 -1
View File
@@ -114,7 +114,7 @@ spec:
type: RuntimeDefault
containers:
- name: controller
image: gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-10-15-0857 # {"$imagepolicy": "flux-system:authentik-gate-sync"}
image: gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-10-19-0544 # {"$imagepolicy": "flux-system:authentik-gate-sync"}
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
+4 -4
View File
@@ -47,9 +47,9 @@ spec:
# in the configured Synology Users group, so the LDAP outpost
# then emits NT-hash-bearing user entries that DSM's smbd
# accepts for NTLMv2 SMB authentication. See
# https://gitea.renner.uno/k.renner/authentik-samba-bridge .
# https://gitea.renner.uno/konairius/authentik-samba-bridge .
image:
repository: gitea.renner.uno/k.renner/authentik-samba-bridge-server
repository: gitea.renner.uno/konairius/authentik-samba-bridge-server
# Pinned to a specific build for two reasons:
# 1. K8s does not pull on every reschedule when tag=latest, so
# a floating tag means we don't actually pick up rebuilds
@@ -57,8 +57,8 @@ spec:
# "no pod invasion" rule).
# 2. Pinning means the deployed binary is traceable to a
# specific repo commit. Bump the tag whenever a new build
# is published from gitea.renner.uno/k.renner/authentik-samba-bridge .
tag: "2026.8.3-872f754"
# is published from gitea.renner.uno/konairius/authentik-samba-bridge .
tag: "2026.8.3-9dd92dd"
envFrom:
# SAMBA_BRIDGE_DOMAIN_SID / _ENABLED_GROUP / _UID_OFFSET
# consumed by the bridge module at Django app startup. Missing
+1 -1
View File
@@ -23,7 +23,7 @@ spec:
type: RuntimeDefault
containers:
- name: gitea-mcp
image: docker.gitea.com/gitea-mcp-server:1.9.0 # {"$imagepolicy": "flux-system:gitea-mcp"}
image: docker.gitea.com/gitea-mcp-server:1.10.0 # {"$imagepolicy": "flux-system:gitea-mcp"}
# Image has no ENTRYPOINT (binary is in CMD), so args alone would replace it.
command:
- /app/gitea-mcp
+22
View File
@@ -72,6 +72,28 @@ spec:
- port: "3000"
protocol: TCP
---
# Secret rotation: the ha-config-sync rotator mints ha-config-sync's PAT. Egress
# half: rotator-ha-config-sync-egress in network-policies/openbao-ns.yaml.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: gitea-allow-ha-config-sync-rotator
namespace: gitea
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: gitea
ingress:
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": openbao
app.kubernetes.io/component: secret-rotator
app.kubernetes.io/name: ha-config-sync
toPorts:
- ports:
- port: "3000"
protocol: TCP
---
# Package retention CronJob (package-retention.yaml, REN-1203). It lists pods
# to find the running image tags. The namespace baseline does not open the API
# server: `kube-apiserver` is its own entity in Cilium; port is the post-DNAT
@@ -61,7 +61,7 @@ spec:
type: RuntimeDefault
containers:
- name: shelly-manager
image: gitea.renner.uno/k.renner/shelly-manager:build.1791777553 # {"$imagepolicy": "flux-system:shelly-manager"}
image: gitea.renner.uno/k.renner/shelly-manager:build.1792387459 # {"$imagepolicy": "flux-system:shelly-manager"}
imagePullPolicy: IfNotPresent
ports:
- name: http
@@ -0,0 +1,19 @@
# Gitea token of the ha-config-sync account for the config-sync sidecar: push
# `live`, open the live -> main PR. Rotated by openbao/ha-config-sync-secret-rotate.
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: home-assistant-config-sync-gitea
namespace: home-assistant
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: openbao
target:
name: home-assistant-config-sync-gitea
data:
- secretKey: token
remoteRef:
key: infrastructure/ha-config-sync
property: token
+32 -11
View File
@@ -1,8 +1,7 @@
#!/bin/sh
# Home Assistant config-sync sidecar — TWO-WAY.
#
# /config is a git checkout of gitea.renner.uno/k.renner/homeassistant on an RWO
# PVC. This loop keeps the tracked HA config paths in sync with the repo in BOTH
# /config is a git checkout of $GITEA_URL/$GITEA_REPO on an RWO PVC. This loop keeps the tracked HA config paths in sync with the repo in BOTH
# directions:
#
# push — changes made inside HA (code-server edits, HA UI automation/script
@@ -15,8 +14,11 @@
# files at runtime; two-way merge handles that fine — the drift is committed to
# `live` (PR'd) and merges cleanly, so it no longer needs excluding. Untracked
# runtime state (.storage/, *.db, secrets.yaml, tts/, deps/, ...) is still left
# alone (it isn't tracked). git auth uses the PAT embedded in the PVC's remote
# URL (also reused for the gitea API to open the PR); reload auth uses $HA_TOKEN.
# alone (it isn't tracked). Git and the gitea API authenticate as $GITEA_USER
# with the token in $GITEA_TOKEN_FILE, re-read every loop and passed to git
# through the environment, so it is never written to the PVC's .git/config.
# The remote URL is reset from $GITEA_URL/$GITEA_REPO on every start. Reload auth
# uses $HA_TOKEN.
#
# Conflicts are left to git: a merge that conflicts is ABORTED (never written
# into the live files, which would break HA) and logged for you to resolve in the
@@ -30,6 +32,12 @@ MAIN="${SYNC_BRANCH:-main}"
LIVE="${SYNC_LIVE_BRANCH:-live}"
INTERVAL="${SYNC_INTERVAL:-30}"
HA_URL="${HA_URL:-http://127.0.0.1:8123}"
GITEA_URL="${GITEA_URL:?GITEA_URL is not set}"
GITEA_REPO="${GITEA_REPO:?GITEA_REPO is not set}"
GITEA_USER="${GITEA_USER:?GITEA_USER is not set}"
GITEA_TOKEN_FILE="${GITEA_TOKEN_FILE:-/run/gitea/token}"
API="${GITEA_URL}/api/v1/repos/${GITEA_REPO}"
GITEA_TOKEN=""
# Tracked HA config paths synced in both directions. www/ is included in full
# (bespoke cards AND HACS-managed assets) — HACS runtime rewrites are captured to
@@ -85,6 +93,21 @@ reload_lovelace() {
fi
}
# Read the (rotated) token and hand it to git as an HTTP header through the
# environment (GIT_CONFIG_*), so it never lands in .git/config on the PVC.
load_auth() {
GITEA_TOKEN="$(cat "${GITEA_TOKEN_FILE}" 2>/dev/null || true)"
if [ -z "${GITEA_TOKEN}" ]; then
log "WARN no token in ${GITEA_TOKEN_FILE}; push, fetch and PRs will fail"
unset GIT_CONFIG_COUNT GIT_CONFIG_KEY_0 GIT_CONFIG_VALUE_0
return
fi
GIT_CONFIG_COUNT=1
GIT_CONFIG_KEY_0=http.extraHeader
GIT_CONFIG_VALUE_0="Authorization: Basic $(printf '%s:%s' "${GITEA_USER}" "${GITEA_TOKEN}" | base64 | tr -d '\n')"
export GIT_CONFIG_COUNT GIT_CONFIG_KEY_0 GIT_CONFIG_VALUE_0
}
# Open a PR live -> main if one isn't already open. gitea returns 422 when a PR
# for this head already exists (or there's no diff); ignore it.
ensure_pr() {
@@ -109,13 +132,9 @@ git config --global pack.windowMemory 32m 2>/dev/null || true
git config --global pack.deltaCacheSize 32m 2>/dev/null || true
git config --global core.deltaBaseCacheLimit 16m 2>/dev/null || true
# Derive gitea API base + token from the remote URL (https://user:TOKEN@host/owner/repo.git).
REMOTE_URL="$(git config --get remote.origin.url 2>/dev/null || echo '')"
GITEA_TOKEN="$(printf '%s' "${REMOTE_URL}" | sed -n 's#^https://[^:]*:\([^@]*\)@.*#\1#p')"
GITEA_HOST="$(printf '%s' "${REMOTE_URL}" | sed -n 's#^https://[^@]*@\([^/]*\)/.*#\1#p')"
REPO_PATH="$(printf '%s' "${REMOTE_URL}" | sed -n 's#^https://[^@]*@[^/]*/\(.*\)\.git$#\1#p')"
API="https://${GITEA_HOST}/api/v1/repos/${REPO_PATH}"
[ -z "${GITEA_TOKEN}" ] && log "WARN could not derive gitea token from remote URL; PRs won't be opened"
# The remote is declared by the manifest, without credentials.
git remote set-url origin "${GITEA_URL}/${GITEA_REPO}.git"
log "remote origin = ${GITEA_URL}/${GITEA_REPO}.git (user ${GITEA_USER})"
# Put HEAD on the live branch without disturbing the working tree (live == current
# HEAD on first run; persisted across restarts on the PVC).
@@ -126,6 +145,8 @@ git symbolic-ref HEAD "refs/heads/${LIVE}" 2>/dev/null || true
FIRST=1
while true; do
load_auth
# 1. PUSH: capture live edits to the tracked config paths onto `live`.
# shellcheck disable=SC2086
if [ -n "$(git status --porcelain -- ${PATHS} 2>/dev/null)" ]; then
+23 -5
View File
@@ -159,7 +159,7 @@ spec:
# substitution to POD_IP so nginx-runtime variables like $host
# / $http_x_forwarded_for in the template stay intact.
- name: proxy
image: nginxinc/nginx-unprivileged:1.31-alpine@sha256:b9241c6e7b8e9a862f129d8d4199ab64b10390949a78bdd5603379b32c844083
image: nginxinc/nginx-unprivileged:1.31-alpine@sha256:378d0c32f6562f7fd9629170797f57d2b953aa09be983b3daaf4809bb039ca00
env:
- name: POD_IP
valueFrom:
@@ -203,7 +203,7 @@ spec:
# readiness probe on purpose: a broker outage must not take HA out of
# its Service endpoints.
- name: mqtt-tls
image: ghostunnel/ghostunnel:v1.11.3@sha256:2599b8a04bae16d70a4209495618dea61b46a00a9a05fcf74da282688ad3517f
image: ghostunnel/ghostunnel:v1.11.4@sha256:e5248233863aadd36383a11c92360f2981141881ef05e38360a6ada8630d36b1
args:
- client
- --listen=127.0.0.1:11883
@@ -298,11 +298,13 @@ spec:
# force-syncs only the HA YAML config paths from origin/main of the
# gitea repo into the working tree, then calls homeassistant.reload_all.
# Leaves www/ (HACS-managed) and untracked runtime state (.storage,
# *.db, secrets.yaml) untouched. git auth uses the PAT already embedded
# in the PVC's remote URL; reload auth uses a dedicated long-lived token.
# *.db, secrets.yaml) untouched. Git and the Gitea API authenticate as
# ha-config-sync with the token file from home-assistant-config-sync-gitea
# (re-read every loop, so a rotation needs no restart); reload auth uses a
# 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:ce84fd6a057b4dc5cf5009da2dd832f1c6d096c1984bf50496cf3c256402264d
image: alpine/git:2.54.0@sha256:03228d7ded483b0b3e55e1de7fa660d70d3e1bb432fa7021d29e192640de11aa
command: ["/bin/sh", "/scripts/sync.sh"]
env:
- name: SYNC_BRANCH
@@ -316,12 +318,23 @@ spec:
secretKeyRef:
name: home-assistant-config-sync-token
key: token
- name: GITEA_URL
value: https://gitea.renner.uno
- name: GITEA_REPO
value: konairius/homeassistant
- name: GITEA_USER
value: ha-config-sync
- name: GITEA_TOKEN_FILE
value: /run/gitea/token
volumeMounts:
- name: config
mountPath: /config
- name: config-sync-script
mountPath: /scripts
readOnly: true
- name: config-sync-gitea
mountPath: /run/gitea
readOnly: true
# ~1.1 GB .git (a 1 GB backup tar in history); auto-gc is disabled in
# the script so it never repacks. fetch/checkout of small config blobs
# peak well under this; the headroom covers index-pack on first fetch.
@@ -343,6 +356,11 @@ spec:
configMap:
name: ha-config-sync-script
defaultMode: 0755
# Not subPath, so a rotated token lands in place.
- name: config-sync-gitea
secret:
secretName: home-assistant-config-sync-gitea
defaultMode: 0400
- name: proxy-config
configMap:
name: ha-proxy-config
@@ -10,6 +10,7 @@ resources:
- vscode-ingress.yaml
- prometheus-token-externalsecret.yaml
- config-sync-token-externalsecret.yaml
- config-sync-gitea-externalsecret.yaml
- mosquitto-bootstrap.yaml
# Former `health` probe credential. The broker no longer reads it (the
# probes use the cert account mosquitto-probe, REN-953); removed with its
@@ -0,0 +1,296 @@
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.23.0
kustomize.toolkit.fluxcd.io/prune: disabled
name: kumamonitors.uptime.renner.uno
spec:
group: uptime.renner.uno
names:
kind: KumaMonitor
listKind: KumaMonitorList
plural: kumamonitors
singular: kumamonitor
scope: Namespaced
versions:
- additionalPrinterColumns:
- jsonPath: .spec.type
name: Type
type: string
- jsonPath: .status.conditions[?(@.type=="Ready")].status
name: Ready
type: string
name: v1alpha1
schema:
openAPIV3Schema:
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:
description:
type: string
displayName:
minLength: 1
type: string
dns:
properties:
hostname:
minLength: 1
type: string
port:
default: 53
format: int32
maximum: 65535
minimum: 1
type: integer
resolveServer:
default: 1.1.1.1
type: string
resolveType:
enum:
- A
- AAAA
- CNAME
- MX
- NS
- PTR
- SOA
- SRV
- TXT
- CAA
type: string
required:
- hostname
- resolveType
type: object
http:
description: HTTPSpec configures http, keyword and json-query monitors.
properties:
acceptedStatusCodes:
default:
- 200-299
items:
type: string
type: array
body:
type: string
expiryNotification:
type: boolean
headers:
additionalProperties:
type: string
type: object
ignoreTLS:
type: boolean
maxRedirects:
format: int32
type: integer
method:
default: GET
type: string
url:
minLength: 1
type: string
required:
- url
type: object
interval:
default: 60
description: Seconds.
format: int32
minimum: 20
type: integer
jsonQuery:
properties:
expected:
type: string
expression:
description: JSONata expression.
minLength: 1
type: string
operator:
enum:
- ==
- '!='
- <
- <=
- '>'
- '>='
- contains
type: string
required:
- expected
- expression
- operator
type: object
keyword:
properties:
invert:
type: boolean
keyword:
minLength: 1
type: string
required:
- keyword
type: object
maxRetries:
default: 0
format: int32
minimum: 0
type: integer
parentRef:
description: LocalRef references a KumaMonitor in the same namespace.
properties:
name:
type: string
required:
- name
type: object
paused:
type: boolean
port:
properties:
hostname:
minLength: 1
type: string
port:
format: int32
maximum: 65535
minimum: 1
type: integer
required:
- hostname
- port
type: object
retryInterval:
default: 60
description: Seconds.
format: int32
minimum: 20
type: integer
timeout:
description: Seconds.
format: int32
minimum: 1
type: integer
type:
enum:
- group
- http
- keyword
- json-query
- dns
- port
type: string
required:
- displayName
- type
type: object
x-kubernetes-validations:
- message: http is required for types http, keyword and json-query, and
forbidden otherwise
rule: (self.type in ['http','keyword','json-query']) == has(self.http)
- message: keyword is required for type keyword and forbidden otherwise
rule: (self.type == 'keyword') == has(self.keyword)
- message: jsonQuery is required for type json-query and forbidden otherwise
rule: (self.type == 'json-query') == has(self.jsonQuery)
- message: dns is required for type dns and forbidden otherwise
rule: (self.type == 'dns') == has(self.dns)
- message: port is required for type port and forbidden otherwise
rule: (self.type == 'port') == has(self.port)
- message: type group takes no type blocks
rule: self.type != 'group' || !(has(self.http) || has(self.keyword)
|| has(self.jsonQuery) || has(self.dns) || has(self.port))
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
monitorID:
format: int64
type: integer
observedGeneration:
format: int64
type: integer
type: object
required:
- spec
type: object
served: true
storage: true
subresources:
status: {}
+72
View File
@@ -0,0 +1,72 @@
---
# kuma-sync: KumaMonitor -> Uptime Kuma monitors on kuma.renner.uno (ci/images/kuma-sync).
apiVersion: apps/v1
kind: Deployment
metadata:
name: kuma-sync
namespace: kuma-sync
labels:
app.kubernetes.io/name: kuma-sync
annotations:
secret.reloader.stakater.com/reload: kuma-sync-kuma
spec:
replicas: 1
# One writer against Kuma at a time; the controller runs without leader election.
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: kuma-sync
template:
metadata:
labels:
app.kubernetes.io/name: kuma-sync
spec:
serviceAccountName: kuma-sync
securityContext:
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
seccompProfile:
type: RuntimeDefault
containers:
- name: controller
image: gitea.renner.uno/gitea_admin/kuma-sync:2026-10-19-0545 # {"$imagepolicy": "flux-system:kuma-sync"}
env:
- name: KUMA_URL
value: https://kuma.renner.uno
- name: KUMA_USERNAME
valueFrom:
secretKeyRef:
name: kuma-sync-kuma
key: username
- name: KUMA_PASSWORD
valueFrom:
secretKeyRef:
name: kuma-sync-kuma
key: password
ports:
- name: metrics
containerPort: 8080
- name: probes
containerPort: 8081
livenessProbe:
httpGet:
path: /healthz
port: probes
readinessProbe:
httpGet:
path: /readyz
port: probes
resources:
requests:
cpu: 10m
memory: 64Mi
limits:
memory: 256Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
terminationGracePeriodSeconds: 30
@@ -0,0 +1,23 @@
---
# Uptime Kuma login for kuma-sync; OpenBao homelab/infrastructure/kuma-sync.
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: kuma-sync-kuma
namespace: kuma-sync
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: openbao
target:
name: kuma-sync-kuma
data:
- secretKey: username
remoteRef:
key: infrastructure/kuma-sync
property: username
- secretKey: password
remoteRef:
key: infrastructure/kuma-sync
property: password
@@ -0,0 +1,7 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- crd-kumamonitors.yaml
- externalsecret.yaml
- rbac.yaml
- deployment.yaml
+43
View File
@@ -0,0 +1,43 @@
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: kuma-sync
namespace: kuma-sync
labels:
app.kubernetes.io/name: kuma-sync
---
# Cluster-wide: KumaMonitor objects live in every app namespace.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: kuma-sync
rules:
- apiGroups: ["uptime.renner.uno"]
resources: ["kumamonitors"]
verbs: ["get", "list", "watch", "update", "patch"]
- apiGroups: ["uptime.renner.uno"]
resources: ["kumamonitors/status"]
verbs: ["get", "update", "patch"]
- apiGroups: ["uptime.renner.uno"]
resources: ["kumamonitors/finalizers"]
verbs: ["update"]
- apiGroups: [""]
resources: ["events"]
verbs: ["create", "patch"]
- apiGroups: [""]
resources: ["namespaces"]
verbs: ["get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: kuma-sync
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: kuma-sync
subjects:
- kind: ServiceAccount
name: kuma-sync
namespace: kuma-sync
@@ -10,6 +10,9 @@ resources:
- prometheusrule-cilium.yaml
- prometheusrule-disk-wear.yaml
- prometheusrule-hetzner-backup.yaml
# Replaces the chart's KubeJobFailed/KubeContainerWaiting (disabled in
# apps/monitoring/helmrelease-prometheus.yaml).
- prometheusrule-jobs.yaml
- prometheusrule-loki.yaml
- prometheusrule-mariadb-backup.yaml
- prometheusrule-netbird-availability.yaml
@@ -0,0 +1,61 @@
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: jobs
namespace: monitoring
labels:
# kube-prometheus-stack's Prometheus selects on this label.
release: kube-prometheus-stack
spec:
groups:
- name: jobs.rules
rules:
# Maps each Job to the series of runs it belongs to: its owning
# CronJob, or its Velero repository for kopia maintenance Jobs.
- record: job_name:kube_job_series:info
expr: |
max by (namespace, job_name, series) (
label_replace(kube_job_owner{job="kube-state-metrics", owner_kind="CronJob"}, "series", "$1", "owner_name", "(.+)")
or
label_replace(kube_job_labels{job="kube-state-metrics", label_velero_io_repo_name!=""}, "series", "$1", "label_velero_io_repo_name", "(.+)")
)
- alert: KubeJobFailed
# A failed Job resolves once a later run of the same series succeeds.
expr: |
kube_job_failed{job="kube-state-metrics", condition="true"} > 0
unless on(namespace, job_name)
(
(
kube_job_status_start_time{job="kube-state-metrics"}
* on(namespace, job_name) group_left(series)
job_name:kube_job_series:info
)
< on(namespace, series) group_left()
max by (namespace, series) (
kube_job_status_completion_time{job="kube-state-metrics"}
* on(namespace, job_name) group_left(series)
job_name:kube_job_series:info
)
)
for: 15m
labels:
severity: warning
annotations:
summary: Job failed to complete.
description: "Job {{ $labels.namespace }}/{{ $labels.job_name }} failed and no later run of the same CronJob or Velero repository has succeeded."
runbook_url: https://runbooks.prometheus-operator.dev/runbooks/kubernetes/kubejobfailed
- alert: KubeContainerWaiting
# Containers of Failed/Succeeded pods never start; KubeJobFailed covers those.
expr: |
kube_pod_container_status_waiting_reason{job="kube-state-metrics", reason!="CrashLoopBackOff"} > 0
unless on(namespace, pod)
(kube_pod_status_phase{job="kube-state-metrics", phase=~"Failed|Succeeded"} == 1)
for: 1h
labels:
severity: warning
annotations:
summary: Pod container waiting longer than 1 hour
description: 'pod/{{ $labels.pod }} in namespace {{ $labels.namespace }} on container {{ $labels.container }} has been in waiting state for longer than 1 hour. (reason: "{{ $labels.reason }}")'
runbook_url: https://runbooks.prometheus-operator.dev/runbooks/kubernetes/kubecontainerwaiting
@@ -317,9 +317,7 @@ spec:
- matchers:
- alertname = "InfoInhibitor"
receiver: 'null'
# Flux notification-controller posts info-severity events
# here (e.g. image-automation commits). Keep them visible
# but don't repeat-spam every 12h on the same event.
# Flux notification-controller error events (flux-failures Alert).
- matchers:
- source = "flux"
receiver: email
@@ -369,6 +367,10 @@ spec:
limits:
memory: 256Mi
# Groups Velero maintenance Jobs per repository in prometheusrule-jobs.yaml.
metricLabelsAllowlist:
- jobs=[velero.io/repo-name]
# --- Flux per-object readiness (REN-102) ---------------------------------
#
# WHY THIS IS HERE AND NOT ON THE FLUX CONTROLLERS. `gotk_reconcile_condition`
@@ -703,3 +705,9 @@ spec:
enabled: false
kubeProxy:
enabled: false
# Replaced by monitoring-rules/prometheusrule-jobs.yaml.
defaultRules:
disabled:
KubeJobFailed: true
KubeContainerWaiting: true
+1 -1
View File
@@ -43,7 +43,7 @@ spec:
mountPath: /consume
containers:
- name: vsftpd
image: gitea.renner.uno/k.renner/vsftpd:build.1789630783 # {"$imagepolicy": "flux-system:vsftpd"}
image: gitea.renner.uno/konairius/vsftpd:build.1792310564 # {"$imagepolicy": "flux-system:vsftpd"}
ports:
- name: ftp-ctrl
containerPort: 21
+1 -1
View File
@@ -4,7 +4,7 @@
# This is the configuration of the RUNNER only: where Gitea is, and which
# repositories to process. What Renovate does INSIDE a repository — schedule,
# grouping, commit titles, digest pinning — lives in the shared preset repository
# `k.renner/renovate-config`, which each repository's renovate.json extends. Change
# `konairius/renovate-config`, which each repository's renovate.json extends. Change
# update policy there, not here.
#
# autodiscover + requireConfig=required + onboarding=false
+1 -1
View File
@@ -2,7 +2,7 @@
# Renovate runner (REN-216).
#
# RUNS DAILY, BUT OPENS ROUTINE PULL REQUESTS ON MONDAY ONLY. The weekly rhythm
# is enforced by the `schedule` in the shared preset (k.renner/renovate-config),
# is enforced by the `schedule` in the shared preset (konairius/renovate-config),
# not by this cron line. The daily run exists for one reason: a security update
# (`vulnerabilityAlerts`, which the preset leaves unscheduled) must not wait up
# to six days. On Tuesday to Sunday a run finds nothing in schedule, opens and
+2 -2
View File
@@ -32,10 +32,10 @@ spec:
type: RuntimeDefault
containers:
- name: review-bot
image: gitea.renner.uno/gitea_admin/review-bot:2026-10-18-0612 # {"$imagepolicy": "flux-system:review-bot"}
image: gitea.renner.uno/gitea_admin/review-bot:2026-10-19-0540 # {"$imagepolicy": "flux-system:review-bot"}
env:
- name: REPOS
value: k.renner/homelab,k.renner/homeassistant,k.renner/authentik-samba-bridge,konairius/shelly-manager
value: k.renner/homelab,konairius/homeassistant,konairius/authentik-samba-bridge,konairius/shelly-manager
- name: GITEA_URL
value: https://gitea.renner.uno
- name: AGENT_NAME
+1 -1
View File
@@ -67,7 +67,7 @@ spec:
- name: Wiki__NoteStore__Gitea__BaseUrl
value: https://gitea.renner.uno
- name: Wiki__NoteStore__Gitea__Repository
value: k.renner/notes
value: konairius/notes
- name: Wiki__NoteStore__Gitea__Branch
value: main
- name: Wiki__Embeddings__Models__bge-m3__Url
@@ -0,0 +1,25 @@
# kuma-sync controller: KumaMonitor -> Uptime Kuma monitors. Ships no KumaMonitor objects.
# healthCheckExprs: Flux has no status reader for ExternalSecret; fail only on the non-retrying reasons.
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: kuma-sync
namespace: flux-system
spec:
interval: 10m
retryInterval: 2m
timeout: 5m
sourceRef:
kind: GitRepository
name: flux-system
path: ./kubernetes/apps/kuma-sync
prune: true
wait: true
healthCheckExprs:
- apiVersion: external-secrets.io/v1
kind: ExternalSecret
failed: "has(status.conditions) && status.conditions.exists(c, c.type == 'Ready' && c.status == 'False' && (c.reason == 'SecretOwnedByOther' || c.reason == 'SecretImmutable'))"
current: "has(status.conditions) && status.conditions.exists(c, c.type == 'Ready' && c.status == 'True')"
dependsOn:
- name: infrastructure
- name: infrastructure-crds
@@ -1,25 +1,3 @@
# Image-automation commits: informational events from the IUA when it
# actually writes a bump commit. The 30-minute "no change since last
# reconciliation" heartbeat is filtered out via exclusionList so we
# don't email-spam every IUA pass that did nothing.
apiVersion: notification.toolkit.fluxcd.io/v1beta3
kind: Alert
metadata:
name: flux-image-automation
namespace: flux-system
spec:
providerRef:
name: alertmanager
eventSeverity: info
eventSources:
- kind: ImageUpdateAutomation
name: '*'
exclusionList:
- 'no change since last reconciliation'
eventMetadata:
source: flux
category: image-automation
---
# Failures across Flux: any error-severity event from a source/sync
# resource. Sends a single alert per error so cluster reconcile breakage
# surfaces in email regardless of which controller emitted it.
@@ -44,6 +44,7 @@ existing `flux-system` GitRepository whose Secret was created by
| netbird-ingress | `0.x` | `-rootless` suffix filter; pre-1.0, bounded to the `0.x` major |
| wyoming-{whisper,openwakeword,speech-to-phrase} | `3.x` / `2.x` / `1.x` | |
| authentik-gate-sync | `YYYY-MM-DD-HHMM` | In-house (build-ci-images.yml); newest tag wins |
| kuma-sync | `YYYY-MM-DD-HHMM` | In-house (build-ci-images.yml); newest tag wins |
| gatus | `5.x` | v-prefix filter |
| velero-plugin-for-aws | `1.13.x` | Tight: must match velero server major.minor |
| gotenberg | `8.x` | Plain-variant filter (rejects `-libreoffice`/`-chromium`/`-cloudrun`/`-aws-lambda`) |
@@ -4,7 +4,7 @@ metadata:
name: gitea-mcp
namespace: flux-system
spec:
image: docker.gitea.com/gitea-mcp-server
image: docker.gitea.com/gitea-mcp-server@sha256:20701c3dcf3b71df384417d04f9f6382205b7f362380b258fb8e4dcf9599091f
interval: 1h
---
apiVersion: image.toolkit.fluxcd.io/v1
@@ -0,0 +1,23 @@
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImageRepository
metadata:
name: kuma-sync
namespace: flux-system
spec:
image: gitea.renner.uno/gitea_admin/kuma-sync
interval: 30m
---
# build-ci-images.yml tags each build YYYY-MM-DD-HHMM; newest wins.
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImagePolicy
metadata:
name: kuma-sync
namespace: flux-system
spec:
imageRepositoryRef:
name: kuma-sync
filterTags:
pattern: '^\d{4}-\d{2}-\d{2}-\d{4}$'
policy:
alphabetical:
order: asc
@@ -12,6 +12,7 @@ resources:
- gotenberg.yaml
- home-assistant.yaml
- immich.yaml
- kuma-sync.yaml
- lidarr.yaml
- matter-server.yaml
- mmwave-configurator.yaml
@@ -4,7 +4,7 @@ metadata:
name: vsftpd
namespace: flux-system
spec:
image: gitea.renner.uno/k.renner/vsftpd
image: gitea.renner.uno/konairius/vsftpd
interval: 30m
---
# CI publishes `build.<unix-ts>` alongside SHA and `main` tags so the
@@ -288,3 +288,9 @@ apiVersion: v1
kind: Namespace
metadata:
name: renovate
---
# kuma-sync controller; declared here because it has a CiliumNetworkPolicy applied by infrastructure-crds.
apiVersion: v1
kind: Namespace
metadata:
name: kuma-sync
@@ -0,0 +1,38 @@
# Baseline policy for the `kuma-sync` namespace: egress to kube-dns, kube-apiserver and kuma.renner.uno:443; ingress only kubelet probes.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: default-deny
namespace: kuma-sync
spec:
endpointSelector: {}
ingress:
- fromEntities:
- host
toPorts:
- ports:
- port: "8081"
protocol: TCP
egress:
# rules.dns lets Cilium learn the toFQDNs mapping
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": kube-system
k8s-app: kube-dns
toPorts:
- ports:
- port: "53"
protocol: UDP
- port: "53"
protocol: TCP
rules:
dns:
- matchPattern: "*"
- toEntities:
- kube-apiserver
- toFQDNs:
- matchName: "kuma.renner.uno"
toPorts:
- ports:
- port: "443"
protocol: TCP
@@ -32,6 +32,7 @@ resources:
- ai-ns.yaml
- navidrome-ns.yaml
- uptime-monitor-ns.yaml
- kuma-sync-ns.yaml
- renovate-ns.yaml
- allow-cilium-health.yaml
- kube-system-hubble-relay.yaml
@@ -212,6 +212,28 @@ spec:
- port: "8000"
protocol: TCP
---
# Egress for the ha-config-sync rotator: gitea :3000 (mint). Complements
# gitea-allow-ha-config-sync-rotator.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: rotator-ha-config-sync-egress
namespace: openbao
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/component: secret-rotator
app.kubernetes.io/name: ha-config-sync
egress:
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": gitea
app.kubernetes.io/name: gitea
toPorts:
- ports:
- port: "3000"
protocol: TCP
---
# Egress for the claude-oauth sync rotator: woodpecker server only (woodpecker :8000).
# It never touches gitea (the OAuth value is seeded into OpenBao by hand, not
# minted). Complements woodpecker-allow-rotators (woodpecker side).
@@ -24,7 +24,7 @@ spec:
# window. The StatefulSet rolls one pod at a time (updateStrategyType
# below). Undo after a pod has run the new server = restore the
# snapshot, not a git revert.
version: ">=0.30.3 <0.31.0"
version: ">=0.30.4 <0.31.0"
sourceRef:
kind: HelmRepository
name: openbao
@@ -0,0 +1,28 @@
# OpenBao policy: rotator-ha-config-sync
#
# Rotates the `ha-config-sync` Gitea PAT (write:repository) used by the Home
# Assistant config-sync sidecar (rotation/ha-config-sync.yaml).
#
# NOT applied by Flux. Apply ONCE with an admin token:
#
# bao policy write rotator-ha-config-sync kubernetes/infrastructure/openbao/policies/rotator-ha-config-sync.hcl
# bao write auth/kubernetes/role/rotator-ha-config-sync \
# bound_service_account_names=ha-config-sync-secret-rotator \
# bound_service_account_namespaces=openbao \
# policies=rotator-ha-config-sync ttl=15m
#
# BOOTSTRAPS (by hand):
# bao kv put homelab/infrastructure/secret-rotators-bootstrap/ha-config-sync \
# username=ha-config-sync password=<ha-config-sync account password>
# bao kv put homelab/infrastructure/ha-config-sync token=placeholder
#
# Do NOT set token_no_default_policy=true (kv-v2 preflight needs the default policy).
# ha-config-sync's password (mint bootstrap)
path "homelab/data/infrastructure/secret-rotators-bootstrap/ha-config-sync" {
capabilities = ["read"]
}
# the rotated ha-config-sync PAT
path "homelab/data/infrastructure/ha-config-sync" {
capabilities = ["read", "patch"]
}
@@ -0,0 +1,10 @@
# OpenBao policy: uptime-kuma-vps
# Read-only on the Uptime Kuma admin credentials; self-renewal for the CI token. NOT applied by Flux.
path "homelab/data/infrastructure/uptime-kuma" {
capabilities = ["read"]
}
path "auth/token/renew-self" {
capabilities = ["update"]
}
@@ -0,0 +1,89 @@
---
# Rotate the `ha-config-sync` Gitea PAT (write:repository). ha-config-sync is a
# write collaborator on the homeassistant repo; the config-sync sidecar
# (apps/home-assistant) pushes the `live` branch and opens the live -> main PR
# with it. ESO syncs the token into Secret home-assistant-config-sync-gitea.
#
# `rotate-mint` — secret-rotator gitea-token: mint the PAT, verify it can pull
# AND push homeassistant, store it at infrastructure/ha-config-sync#token,
# reap old tokens keeping the two newest.
#
# Policy/role rotator-ha-config-sync and the bootstraps are provisioned by hand —
# see policies/rotator-ha-config-sync.hcl. Netpol halves:
# gitea-allow-ha-config-sync-rotator, rotator-ha-config-sync-egress.
apiVersion: v1
kind: ServiceAccount
metadata:
name: ha-config-sync-secret-rotator
namespace: openbao
labels:
app.kubernetes.io/name: ha-config-sync
app.kubernetes.io/component: secret-rotator
---
apiVersion: batch/v1
kind: CronJob
metadata:
name: ha-config-sync-secret-rotate
namespace: openbao
labels:
app.kubernetes.io/name: ha-config-sync
app.kubernetes.io/component: secret-rotator
spec:
suspend: false
schedule: "30 4 1 1,4,7,10 *" # quarterly, Europe/Berlin
timeZone: Europe/Berlin
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 2
failedJobsHistoryLimit: 5
startingDeadlineSeconds: 3600
jobTemplate:
spec:
backoffLimit: 1
activeDeadlineSeconds: 300
ttlSecondsAfterFinished: 259200 # 3 d
template:
metadata:
labels:
app.kubernetes.io/name: ha-config-sync
app.kubernetes.io/component: secret-rotator
spec:
serviceAccountName: ha-config-sync-secret-rotator
restartPolicy: Never
securityContext:
runAsNonRoot: true
runAsUser: 65534
runAsGroup: 65534
seccompProfile:
type: RuntimeDefault
containers:
- name: rotate-mint
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-07-30
args: ["gitea-token"]
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
env:
- name: BAO_ADDR
value: http://openbao.openbao.svc.cluster.local:8200
- name: BAO_ROLE
value: rotator-ha-config-sync
- name: BOOTSTRAP_PATH
value: homelab/infrastructure/secret-rotators-bootstrap/ha-config-sync
- name: BAO_PATH
value: homelab/infrastructure/ha-config-sync
- name: GITEA_URL
value: http://gitea.gitea.svc.cluster.local:3000
- name: GITEA_VERIFY_REPO
value: konairius/homeassistant
- name: GITEA_SCOPES
value: write:repository
- name: GITEA_TOKEN_NAME_PREFIX
value: ha-config-sync-rotated
resources:
requests:
cpu: 25m
memory: 16Mi
limits:
memory: 48Mi
@@ -45,6 +45,7 @@ resources:
- external-dns.yaml
- flux-git.yaml
- home-assistant.yaml
- ha-config-sync.yaml
- registry-bot.yaml
- review-bot.yaml
- claude-oauth.yaml
@@ -1,5 +1,5 @@
---
# Rotate the `registry-bot` Gitea PAT (scope write:package) and sync it into the
# Rotate the `registry-bot` Gitea PAT (write:package, read:repository) and sync it into the
# konairius org's Woodpecker secret `registry_bot_token`. registry-bot is in the
# konairius team `publishers` (packages write, code read); every konairius release
# pipeline publishes images to gitea.renner.uno/konairius/* with it.
@@ -81,8 +81,9 @@ spec:
# Private konairius repo the publishers team can read.
- name: GITEA_VERIFY_REPO
value: konairius/vsftpd
# read:repository: the pull check needs it.
- name: GITEA_SCOPES
value: write:package
value: write:package,read:repository
- name: GITEA_REQUIRE_PUSH
value: "false"
- name: GITEA_TOKEN_NAME_PREFIX
@@ -27,6 +27,7 @@ spec:
- immich
- influxdb
- ingress
- kuma-sync # kuma-sync-kuma (apps/kuma-sync/externalsecret.yaml)
- matrix # synapse-secrets, mx-pg barman creds (apps/matrix)
- monitoring
- paperless
@@ -32,7 +32,7 @@ spec:
type: RuntimeDefault
containers:
- name: backup-verify
image: gitea.renner.uno/gitea_admin/ci-backup-verify:2026-10-15 # {"$imagepolicy": "flux-system:backup-verify"}
image: gitea.renner.uno/gitea_admin/ci-backup-verify:2026-10-19 # {"$imagepolicy": "flux-system:backup-verify"}
command: ["bash", "/script/backup-verify.sh"]
env:
- name: HOME
+1 -1
View File
@@ -11,7 +11,7 @@ spec:
restartPolicy: Never
containers:
- name: check
image: docker.io/nicolaka/netshoot:v0.13
image: docker.io/nicolaka/netshoot:v0.13@sha256:a20c2531bf35436ed3766cd6cfe89d352b050ccc4d7005ce6400adf97503da1b
command:
- /bin/bash
- -c
+2 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"local>k.renner/renovate-config"
"local>konairius/renovate-config"
],
"enabledManagers": [
"flux",
@@ -45,7 +45,7 @@
"ghcr.io/usetrmnl/terminus",
"ghcr.io/usetrmnl/trmnl-ha-amd64",
"gitea.renner.uno/k.renner/shelly-manager",
"gitea.renner.uno/k.renner/vsftpd",
"gitea.renner.uno/konairius/vsftpd",
"gitea.renner.uno/konairius/wiki",
"gotenberg/gotenberg",
"lscr.io/linuxserver/lidarr",