Compare commits

..
Author SHA1 Message Date
hermes-bot a4c1f31557 fix(review-bot): mint the review-bot PAT with read:issue
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
Since 9ec31f07 (PR #1503) review-bot's sweep lists the PRs that request its
review via GET /repos/issues/search?review_requested=true. Gitea requires
read:issue for that endpoint, and the rotator mints the review-bot PAT with
write:repository only, so every 5-minute sweep fails with HTTP 403 and
requested reviews are picked up only by the webhook.

- review-bot-secret-rotate: GITEA_SCOPES = write:repository,read:issue.
- One-shot Job review-bot-rotate-read-issue runs the CronJob's rotate-mint and
  sync-woodpecker steps once, so the new token is minted on merge instead of
  at the next quarterly run (2027-01-01). ESO (refresh 1h) picks it up and
  Reloader restarts review-bot. Remove the Job in a follow-up once it ran.

Closes #1505
2026-10-27 23:25:12 +00:00
200 changed files with 1190 additions and 5943 deletions
+28 -25
View File
@@ -47,7 +47,7 @@ when:
steps:
- name: build-ansible
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: ci/images/ansible
# Only rebuild when THIS image's source (or the pipeline file) changed, or
# on a manual run. Without this every step ran on any matching push, so a
@@ -74,7 +74,7 @@ steps:
--destination gitea.renner.uno/gitea_admin/ci-ansible:latest
- name: build-k8s
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: ci/images/k8s
when:
- event: deployment
@@ -97,7 +97,7 @@ steps:
--destination gitea.renner.uno/gitea_admin/ci-k8s:latest
- name: build-vmtest
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: ci/images/vmtest
when:
- event: deployment
@@ -120,7 +120,7 @@ steps:
--destination gitea.renner.uno/gitea_admin/ci-vmtest:latest
- name: build-tofu
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: ci/images/tofu
when:
- event: deployment
@@ -150,7 +150,7 @@ steps:
# Runtime image, not CI tooling: a static musl binary in a FROM-scratch image,
# used as the base for secret-rotation CronJobs. Replaces per-job inline Python.
- name: build-secret-rotator
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: secret-rotator
when:
- event: deployment
@@ -175,7 +175,7 @@ steps:
# review-bot: the Go service that reviews pull requests with a Mistral agent (webhook plus sweep).
# Runs after Gitea is up, so no ghcr mirror.
- name: build-review-bot
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: review-bot
when:
- event: deployment
@@ -225,7 +225,7 @@ steps:
#
# The gitea_admin/ copy stays until nothing references it any more.
- name: build-alpine-jq
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: ci/images/alpine-jq
when:
- event: deployment
@@ -243,8 +243,7 @@ steps:
- AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n')
- GH_AUTH=$(printf '%s:%s' "$GHCR_USER" "$GHCR_TOKEN" | base64 | tr -d '\n')
- printf '{"auths":{"gitea.renner.uno":{"auth":"%s"},"ghcr.io":{"auth":"%s"}}}' "$AUTH" "$GH_AUTH" > /kaniko/.docker/config.json
# konairius/ gets one tag per build: Gitea's cleanup rule counts tags.
- TAG=$(date +%Y-%m-%d-%H%M)
- TAG=$(date +%Y-%m-%d)
- |
/kaniko/executor \
--context . \
@@ -252,7 +251,9 @@ steps:
--destination gitea.renner.uno/gitea_admin/alpine-jq:$TAG \
--destination gitea.renner.uno/gitea_admin/alpine-jq:latest \
--destination gitea.renner.uno/konairius/alpine-jq:$TAG \
--destination ghcr.io/konairius/alpine-jq:$TAG
--destination gitea.renner.uno/konairius/alpine-jq:latest \
--destination ghcr.io/konairius/alpine-jq:$TAG \
--destination ghcr.io/konairius/alpine-jq:latest
# Runtime image, not CI tooling: upstream containernetworking/plugins binaries
# baked in, consumed by the multus cni-plugins-installer DaemonSet. Replaces a
@@ -276,7 +277,7 @@ steps:
# note on build-alpine-jq. Anything an app needs while the cluster is still
# being built cannot live only in that cluster's own registry.
- name: build-cni-plugins
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: ci/images/cni-plugins
when:
- event: deployment
@@ -294,8 +295,7 @@ steps:
- AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n')
- GH_AUTH=$(printf '%s:%s' "$GHCR_USER" "$GHCR_TOKEN" | base64 | tr -d '\n')
- printf '{"auths":{"gitea.renner.uno":{"auth":"%s"},"ghcr.io":{"auth":"%s"}}}' "$AUTH" "$GH_AUTH" > /kaniko/.docker/config.json
# konairius/ gets one tag per build: Gitea's cleanup rule counts tags.
- TAG=$(date +%Y-%m-%d-%H%M)
- TAG=$(date +%Y-%m-%d)
- |
/kaniko/executor \
--context . \
@@ -303,7 +303,9 @@ steps:
--destination gitea.renner.uno/gitea_admin/cni-plugins:$TAG \
--destination gitea.renner.uno/gitea_admin/cni-plugins:latest \
--destination gitea.renner.uno/konairius/cni-plugins:$TAG \
--destination ghcr.io/konairius/cni-plugins:$TAG
--destination gitea.renner.uno/konairius/cni-plugins:latest \
--destination ghcr.io/konairius/cni-plugins:$TAG \
--destination ghcr.io/konairius/cni-plugins:latest
# Runtime image, not CI tooling: alpine + the openssl CLI, for shell scripts
# needing s_client/x509. Named for what it is; first consumer is the paperless
@@ -316,7 +318,7 @@ steps:
# gitea.renner.uno is not there to serve it. A cold-rebuild rehearsal left
# paperless in Init:ErrImagePull on exactly this image.
- name: build-alpine-openssl
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: ci/images/alpine-openssl
when:
- event: deployment
@@ -334,8 +336,7 @@ steps:
- AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n')
- GH_AUTH=$(printf '%s:%s' "$GHCR_USER" "$GHCR_TOKEN" | base64 | tr -d '\n')
- printf '{"auths":{"gitea.renner.uno":{"auth":"%s"},"ghcr.io":{"auth":"%s"}}}' "$AUTH" "$GH_AUTH" > /kaniko/.docker/config.json
# konairius/ gets one tag per build: Gitea's cleanup rule counts tags.
- TAG=$(date +%Y-%m-%d-%H%M)
- TAG=$(date +%Y-%m-%d)
- |
/kaniko/executor \
--context . \
@@ -343,7 +344,9 @@ steps:
--destination gitea.renner.uno/gitea_admin/alpine-openssl:$TAG \
--destination gitea.renner.uno/gitea_admin/alpine-openssl:latest \
--destination gitea.renner.uno/konairius/alpine-openssl:$TAG \
--destination ghcr.io/konairius/alpine-openssl:$TAG
--destination gitea.renner.uno/konairius/alpine-openssl:latest \
--destination ghcr.io/konairius/alpine-openssl:$TAG \
--destination ghcr.io/konairius/alpine-openssl:latest
# Heating CAN readers: open3e (Vitocal/VX3) + E3onCAN (E380 meter), one image
# run as two services on edge-1. Replaces pip-into-venv on the host, where an
@@ -351,7 +354,7 @@ steps:
# Ansible converge. Not a ci-* image -- it is a runtime artifact -- but it
# builds the same way and lands in the same registry so the audit sees it.
- name: build-heating-can
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: ci/images/heating-can
when:
- event: deployment
@@ -377,7 +380,7 @@ steps:
# services (public Ingress hosts -> TLS passthrough -> Traefik ClusterIP).
# Runtime artifact like heating-can; same registry so the audit tracks it.
- name: build-netbird-service-sync
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: ci/images/netbird-service-sync
when:
- event: deployment
@@ -402,7 +405,7 @@ steps:
# authentik-gate-sync: the authentik-gate ingress class (Ingress -> Authentik
# proxy gate + Traefik route). Its unit tests run as a stage of the image build.
- name: build-authentik-gate-sync
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: ci/images/authentik-gate-sync
when:
- event: deployment
@@ -428,7 +431,7 @@ steps:
# kuma-sync: reconciles KumaMonitor resources into Uptime Kuma. Its unit
# tests run as a stage of the image build.
- name: build-kuma-sync
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: ci/images/kuma-sync
when:
- event: deployment
@@ -454,7 +457,7 @@ steps:
# cloudflare-lb-sync: reconciles CloudflareLoadBalancer resources into
# Cloudflare. Its unit tests run as a stage of the image build.
- name: build-cloudflare-lb-sync
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: ci/images/cloudflare-lb-sync
when:
- event: deployment
@@ -480,7 +483,7 @@ steps:
# bird: BGP daemon for the pod-network mesh over pod0 (#576). Runtime
# artifact like heating-can; same registry so the audit tracks it.
- name: build-bird
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: ci/images/bird
when:
- event: deployment
@@ -504,7 +507,7 @@ steps:
# Orchestrator for the monthly backup-verify CronJob (velero ns).
- name: build-backup-verify
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: ci/images/backup-verify
when:
- event: deployment
+1 -1
View File
@@ -131,7 +131,7 @@ steps:
# --build-arg to the resolved version (not "latest") guarantees the tag and
# the image content agree even if a new release lands mid-run.
- name: build
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: ci/images/claude
depends_on: [decide]
environment:
+1 -1
View File
@@ -172,7 +172,7 @@ steps:
# whenever it trails the deployed server. If you bump the server, bump here.
- name: woodpecker-lint
depends_on: [] # see note above `steps:`
image: woodpeckerci/woodpecker-cli:v3.20.1-alpine@sha256:74c576faf49cce699825d11c7d7bd38ab5f007dd4f57cdbdd55099d2c0f2621a
image: woodpeckerci/woodpecker-cli:v3.19.0-alpine@sha256:27a77117e38d7bbdc380f0e90ff3ee4871ab1eefa78b5d9136a2c969fbecd9f4
commands:
- |
set -eu
+5 -5
View File
@@ -35,25 +35,25 @@ when:
steps:
- name: model-selection-tests
depends_on: []
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.316
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
commands:
- sh scripts/ci/test-model-selection.sh
- name: pr-review-mistral-tests
depends_on: []
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.316
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
commands:
- sh scripts/ci/test-pr-review-mistral.sh
- name: promtool-rules-guard-tests
depends_on: []
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.316
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
commands:
- sh scripts/ci/test-promtool-rules.sh
- name: yaml-citations-guard-tests
depends_on: []
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.316
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
commands:
- sh scripts/ci/test-yaml-citations.sh
@@ -63,7 +63,7 @@ steps:
# bridge, and the secret guard.
- name: hermes-skill-tests
depends_on: []
image: python:3.15-alpine@sha256:20a82dbc2783112ddb91d1534603f802d222a3e63e593cd002eb2ac534e70211
image: python:3.15-alpine@sha256:d44b1254cb92329418d164b22b20d86bbd3869d48e80e09e476531a409a728bb
environment:
PYTHONDONTWRITEBYTECODE: "1"
commands:
+1 -1
View File
@@ -26,7 +26,7 @@ steps:
# fetch-models.sh response mapping. No cluster, no secrets, no network — the
# ci-claude image just provides sh + jq. Keeps the flag-injection regression
# test from rotting (it exercises the exact injection shapes).
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.316
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
commands:
- sh scripts/ci/test-model-selection.sh
+5 -4
View File
@@ -1,5 +1,5 @@
# Builds ci/images/hermes (nousresearch/hermes-agent plus the agent's CLIs) as
# gitea.renner.uno/konairius/hermes:YYYY-MM-DD-HHMM, one tag per build.
# gitea.renner.uno/konairius/hermes:build.<unix-ts> and :latest.
when:
- event: push
branch: main
@@ -10,7 +10,7 @@ when:
steps:
- name: build
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: ci/images/hermes
environment:
# registry-bot write:package PAT (konairius org secret).
@@ -20,9 +20,10 @@ steps:
- mkdir -p /kaniko/.docker
- AUTH=$(printf '%s:%s' registry-bot "$GITEA_TOKEN" | base64 | tr -d '\n')
- printf '{"auths":{"gitea.renner.uno":{"auth":"%s"}}}' "$AUTH" > /kaniko/.docker/config.json
- TAG=$(date +%Y-%m-%d-%H%M)
- TAG="build.$(date +%s)"
- |
/kaniko/executor \
--context . \
--dockerfile Containerfile \
--destination gitea.renner.uno/konairius/hermes:$TAG
--destination gitea.renner.uno/konairius/hermes:$TAG \
--destination gitea.renner.uno/konairius/hermes:latest
+1 -1
View File
@@ -6,7 +6,7 @@ when:
steps:
- name: go-test
image: golang:1.27.3-bookworm@sha256:d9b6a1beb9474adc2a1ce842573639b44b2a0dd49b6669f51d3821baf9ec37c9
image: golang:1.27.3-bookworm@sha256:972b4110ba0f47539a975c31e52c64765e1ff19f2d74469ae4a595c6676e80a5
commands:
- test -z "$(gofmt -l review-bot)"
- go -C review-bot vet ./...
+1 -1
View File
@@ -70,7 +70,7 @@ steps:
# this step is skipped — otherwise we'd fork-bomb PRs.
when:
- branch: main
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.316
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
environment:
CLAUDE_CODE_OAUTH_TOKEN:
from_secret: claude_code_oauth_token
+1 -1
View File
@@ -30,7 +30,7 @@ when:
steps:
- name: update-audit-agent
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.316
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
environment:
CLAUDE_CODE_OAUTH_TOKEN:
from_secret: claude_code_oauth_token
+1 -1
View File
@@ -8,7 +8,7 @@
# two plays over this group -- withdraw the static assignments fleet-wide, then
# hand the address to keepalived -- and both need the same value. Duplicating a
# service address across plays is exactly the sort of thing that drifts.
ntp_vip6: "fd02:40::123/128"
ntp_vip6: "fd40::123/128"
# Link-local address carried as the FIRST virtual_ipaddress of the IPv6 VRRP
# instance. RFC 5798 gives an IPv6 virtual router a link-local primary, and
@@ -1,8 +0,0 @@
---
# DSM's own python3 is 3.8; plays/configure-synology-python.yml installs this.
ansible_python_interpreter: /var/packages/Python3.9/target/usr/bin/python3.9
# DSM offers no sftp/scp.
ansible_ssh_transfer_method: piped
# claude-bot is a DSM administrator here; its password is unknown.
ssh_ca_claude_sudo_all: true
+3 -8
View File
@@ -8,6 +8,8 @@
# pve_cluster (jupiter/neptune/saturn) run Talos on bare metal; no SSH at all
# authentik_db / authentik_app authentik runs in-cluster (CNPG)
# grafana runs in-cluster
# synology_nas (nas-1) powered off; Garage and the PBS client
# it hosted are gone with it
# Their hosts no longer resolve, except the three that resolve to Talos nodes.
[netbird_server]
@@ -23,11 +25,6 @@ proxy-2.renner.uno ansible_host=proxy-2.nb.renner.uno ansible_user=root
[edge_host]
edge-1.iot.renner.uno ansible_user=root
# Synology NAS (DSM): nas-1 RS1221+ on site, nas-2 DS1522+ off site.
[synology_nas]
nas-1.renner.uno ansible_user=root
nas-2.renner.uno ansible_user=root
# Capability groups. Membership states which hardware a host physically has,
# and each service playbook targets the group for its own concern. No flags and
# no `when:` on the roles -- moving a stick to another box is a one-line change
@@ -55,7 +52,7 @@ heating_can
# THIS IS NO LONGER HIGHLY AVAILABLE. The group was jupiter (MASTER, prio 100),
# saturn (90), neptune (80) and edge-1 (BACKUP, 70); the first three are gone.
# A single VRRP member takes the address regardless of its configured state, so
# the service address is still served, but by one thin client with
# fd40::123 is still served — verified answering — but by one thin client with
# no failover behind it. install-ntp-ha.yml still carries the four-host
# ntp_servers priority map, which now describes hosts that do not exist.
#
@@ -73,8 +70,6 @@ proxy-1.renner.uno
proxy-2.renner.uno
netbird.renner.uno
edge-1.iot.renner.uno
nas-1.renner.uno
nas-2.renner.uno
# Hosts whose admin logins accept only ssh_admin_authorized_keys (break-glass);
# everything else needs a CA certificate. Members must be in [ssh_ca].
@@ -1,19 +0,0 @@
---
# Ansible needs Python >= 3.9 on the target; DSM ships 3.8. Synology's
# Python3.9 package installs beside it. raw, because the modules cannot run
# before it is there; raw is skipped in check mode.
- name: Install Python 3.9 on the Synology NAS
hosts: synology_nas
become: true
gather_facts: false
tasks:
- name: Install the Python3.9 package
ansible.builtin.raw: >-
if [ -x {{ ansible_python_interpreter }} ]; then echo present;
else /usr/syno/bin/synopkg install_from_server Python3.9; fi
register: synology_python_install
changed_when: "'present' not in synology_python_install.stdout"
- name: Check the interpreter exists
ansible.builtin.raw: test -x {{ ansible_python_interpreter }}
changed_when: false
+1 -1
View File
@@ -62,7 +62,7 @@
# One node at a time. The keepalived role withdraws the statically assigned
# IPv6 service address and restarts keepalived so the address is re-taken
# under VRRP control; doing that across every NTP server at once would leave
# the whole fleet with nobody holding the service address until the elections settle.
# the whole fleet with nobody holding fd40::123 until the elections settle.
# Serialised, the remaining members keep answering throughout.
serial: 1
@@ -4,7 +4,7 @@
# Networks allowed to use this server as NTP source.
#
# BOTH families are required. IPv6 is the primary address family here -- the
# service address ntp.srv.renner.uno resolves to fd02:40::123 -- so an IPv4-only
# service address ntp.srv.renner.uno resolves to fd40::123 -- so an IPv4-only
# allow list refuses every client that uses the documented name.
#
# The hosts carried `allow fd00::/8` for a long time without it ever being in
+6 -5
View File
@@ -8,11 +8,12 @@
lineinfile:
path: /etc/hosts
regexp: '^127\.0\.1\.1\s+{{ inventory_hostname }}'
line: "{{ ansible_facts['default_ipv4']['address'] }} {{ inventory_hostname }} {{ inventory_hostname_short | default(inventory_hostname.split('.')[0]) }}"
line: "{{ ansible_default_ipv4.address }} {{ inventory_hostname }} {{ inventory_hostname_short | default(inventory_hostname.split('.')[0]) }}"
state: present
when:
- ansible_facts['virtualization_type'] == 'lxc'
- ansible_facts['default_ipv4']['address'] is defined
- ansible_virtualization_type is defined
- ansible_virtualization_type == 'lxc'
- ansible_default_ipv4.address is defined
# -------------------------------------------------------
# 1. Install Common Packages
@@ -63,8 +64,8 @@
enabled: no
masked: yes
when:
- ansible_facts['virtualization_role'] == 'guest'
- ansible_facts['virtualization_type'] in ['kvm', 'lxc']
- ansible_virtualization_type is defined
- ansible_virtualization_type in ['kvm', 'lxc']
ignore_errors: yes
# -------------------------------------------------------
@@ -15,8 +15,6 @@
# amd64 asset for exactly this tag.
crowdsec_bouncer_version: v0.0.36
crowdsec_bouncer_sha256: f86e4b72693549d99f40a9402abefb894108f047a3fbe6e72fada25ee17ce88b
# Controller cache for the release archive; it outlives /tmp cleanups.
crowdsec_bouncer_download_dir: "{{ lookup('ansible.builtin.env', 'HOME') }}/.cache/ansible/crowdsec"
# The LAPI over the public path these hosts already serve, see
# kubernetes/infrastructure/crowdsec/lapi-ingress.yaml. The proxies cannot
@@ -32,23 +32,13 @@
# The controller has v4, so it does the fetching. The checksum is verified
# there, before anything is copied to a host. Also in check mode: the fetch
# only writes on the controller, and the copy below needs the file.
- name: Create the controller download directory
ansible.builtin.file:
path: "{{ crowdsec_bouncer_download_dir }}"
state: directory
mode: "0755"
delegate_to: localhost
become: false
run_once: true
check_mode: false
- name: Fetch the pinned bouncer release on the controller
check_mode: false
ansible.builtin.get_url:
url: >-
https://github.com/crowdsecurity/cs-firewall-bouncer/releases/download/{{
crowdsec_bouncer_version }}/crowdsec-firewall-bouncer-linux-amd64.tgz
dest: "{{ crowdsec_bouncer_download_dir }}/crowdsec-firewall-bouncer-{{ crowdsec_bouncer_version }}.tgz"
dest: /tmp/crowdsec-firewall-bouncer-{{ crowdsec_bouncer_version }}.tgz
checksum: sha256:{{ crowdsec_bouncer_sha256 }}
mode: "0644"
delegate_to: localhost
@@ -63,7 +53,7 @@
# host.
- name: Copy the bouncer archive to the host
ansible.builtin.copy:
src: "{{ crowdsec_bouncer_download_dir }}/crowdsec-firewall-bouncer-{{ crowdsec_bouncer_version }}.tgz"
src: /tmp/crowdsec-firewall-bouncer-{{ crowdsec_bouncer_version }}.tgz
dest: /opt/crowdsec-firewall-bouncer-{{ crowdsec_bouncer_version }}.tgz
mode: "0644"
+3 -3
View File
@@ -99,7 +99,7 @@
# quietly proceed either. install-edge-host.yml is a separate play with no
# ordering relationship to this one, so nothing guarantees it has been re-run
# to drop the [Address] block. If it has not, edge-1 would end up holding
# the service address statically AND running a VRRP instance for the same address:
# fd40::123 statically AND running a VRRP instance for the same address:
# keepalived would believe it had withdrawn the address while networkd kept it
# assigned, and the host would go on answering NTP with a clock the health
# check had already rejected.
@@ -107,9 +107,9 @@
# That is the precise failure of 2026-08-04, on the host it happened to, so it
# fails fast rather than being left to inspection.
# Matched on the bare address, not the full address/prefixlen, and anchored
# with \b so it cannot also hit a longer address sharing the prefix (fd02:40::1234).
# with \b so it cannot also hit a longer address sharing the prefix (fd40::1234).
#
# Searching for the literal "fd02:40::123/128" would fail open: a hand-written
# Searching for the literal "fd40::123/128" would fail open: a hand-written
# [Address] using any other mask -- /64, say -- would not match, the assert
# below would pass, and keepalived would be handed an address networkd is
# still assigning. Since this check is detect-only, with no delete afterwards
@@ -2,7 +2,7 @@
# Managed by Ansible - Do not edit manually
global_defs {
router_id {{ ansible_facts['hostname'] }}_ntp
router_id {{ ansible_hostname }}_ntp
enable_script_security
script_user root
}
+4 -15
View File
@@ -11,8 +11,6 @@
- curl
- ca-certificates
- gnupg
# deb822_repository
- python3-debian
state: present
update_cache: true
@@ -23,19 +21,10 @@
creates: /usr/share/keyrings/netbird-archive-keyring.gpg
- name: Add NetBird repository
ansible.builtin.deb822_repository:
name: netbird
types: deb
uris: https://pkgs.netbird.io/debian
suites: stable
components: main
signed_by: /usr/share/keyrings/netbird-archive-keyring.gpg
# A one-line netbird.list beside netbird.sources lists the repository twice.
- name: Remove the one-line NetBird repository
ansible.builtin.file:
path: /etc/apt/sources.list.d/netbird.list
state: absent
ansible.builtin.apt_repository:
repo: "deb [signed-by=/usr/share/keyrings/netbird-archive-keyring.gpg] https://pkgs.netbird.io/debian stable main"
state: present
filename: netbird
- name: Install NetBird client
ansible.builtin.apt:
@@ -6,15 +6,15 @@
apt:
name: qemu-guest-agent
state: present
when:
- ansible_facts['os_family'] == 'Debian'
when:
- ansible_os_family is defined and ansible_os_family == 'Debian'
- name: Install QEMU Guest Agent (RHEL/Rocky VMs)
dnf:
name: qemu-guest-agent
state: present
when:
- ansible_facts['os_family'] == 'RedHat'
when:
- ansible_os_family is defined and ansible_os_family == 'RedHat'
- name: Ensure qemu-guest-agent is started and enabled
service:
-3
View File
@@ -16,6 +16,3 @@ ssh_ca_claude_principal: claude-bot
# Units claude-bot may restart/reload via sudo on this host, e.g. [netbird].
ssh_ca_claude_sudo_units: []
# Full passwordless sudo for claude-bot on this host.
ssh_ca_claude_sudo_all: false
+12 -4
View File
@@ -1,15 +1,23 @@
---
# Replaces the admin logins' authorized_keys with ssh_admin_authorized_keys
# (break-glass + CI). Everything else logs in with a CA certificate.
# ssh_ca_admin_logins comes from main.yml, which runs first in the same play.
- name: Look up the admin logins
ansible.builtin.getent:
database: passwd
key: "{{ item }}"
fail_key: false
loop: "{{ ssh_ca_owner_users }}"
register: ssh_ca_admin_lookup
- name: Set the admin logins' authorized_keys to the managed list
ansible.posix.authorized_key:
user: "{{ item[0] }}"
user: "{{ item.item }}"
key: |
{% for k in ssh_admin_authorized_keys %}
{{ (k.options ~ ' ') if (k.options | default('')) else '' }}{{ k.key }}
{% endfor %}
exclusive: true
loop: "{{ ssh_ca_admin_logins }}"
loop: "{{ ssh_ca_admin_lookup.results }}"
loop_control:
label: "{{ item[0] }}"
label: "{{ item.item }}"
when: (item.ansible_facts.getent_passwd | default({})).get(item.item) is not none
+14 -123
View File
@@ -23,7 +23,6 @@
owner: root
group: root
mode: "0644"
register: ssh_ca_trusted_file
# sshd refuses all public key logins when RevokedKeys is set but unreadable,
# so the file is always deployed, even when empty.
@@ -35,26 +34,6 @@
group: root
mode: "0644"
# /etc/passwd, not getent: DSM has no getent binary. The admin logins are
# local accounts. Each entry is the split passwd line (0 name, 5 home).
- name: Read the local accounts
ansible.builtin.slurp:
src: /etc/passwd
register: ssh_ca_passwd_file
- name: Find the existing admin logins
vars:
ssh_ca_passwd: "{{ ssh_ca_passwd_file.content | b64decode | trim | split('\n') | map('split', ':') | list }}"
ansible.builtin.set_fact:
ssh_ca_admin_logins: "{{ ssh_ca_passwd | selectattr('0', 'in', ssh_ca_owner_users) | list }}"
ssh_ca_claude_exists: "{{ ssh_ca_claude_user in ssh_ca_passwd | map('first') }}"
# DSM manages accounts with synouser/synogroup and has no useradd.
- name: Detect DSM
ansible.builtin.stat:
path: /usr/syno/sbin/synouser
register: ssh_ca_synouser
# systemd-journal: read every journal without sudo (see sudoers-claude.j2).
- name: Create the Claude account
ansible.builtin.user:
@@ -64,57 +43,14 @@
password: "!"
groups: systemd-journal
append: true
when: not ssh_ca_synouser.stat.exists
- name: Create the Claude account on DSM
when: ssh_ca_synouser.stat.exists
block:
# synouser requires a password; nobody knows it and sshd refuses passwords.
- name: Create the DSM account
ansible.builtin.command:
argv:
- /usr/syno/sbin/synouser
- --add
- "{{ ssh_ca_claude_user }}"
- "{{ lookup('ansible.builtin.password', '/dev/null', length=40, chars=['ascii_letters', 'digits']) }}"
- Claude Bot
- "0"
- ""
- "0"
# command runs in check mode here.
when:
- not ssh_ca_claude_exists
- not ansible_check_mode
no_log: true
# Members are listed as "<n>:[<name>]".
- name: Read the administrators members
ansible.builtin.command: /usr/syno/sbin/synogroup --get administrators
register: ssh_ca_dsm_admins_out
changed_when: false
check_mode: false
- name: Find the administrators members
ansible.builtin.set_fact:
ssh_ca_dsm_admins: >-
{{ ssh_ca_dsm_admins_out.stdout_lines | select('match', '^[0-9]+:\[.*\]$')
| map('regex_replace', '^[0-9]+:\[(.*)\]$', '\1') | list }}
# synogroup --member replaces the member list; a parse that lost the owner
# admin login would drop it.
- name: Check the member list holds the owner admin login
ansible.builtin.assert:
that:
- ssh_ca_dsm_admins | intersect(ssh_ca_owner_users) | length > 0
fail_msg: "administrators members lack {{ ssh_ca_owner_users }}: {{ ssh_ca_dsm_admins }}"
# DSM allows SSH for administrators only.
- name: Add the Claude account to administrators
ansible.builtin.command:
argv: "{{ ['/usr/syno/sbin/synogroup', '--member', 'administrators'] + ssh_ca_dsm_admins + [ssh_ca_claude_user] }}"
when:
- ssh_ca_claude_user not in ssh_ca_dsm_admins
- not ansible_check_mode
- name: Look up the owner admin logins
ansible.builtin.getent:
database: passwd
key: "{{ item }}"
fail_key: false
loop: "{{ ssh_ca_owner_users }}"
register: ssh_ca_owner_lookup
- name: Create the principals directory
ansible.builtin.file:
@@ -127,13 +63,14 @@
- name: Accept the owner principal for the existing admin logins
ansible.builtin.copy:
content: "{{ ssh_ca_owner_principal }}\n"
dest: "/etc/ssh/auth_principals/{{ item[0] }}"
dest: "/etc/ssh/auth_principals/{{ item.item }}"
owner: root
group: root
mode: "0644"
loop: "{{ ssh_ca_admin_logins }}"
loop: "{{ ssh_ca_owner_lookup.results }}"
loop_control:
label: "{{ item[0] }}"
label: "{{ item.item }}"
when: (item.ansible_facts.getent_passwd | default({})).get(item.item) is not none
- name: Accept the Claude principal for the Claude account
ansible.builtin.copy:
@@ -151,48 +88,6 @@
group: root
mode: "0440"
validate: visudo -cf %s
when: not ssh_ca_synouser.stat.exists
# DSM has no visudo, so the rules are checked on the controller.
- name: Install the Claude sudo rules on DSM
when: ssh_ca_synouser.stat.exists
vars:
ssh_ca_sudoers: "{{ lookup('ansible.builtin.template', 'sudoers-claude.j2') | trim }}"
block:
- name: Check the sudo rules on the controller
ansible.builtin.command:
cmd: visudo -c -f -
stdin: "{{ ssh_ca_sudoers }}"
delegate_to: localhost
become: false
changed_when: false
check_mode: false
- name: Install the checked sudo rules
ansible.builtin.copy:
content: "{{ ssh_ca_sudoers }}\n"
dest: "/etc/sudoers.d/{{ ssh_ca_claude_user }}"
owner: root
group: root
mode: "0440"
- name: Create the sshd drop-in directory
ansible.builtin.file:
path: /etc/ssh/sshd_config.d
state: directory
owner: root
group: root
mode: "0755"
# DSM's sshd_config has no Include. sshd keeps the first value it reads, so
# the Include goes first for the drop-ins to win.
- name: Include the sshd drop-in directory
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
line: Include /etc/ssh/sshd_config.d/*.conf
insertbefore: BOF
validate: sshd -t -f %s
register: ssh_ca_include_line
# A broken drop-in only bites on the next sshd restart, so it is tested here
# and removed again if the full config does not parse.
@@ -205,7 +100,6 @@
owner: root
group: root
mode: "0644"
register: ssh_ca_dropin_file
- name: Test the full sshd config
ansible.builtin.command: sshd -t
@@ -242,11 +136,10 @@
fail_msg: "sshd -T does not show the SSH CA settings; another config line takes precedence"
# The unit is sshd.service on Arch and RedHat, ssh.service on Debian/Ubuntu,
# where sshd.service is an alias that resolves to it. The running daemon is
# reloaded when a file it reads at start changed; a reload keeps open
# where sshd.service is an alias that resolves to it. sshd -T only parses the
# files, so the running daemon is reloaded on every run; a reload keeps open
# sessions. None runs when sshd is socket activated and stopped, or per
# connection (ssh-mesh-only role); the next start reads the config.
# Principals and revoked keys are read per login and need no reload.
# systemctl directly: service_facts took 3-12 s per host.
- name: Find the running sshd unit
ansible.builtin.command: systemctl list-units --state=running --plain --no-legend --type=service sshd.service ssh.service
@@ -268,9 +161,7 @@
ansible.builtin.systemd:
name: "{{ ssh_ca_sshd_units | first }}"
state: reloaded
when:
- ssh_ca_sshd_units | length == 1
- ssh_ca_trusted_file is changed or ssh_ca_include_line is changed or ssh_ca_dropin_file is changed
when: ssh_ca_sshd_units | length == 1
- name: Report the admin logins' authorized keys
ansible.builtin.include_tasks: report.yml
+4 -3
View File
@@ -3,10 +3,11 @@
# [ssh_keys_managed] have this reduced to ssh_admin_authorized_keys.
- name: Find the admin logins' authorized_keys
ansible.builtin.stat:
path: "{{ item[5] }}/.ssh/authorized_keys"
loop: "{{ ssh_ca_admin_logins }}"
path: "{{ item.ansible_facts.getent_passwd[item.item][4] }}/.ssh/authorized_keys"
loop: "{{ ssh_ca_owner_lookup.results }}"
loop_control:
label: "{{ item[0] }}"
label: "{{ item.item }}"
when: (item.ansible_facts.getent_passwd | default({})).get(item.item) is not none
register: ssh_ca_keyfiles
- name: Fingerprint the admin logins' authorized keys
@@ -10,6 +10,3 @@
{% for unit in ssh_ca_claude_sudo_units %}
{{ ssh_ca_claude_user }} ALL=(root) NOPASSWD:NOEXEC: /usr/bin/systemctl restart {{ unit }}, /usr/bin/systemctl reload {{ unit }}
{% endfor %}
{% if ssh_ca_claude_sudo_all %}
{{ ssh_ca_claude_user }} ALL=(ALL) NOPASSWD: ALL
{% endif %}
@@ -5,9 +5,14 @@ uptime_kuma_base_url: "https://{{ uptime_kuma_host }}"
uptime_kuma_dir: /opt/uptime-kuma
uptime_kuma_traefik_network: netbird_netbird
uptime_kuma_smtp_from: uptime@renner.uno
# Alertmanager email receiver in kubernetes/apps/monitoring/helmrelease-prometheus.yaml.
uptime_kuma_smtp_to: admin@renner.uno
uptime_kuma_openbao_url: https://openbao.renner.uno
uptime_kuma_openbao_mount: homelab
uptime_kuma_openbao_path: infrastructure/uptime-kuma
uptime_kuma_openbao_smtp_path: infrastructure/uptime-kuma/smtp
uptime_kuma_openbao_metrics_path: infrastructure/uptime-kuma-metrics
# Token with policy uptime-kuma-vps, looked up like the bao CLI does:
# VAULT_TOKEN, else the token helper file written by `bao login -method=oidc`.
+42 -3
View File
@@ -1,4 +1,4 @@
// Bootstraps Uptime Kuma: admin user, general settings,
// Bootstraps Uptime Kuma: admin user, general settings, SMTP notification,
// and (when KUMA_METRICS_ROTATE=true) a fresh Prometheus API key written to KUMA_METRICS_KEY_FILE.
// All input comes from environment variables; secrets are never printed.
// Output lines: "admin user created" / "already set up", then "changed: <step>" or "ok: <step>".
@@ -20,7 +20,17 @@ const SETTINGS_DEFAULTS = {
trustProxy: false,
};
const REQUIRED = ["KUMA_SETUP_USERNAME", "KUMA_SETUP_PASSWORD", "KUMA_BASE_URL"];
const REQUIRED = [
"KUMA_SETUP_USERNAME",
"KUMA_SETUP_PASSWORD",
"KUMA_BASE_URL",
"KUMA_SMTP_HOST",
"KUMA_SMTP_PORT",
"KUMA_SMTP_USERNAME",
"KUMA_SMTP_PASSWORD",
"KUMA_SMTP_FROM",
"KUMA_SMTP_TO",
];
const missing = REQUIRED.filter((name) => !process.env[name]);
const rotateMetricsKey = process.env.KUMA_METRICS_ROTATE === "true";
if (rotateMetricsKey && !process.env.KUMA_METRICS_KEY_FILE) {
@@ -34,7 +44,7 @@ const env = process.env;
const socket = io(KUMA_URL, { transports: ["websocket"], reconnection: false });
const lists = {};
const LIST_EVENTS = ["apiKeyList"];
const LIST_EVENTS = ["notificationList", "apiKeyList"];
for (const event of LIST_EVENTS) {
socket.on(event, (data) => {
lists[event] = data;
@@ -113,6 +123,34 @@ async function ensureSettings() {
report("settings", true);
}
async function ensureNotification() {
const port = Number(env.KUMA_SMTP_PORT);
const desired = {
name: "email",
type: "smtp",
isDefault: true,
smtpHost: env.KUMA_SMTP_HOST,
smtpPort: port,
// 465 is implicit TLS; any other port uses STARTTLS.
smtpSecure: port === 465,
smtpIgnoreSTARTTLS: false,
smtpUsername: env.KUMA_SMTP_USERNAME,
smtpPassword: env.KUMA_SMTP_PASSWORD,
smtpFrom: env.KUMA_SMTP_FROM,
smtpTo: env.KUMA_SMTP_TO,
};
const existing = (lists.notificationList || []).filter((n) => n.name === desired.name).sort((a, b) => a.id - b.id)[0];
const current = existing ? JSON.parse(existing.config || "{}") : {};
const upToDate =
existing && existing.isDefault === true && Object.keys(desired).every((key) => current[key] === desired[key]);
if (upToDate) {
report("notification", false);
return;
}
await callOk("addNotification", { ...current, ...desired, applyExisting: true }, existing ? existing.id : null);
report("notification", true);
}
async function rotateMetricsApiKey() {
if (!rotateMetricsKey) {
report("metrics api key", false);
@@ -131,6 +169,7 @@ async function main() {
await setupAdmin();
await login();
await ensureSettings();
await ensureNotification();
await rotateMetricsApiKey();
}
+31
View File
@@ -23,6 +23,18 @@
register: _uptime_kuma_secret_read
no_log: true
- name: Read SMTP credentials from OpenBao
ansible.builtin.uri:
url: "{{ uptime_kuma_openbao_url }}/v1/{{ uptime_kuma_openbao_mount }}/data/{{ uptime_kuma_openbao_smtp_path }}"
headers:
X-Vault-Token: "{{ uptime_kuma_openbao_token }}"
delegate_to: localhost
become: false
run_once: true
check_mode: false
register: _uptime_kuma_smtp_read
no_log: true
# 404 until the role has stored a key for the first time.
- name: Read metrics API key from OpenBao
ansible.builtin.uri:
@@ -40,6 +52,7 @@
- name: Extract the OpenBao secrets
ansible.builtin.set_fact:
_uptime_kuma_secret: "{{ _uptime_kuma_secret_read.json.data.data }}"
_uptime_kuma_smtp: "{{ _uptime_kuma_smtp_read.json.data.data }}"
_uptime_kuma_metrics_key: "{{ _uptime_kuma_metrics_read.json.data.data.api_key if _uptime_kuma_metrics_read.status == 200 else '' }}"
no_log: true
@@ -127,6 +140,18 @@
- -e
- KUMA_BASE_URL
- -e
- KUMA_SMTP_HOST
- -e
- KUMA_SMTP_PORT
- -e
- KUMA_SMTP_USERNAME
- -e
- KUMA_SMTP_PASSWORD
- -e
- KUMA_SMTP_FROM
- -e
- KUMA_SMTP_TO
- -e
- KUMA_METRICS_ROTATE
- -e
- KUMA_METRICS_KEY_FILE
@@ -139,6 +164,12 @@
KUMA_SETUP_USERNAME: "{{ _uptime_kuma_secret.username }}"
KUMA_SETUP_PASSWORD: "{{ _uptime_kuma_secret.password }}"
KUMA_BASE_URL: "{{ uptime_kuma_base_url }}"
KUMA_SMTP_HOST: "{{ _uptime_kuma_smtp.server }}"
KUMA_SMTP_PORT: "{{ _uptime_kuma_smtp.port }}"
KUMA_SMTP_USERNAME: "{{ _uptime_kuma_smtp.username }}"
KUMA_SMTP_PASSWORD: "{{ _uptime_kuma_smtp.token }}"
KUMA_SMTP_FROM: "{{ uptime_kuma_smtp_from }}"
KUMA_SMTP_TO: "{{ uptime_kuma_smtp_to }}"
KUMA_METRICS_ROTATE: "{{ 'false' if (_uptime_kuma_metrics_check.status | default(0)) == 200 else 'true' }}"
KUMA_METRICS_KEY_FILE: /app/data/.metrics-api-key
register: _uptime_kuma_setup
-2
View File
@@ -8,8 +8,6 @@
# One-off actions are not state and stay separate: plays/update-os.yml,
# plays/update-dist-upgrade.yml, plays/rotate-netbird-secrets.yml.
- name: Synology Python
ansible.builtin.import_playbook: plays/configure-synology-python.yml
- name: Base system
ansible.builtin.import_playbook: plays/common.yml
- name: VM time sync
+2 -2
View File
@@ -5,12 +5,12 @@
# The unit tests gate the image: the final stage copies the controller out of
# this stage, so a failing test fails the build.
FROM python:3.15-slim@sha256:94d3426f1def7db4a106a232ac121bfd975393e5dfd40a619b955fae45fe7913 AS test
FROM python:3.15-slim@sha256:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51 AS test
WORKDIR /src
COPY controller.py test_controller.py ./
RUN python3 -B -m unittest -v
FROM python:3.15-slim@sha256:94d3426f1def7db4a106a232ac121bfd975393e5dfd40a619b955fae45fe7913
FROM python:3.15-slim@sha256:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51
RUN useradd --system --no-create-home --shell /usr/sbin/nologin controller
COPY --from=test /src/controller.py /opt/controller.py
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:24-alpine@sha256:8a32c8cf649a49915a66e81e7a609223c708d0adabf288ee3f4077d765542f85
FROM node:24-alpine@sha256:06d7adf3e0ef61a13f1d28682726366f97a15aa1888c934d3ecec86c33918b53
# Default to the latest Claude Code. Pinning a fixed version is the wrong
# default here: old releases eventually stop working against the API, so a
+2 -2
View File
@@ -2,7 +2,7 @@
# Cloudflare. The unit tests (no tags, so no envtest) gate the image: the
# final stage copies the binary out of this stage, so a failing test fails the
# build.
FROM golang:1.27@sha256:2920bb124f1a2e12a1d39f0cab756ae0068441ee1da7655dec7f5e374944095b AS build
FROM golang:1.27@sha256:f20d83a83e18dbd2a4714b8ffd4b30b287b6243962dd75608e86abcc63ba4efc AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
@@ -10,7 +10,7 @@ COPY . .
RUN go test ./...
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/cloudflare-lb-sync ./cmd/cloudflare-lb-sync
FROM gcr.io/distroless/static:nonroot@sha256:25ac8f0c3b2334a9eb05452266bd4697864a8c8ef0ef71e5e6480d7f92868329
FROM gcr.io/distroless/static:nonroot@sha256:18f4cc26c9ffa7f20a359298be7deadac5c607c8e03a02b8119f1a5fc2f82c99
COPY --from=build /out/cloudflare-lb-sync /cloudflare-lb-sync
USER 65532
ENTRYPOINT ["/cloudflare-lb-sync"]
+2 -2
View File
@@ -23,7 +23,7 @@
# namespace, so can0 is only reachable with network_mode: host. That is also
# how the local MQTT broker on 127.0.0.1:1883 is reached. CAN_RAW sockets need
# no capabilities, so both run unprivileged.
FROM python:3.15-slim@sha256:94d3426f1def7db4a106a232ac121bfd975393e5dfd40a619b955fae45fe7913 AS build
FROM python:3.15-slim@sha256:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51 AS build
# Pins live here, in the artifact, rather than in Ansible variables.
#
@@ -56,7 +56,7 @@ RUN git clone --quiet https://github.com/MyHomeMyData/E3onCAN.git /opt/e3oncan \
&& rm -rf /opt/e3oncan/.git
FROM python:3.15-slim@sha256:94d3426f1def7db4a106a232ac121bfd975393e5dfd40a619b955fae45fe7913
FROM python:3.15-slim@sha256:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51
ARG OPEN3E_REF=6553c10453a6bae1210908f38a5af2481a46a3e5
ARG E3ONCAN_REF=461dcbdf97e8d72d2edb9929c63b39382af6c3a1
+4 -4
View File
@@ -6,10 +6,10 @@ RUN apt-get update \
&& apt-get install -y --no-install-recommends jq \
&& rm -rf /var/lib/apt/lists/*
COPY --from=ghcr.io/siderolabs/talosctl:v1.14.5@sha256:0b852e35c1f513ee5eacc22183d6193486012420569fde39fcd5260c51938443 /talosctl /usr/local/bin/talosctl
COPY --from=alpine/kubectl:1.37.2@sha256:5e2de75d37e811ba6eb92e06890b1b3263eae4f0a1c17b49269edec589b1352a /usr/local/bin/kubectl /usr/local/bin/kubectl
COPY --from=ghcr.io/fluxcd/flux-cli:v2.10.0@sha256:1a5178b1770052d6e88ab1c7c8434c5ac9bca8a8ab984dcaf6b9df31814ce9f2 /usr/local/bin/flux /usr/local/bin/flux
COPY --from=quay.io/openbao/openbao:2.7.2@sha256:5046911075e137e154ce49ab051502f4ec7cf367d484ae4a4dce665d9b90561f /usr/bin/bao /usr/local/bin/bao
COPY --from=ghcr.io/siderolabs/talosctl:v1.14.4@sha256:b3233234327350f20ed194f3fad83d90a64344c1940f839c7b0c0abef50c8c33 /talosctl /usr/local/bin/talosctl
COPY --from=alpine/kubectl:1.37.1@sha256:a5d0fbecab3f7b6c2ab7398f1daa73a3e0ed6ece18556d21698233e9e3759cc7 /usr/local/bin/kubectl /usr/local/bin/kubectl
COPY --from=ghcr.io/fluxcd/flux-cli:v2.9.6@sha256:b1ac18156f227af9a524b842a96f2c20986c7a779ef721df3ba4e4540f449d76 /usr/local/bin/flux /usr/local/bin/flux
COPY --from=quay.io/openbao/openbao:2.7.1@sha256:6d2b93856e3fcf7b18ad855a0b51eaba474dc8b79cf554379ea32034797d2acf /usr/bin/bao /usr/local/bin/bao
COPY --from=gitea/tea:0.16.0@sha256:af50a2060ce4d8fd76b7ebe75a3d6db4bb0c615c5bc8f2bdfd8ea83207c4752d /usr/bin/tea /usr/local/bin/tea
RUN talosctl version --client \
+2 -2
View File
@@ -1,7 +1,7 @@
# kuma-sync: reconciles KumaMonitor resources into Uptime Kuma. The unit tests
# (no tags, so no Docker and no envtest) gate the image: the final stage copies
# the binary out of this stage, so a failing test fails the build.
FROM golang:1.27@sha256:2920bb124f1a2e12a1d39f0cab756ae0068441ee1da7655dec7f5e374944095b AS build
FROM golang:1.27@sha256:f20d83a83e18dbd2a4714b8ffd4b30b287b6243962dd75608e86abcc63ba4efc AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
@@ -9,7 +9,7 @@ COPY . .
RUN go test ./...
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/kuma-sync ./cmd/kuma-sync
FROM gcr.io/distroless/static:nonroot@sha256:25ac8f0c3b2334a9eb05452266bd4697864a8c8ef0ef71e5e6480d7f92868329
FROM gcr.io/distroless/static:nonroot@sha256:18f4cc26c9ffa7f20a359298be7deadac5c607c8e03a02b8119f1a5fc2f82c99
COPY --from=build /out/kuma-sync /kuma-sync
USER 65532
ENTRYPOINT ["/kuma-sync"]
@@ -97,10 +97,6 @@ type KumaMonitorSpec struct {
// +kubebuilder:validation:Minimum=0
// +kubebuilder:default=0
MaxRetries int32 `json:"maxRetries,omitempty"`
// Consecutive DOWN heartbeats after which Kuma sends the DOWN notification
// again, not minutes; 0 sends it once per outage.
// +kubebuilder:validation:Minimum=0
ResendInterval int32 `json:"resendInterval,omitempty"`
// Seconds.
// +kubebuilder:validation:Minimum=1
Timeout *int32 `json:"timeout,omitempty"`
@@ -112,17 +108,6 @@ type KumaMonitorSpec struct {
JSONQuery *JSONQuerySpec `json:"jsonQuery,omitempty"`
DNS *DNSSpec `json:"dns,omitempty"`
Port *PortSpec `json:"port,omitempty"`
// KumaNotifications attached to the monitor; none attaches no notification.
// +listType=map
// +listMapKey=name
NotificationRefs []NotificationRef `json:"notificationRefs,omitempty"`
}
// NotificationRef references a KumaNotification.
type NotificationRef struct {
// +kubebuilder:validation:MinLength=1
Name string `json:"name"`
}
type KumaMonitorStatus struct {
@@ -1,66 +0,0 @@
package v1alpha1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// SecretRef references a Secret in any namespace.
type SecretRef struct {
// +kubebuilder:validation:MinLength=1
Namespace string `json:"namespace"`
// +kubebuilder:validation:MinLength=1
Name string `json:"name"`
}
// SMTPSpec configures an smtp notification. The server and its credentials
// come from the Secret: keys host, port, username and password, all required.
// Port 465 uses implicit TLS, any other port STARTTLS.
type SMTPSpec struct {
// +kubebuilder:validation:MinLength=1
From string `json:"from"`
// +kubebuilder:validation:MinLength=1
To string `json:"to"`
SecretRef SecretRef `json:"secretRef"`
}
// +kubebuilder:validation:XValidation:rule="(self.type == 'smtp') == has(self.smtp)",message="smtp is required for type smtp and forbidden otherwise"
type KumaNotificationSpec struct {
// +kubebuilder:validation:Enum=smtp
Type string `json:"type"`
SMTP *SMTPSpec `json:"smtp,omitempty"`
}
type KumaNotificationStatus struct {
NotificationID int64 `json:"notificationID,omitempty"`
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
// +listType=map
// +listMapKey=type
Conditions []metav1.Condition `json:"conditions,omitempty"`
}
// KumaNotification is the Kuma notification named like the resource. It is
// never a default notification and never applied to existing monitors;
// KumaMonitors attach it through notificationRefs.
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:resource:path=kumanotifications,scope=Cluster
// +kubebuilder:printcolumn:name="Type",type=string,JSONPath=`.spec.type`
// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status`
type KumaNotification struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec KumaNotificationSpec `json:"spec"`
Status KumaNotificationStatus `json:"status,omitempty"`
}
// +kubebuilder:object:root=true
type KumaNotificationList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []KumaNotification `json:"items"`
}
func init() {
SchemeBuilder.Register(&KumaNotification{}, &KumaNotificationList{})
}
@@ -1,78 +0,0 @@
//go:build envtest
package v1alpha1
import (
"context"
"strings"
"testing"
"k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
func smtpNotification(name string) *KumaNotification {
return &KumaNotification{
ObjectMeta: metav1.ObjectMeta{Name: name},
Spec: KumaNotificationSpec{Type: "smtp", SMTP: &SMTPSpec{
From: "kuma@renner.uno", To: "ops@renner.uno",
SecretRef: SecretRef{Namespace: "kuma-sync", Name: "smtp"},
}},
}
}
func expectNotificationInvalid(t *testing.T, n *KumaNotification, wants ...string) {
t.Helper()
err := k8s.Create(context.Background(), n)
if err == nil || !errors.IsInvalid(err) {
t.Fatalf("expected invalid, got %v", err)
}
for _, w := range wants {
if !strings.Contains(err.Error(), w) {
t.Fatalf("error %q does not contain %q", err.Error(), w)
}
}
}
func TestValidNotification(t *testing.T) {
if err := k8s.Create(context.Background(), smtpNotification("valid-smtp")); err != nil {
t.Fatalf("expected accept, got %v", err)
}
}
func TestNotificationInvalidSpecs(t *testing.T) {
noSMTP := smtpNotification("no-smtp")
noSMTP.Spec.SMTP = nil
expectNotificationInvalid(t, noSMTP, "smtp is required for type smtp")
webhook := smtpNotification("webhook")
webhook.Spec.Type = "webhook"
expectNotificationInvalid(t, webhook, "spec.type")
noTo := smtpNotification("no-to")
noTo.Spec.SMTP.To = ""
expectNotificationInvalid(t, noTo, "spec.smtp.to")
noSecret := smtpNotification("no-secret")
noSecret.Spec.SMTP.SecretRef.Namespace = ""
expectNotificationInvalid(t, noSecret, "spec.smtp.secretRef.namespace")
}
func TestMonitorNotificationRefs(t *testing.T) {
m := monitor("with-refs", KumaMonitorSpec{
Type: "group", DisplayName: "g",
NotificationRefs: []NotificationRef{{Name: "mail"}, {Name: "ops"}},
})
if err := k8s.Create(context.Background(), m); err != nil {
t.Fatalf("expected accept, got %v", err)
}
dup := monitor("dup-refs", KumaMonitorSpec{
Type: "group", DisplayName: "g",
NotificationRefs: []NotificationRef{{Name: "mail"}, {Name: "mail"}},
})
expectInvalid(t, dup, "Duplicate value")
empty := monitor("empty-ref", KumaMonitorSpec{
Type: "group", DisplayName: "g", NotificationRefs: []NotificationRef{{Name: ""}},
})
expectInvalid(t, empty, "spec.notificationRefs[0].name")
}
@@ -131,15 +131,6 @@ func TestNegativeMaxRetriesRejected(t *testing.T) {
expectInvalid(t, m, "spec.maxRetries")
}
func TestNegativeResendIntervalRejected(t *testing.T) {
m := monitor("neg-resend", KumaMonitorSpec{
Type: "group",
DisplayName: "g",
ResendInterval: -1,
})
expectInvalid(t, m, "spec.resendInterval")
}
func TestJSONQueryBadOperator(t *testing.T) {
m := monitor("jq-bad-op", KumaMonitorSpec{
Type: "json-query",
@@ -203,11 +203,6 @@ func (in *KumaMonitorSpec) DeepCopyInto(out *KumaMonitorSpec) {
*out = new(PortSpec)
**out = **in
}
if in.NotificationRefs != nil {
in, out := &in.NotificationRefs, &out.NotificationRefs
*out = make([]NotificationRef, len(*in))
copy(*out, *in)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KumaMonitorSpec.
@@ -242,107 +237,6 @@ func (in *KumaMonitorStatus) DeepCopy() *KumaMonitorStatus {
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *KumaNotification) DeepCopyInto(out *KumaNotification) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
in.Spec.DeepCopyInto(&out.Spec)
in.Status.DeepCopyInto(&out.Status)
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KumaNotification.
func (in *KumaNotification) DeepCopy() *KumaNotification {
if in == nil {
return nil
}
out := new(KumaNotification)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *KumaNotification) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *KumaNotificationList) DeepCopyInto(out *KumaNotificationList) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ListMeta.DeepCopyInto(&out.ListMeta)
if in.Items != nil {
in, out := &in.Items, &out.Items
*out = make([]KumaNotification, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KumaNotificationList.
func (in *KumaNotificationList) DeepCopy() *KumaNotificationList {
if in == nil {
return nil
}
out := new(KumaNotificationList)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *KumaNotificationList) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *KumaNotificationSpec) DeepCopyInto(out *KumaNotificationSpec) {
*out = *in
if in.SMTP != nil {
in, out := &in.SMTP, &out.SMTP
*out = new(SMTPSpec)
**out = **in
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KumaNotificationSpec.
func (in *KumaNotificationSpec) DeepCopy() *KumaNotificationSpec {
if in == nil {
return nil
}
out := new(KumaNotificationSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *KumaNotificationStatus) DeepCopyInto(out *KumaNotificationStatus) {
*out = *in
if in.Conditions != nil {
in, out := &in.Conditions, &out.Conditions
*out = make([]v1.Condition, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KumaNotificationStatus.
func (in *KumaNotificationStatus) DeepCopy() *KumaNotificationStatus {
if in == nil {
return nil
}
out := new(KumaNotificationStatus)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *KumaStatusPage) DeepCopyInto(out *KumaStatusPage) {
*out = *in
@@ -471,21 +365,6 @@ func (in *LocalRef) DeepCopy() *LocalRef {
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *NotificationRef) DeepCopyInto(out *NotificationRef) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NotificationRef.
func (in *NotificationRef) DeepCopy() *NotificationRef {
if in == nil {
return nil
}
out := new(NotificationRef)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *PortSpec) DeepCopyInto(out *PortSpec) {
*out = *in
@@ -501,37 +380,6 @@ func (in *PortSpec) DeepCopy() *PortSpec {
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *SMTPSpec) DeepCopyInto(out *SMTPSpec) {
*out = *in
out.SecretRef = in.SecretRef
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SMTPSpec.
func (in *SMTPSpec) DeepCopy() *SMTPSpec {
if in == nil {
return nil
}
out := new(SMTPSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *SecretRef) DeepCopyInto(out *SecretRef) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecretRef.
func (in *SecretRef) DeepCopy() *SecretRef {
if in == nil {
return nil
}
out := new(SecretRef)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *SectionStatus) DeepCopyInto(out *SectionStatus) {
*out = *in
+4 -32
View File
@@ -3,25 +3,19 @@ package main
import (
"errors"
"fmt"
"strings"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/validation"
)
type config struct {
URL string
Username string
Password string
// RootGroup is the zero value when KUMA_ROOT_GROUP is unset.
RootGroup types.NamespacedName
}
// loadConfig reads the Kuma connection settings from the environment. It
// names every missing or invalid variable.
// names every missing variable.
func loadConfig(getenv func(string) string) (config, error) {
var cfg config
var errs []error
var missing []error
for _, v := range []struct {
name string
dst *string
@@ -31,30 +25,8 @@ func loadConfig(getenv func(string) string) (config, error) {
{"KUMA_PASSWORD", &cfg.Password},
} {
if *v.dst = getenv(v.name); *v.dst == "" {
errs = append(errs, fmt.Errorf("missing required environment variable %s", v.name))
missing = append(missing, fmt.Errorf("missing required environment variable %s", v.name))
}
}
if raw := getenv("KUMA_ROOT_GROUP"); raw != "" {
root, err := parseRootGroup(raw)
if err != nil {
errs = append(errs, fmt.Errorf("KUMA_ROOT_GROUP: %w", err))
}
cfg.RootGroup = root
}
return cfg, errors.Join(errs...)
}
// parseRootGroup parses namespace/name of a KumaMonitor.
func parseRootGroup(raw string) (types.NamespacedName, error) {
ns, name, ok := strings.Cut(raw, "/")
if !ok || strings.Contains(name, "/") {
return types.NamespacedName{}, fmt.Errorf("%q is not namespace/name", raw)
}
if msgs := validation.IsDNS1123Label(ns); len(msgs) > 0 {
return types.NamespacedName{}, fmt.Errorf("namespace %q: %s", ns, strings.Join(msgs, "; "))
}
if msgs := validation.IsDNS1123Subdomain(name); len(msgs) > 0 {
return types.NamespacedName{}, fmt.Errorf("name %q: %s", name, strings.Join(msgs, "; "))
}
return types.NamespacedName{Namespace: ns, Name: name}, nil
return cfg, errors.Join(missing...)
}
@@ -3,8 +3,6 @@ package main
import (
"strings"
"testing"
"k8s.io/apimachinery/pkg/types"
)
func TestLoadConfig(t *testing.T) {
@@ -38,21 +36,3 @@ func TestLoadConfig(t *testing.T) {
}
}
}
func TestLoadConfigRootGroup(t *testing.T) {
env := func(root string) func(string) string {
m := map[string]string{"KUMA_URL": "http://kuma", "KUMA_USERNAME": "u", "KUMA_PASSWORD": "p", "KUMA_ROOT_GROUP": root}
return func(k string) string { return m[k] }
}
cfg, err := loadConfig(env("kuma-sync/renner-uno"))
if err != nil || cfg.RootGroup != (types.NamespacedName{Namespace: "kuma-sync", Name: "renner-uno"}) {
t.Fatalf("cfg=%+v err=%v", cfg, err)
}
for _, bad := range []string{"renner-uno", "/renner-uno", "kuma-sync/", "a/b/c", "Kuma/x", "ns/UPPER", " ns/x"} {
if _, err := loadConfig(env(bad)); err == nil || !strings.Contains(err.Error(), "KUMA_ROOT_GROUP") {
t.Errorf("%q: err = %v, want KUMA_ROOT_GROUP rejected", bad, err)
}
}
}
@@ -48,7 +48,6 @@ func run() error {
GCInterval: 5 * time.Minute,
OrphanGrace: time.Hour,
Resync: 5 * time.Minute,
RootGroup: cfg.RootGroup,
})
if err != nil {
return err
@@ -182,22 +182,6 @@ spec:
format: int32
minimum: 0
type: integer
notificationRefs:
description: KumaNotifications attached to the monitor; none attaches
no notification.
items:
description: NotificationRef references a KumaNotification.
properties:
name:
minLength: 1
type: string
required:
- name
type: object
type: array
x-kubernetes-list-map-keys:
- name
x-kubernetes-list-type: map
parentRef:
description: LocalRef references a KumaMonitor in the same namespace.
properties:
@@ -222,13 +206,6 @@ spec:
- hostname
- port
type: object
resendInterval:
description: |-
Consecutive DOWN heartbeats after which Kuma sends the DOWN notification
again, not minutes; 0 sends it once per outage.
format: int32
minimum: 0
type: integer
retryInterval:
default: 60
description: Seconds.
@@ -1,165 +0,0 @@
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.23.0
name: kumanotifications.uptime.renner.uno
spec:
group: uptime.renner.uno
names:
kind: KumaNotification
listKind: KumaNotificationList
plural: kumanotifications
singular: kumanotification
scope: Cluster
versions:
- additionalPrinterColumns:
- jsonPath: .spec.type
name: Type
type: string
- jsonPath: .status.conditions[?(@.type=="Ready")].status
name: Ready
type: string
name: v1alpha1
schema:
openAPIV3Schema:
description: |-
KumaNotification is the Kuma notification named like the resource. It is
never a default notification and never applied to existing monitors;
KumaMonitors attach it through notificationRefs.
properties:
apiVersion:
description: |-
APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
type: string
kind:
description: |-
Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
type: string
metadata:
type: object
spec:
properties:
smtp:
description: |-
SMTPSpec configures an smtp notification. The server and its credentials
come from the Secret: keys host, port, username and password, all required.
Port 465 uses implicit TLS, any other port STARTTLS.
properties:
from:
minLength: 1
type: string
secretRef:
description: SecretRef references a Secret in any namespace.
properties:
name:
minLength: 1
type: string
namespace:
minLength: 1
type: string
required:
- name
- namespace
type: object
to:
minLength: 1
type: string
required:
- from
- secretRef
- to
type: object
type:
enum:
- smtp
type: string
required:
- type
type: object
x-kubernetes-validations:
- message: smtp is required for type smtp and forbidden otherwise
rule: (self.type == 'smtp') == has(self.smtp)
status:
properties:
conditions:
items:
description: Condition contains details for one aspect of the current
state of this API Resource.
properties:
lastTransitionTime:
description: |-
lastTransitionTime is the last time the condition transitioned from one status to another.
This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
format: date-time
type: string
message:
description: |-
message is a human readable message indicating details about the transition.
This may be an empty string.
maxLength: 32768
type: string
observedGeneration:
description: |-
observedGeneration represents the .metadata.generation that the condition was set based upon.
For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
with respect to the current state of the instance.
format: int64
minimum: 0
type: integer
reason:
description: |-
reason contains a programmatic identifier indicating the reason for the condition's last transition.
Producers of specific condition types may define expected values and meanings for this field,
and whether the values are considered a guaranteed API.
The value should be a CamelCase string.
This field may not be empty.
maxLength: 1024
minLength: 1
pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
type: string
status:
description: status of the condition, one of True, False, Unknown.
enum:
- "True"
- "False"
- Unknown
type: string
type:
description: type of condition in CamelCase or in foo.example.com/CamelCase.
maxLength: 316
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
type: string
required:
- lastTransitionTime
- message
- reason
- status
- type
type: object
type: array
x-kubernetes-list-map-keys:
- type
x-kubernetes-list-type: map
notificationID:
format: int64
type: integer
observedGeneration:
format: int64
type: integer
type: object
required:
- spec
type: object
served: true
storage: true
subresources:
status: {}
@@ -21,7 +21,6 @@ rules:
- uptime.renner.uno
resources:
- kumamonitors
- kumanotifications
- kumastatuspages
verbs:
- get
@@ -33,7 +32,6 @@ rules:
- uptime.renner.uno
resources:
- kumamonitors/finalizers
- kumanotifications/finalizers
- kumastatuspages/finalizers
verbs:
- update
@@ -41,7 +39,6 @@ rules:
- uptime.renner.uno
resources:
- kumamonitors/status
- kumanotifications/status
- kumastatuspages/status
verbs:
- get
+1 -12
View File
@@ -10,7 +10,6 @@ import (
"time"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/rest"
"k8s.io/utils/ptr"
@@ -42,9 +41,6 @@ type Options struct {
// Resync is how often a healthy resource is reconciled again, which is
// what repairs drift made in Kuma; 0 means 5 minutes.
Resync time.Duration
// RootGroup is the group KumaMonitor that monitors without a parentRef are
// placed under; the zero value leaves them at the top level.
RootGroup types.NamespacedName
// SkipNameValidation allows several managers in one process, as in tests
// that restart the manager.
@@ -76,19 +72,12 @@ func NewManager(o Options) (manager.Manager, error) {
return nil, fmt.Errorf("create manager: %w", err)
}
if err := (&controller.Reconciler{
Client: mgr.GetClient(), Kuma: o.Kuma, Resync: o.Resync, RootGroup: o.RootGroup,
}).SetupWithManager(mgr); err != nil {
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 := (&controller.StatusPageReconciler{Client: mgr.GetClient(), Kuma: o.Kuma, Resync: o.Resync}).SetupWithManager(mgr); err != nil {
return nil, fmt.Errorf("set up status page reconciler: %w", err)
}
if err := (&controller.NotificationReconciler{
Client: mgr.GetClient(), APIReader: mgr.GetAPIReader(), Kuma: o.Kuma, Resync: o.Resync,
}).SetupWithManager(mgr); err != nil {
return nil, fmt.Errorf("set up notification reconciler: %w", err)
}
if err := mgr.Add(&controller.GC{
Client: mgr.GetClient(),
Kuma: o.Kuma,
@@ -7,6 +7,7 @@ import (
"fmt"
"io"
"path/filepath"
"slices"
"sync"
"testing"
"time"
@@ -20,7 +21,6 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/util/retry"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/envtest"
@@ -85,9 +85,6 @@ func newE2E(t *testing.T) (*e2e, *envtest.Environment) {
t.Cleanup(func() { _ = env.Stop() })
scheme := runtime.NewScheme()
if err := clientgoscheme.AddToScheme(scheme); err != nil {
t.Fatal(err)
}
if err := v1.AddToScheme(scheme); err != nil {
t.Fatal(err)
}
@@ -414,6 +411,46 @@ func (f *e2e) webhook(name string, isDefault bool) int64 {
return id
}
func TestE2EDefaultNotifications(t *testing.T) {
f, env := newE2E(t)
first := f.webhook("first", true)
f.webhook("opt-in", false)
f.start(env)
f.apply(group("g", ""), dns("probe", "g"))
carry := func(want ...int64) func() (bool, string) {
return func() (bool, string) {
m := byKey(f.owned())
for _, k := range []string{"default/g", "default/probe"} {
got, ok := m[k]
if !ok {
return false, k + " missing"
}
if !slices.Equal(got.NotificationIDs, want) {
return false, fmt.Sprintf("%s carries %v, want %v", k, got.NotificationIDs, want)
}
}
return true, ""
}
}
eventually(t, 30*time.Second, "monitors created with the default notification", carry(first))
second := f.webhook("second", true)
want := []int64{first, second}
slices.Sort(want)
eventually(t, 30*time.Second, "new default notification attached by a resync", carry(want...))
var live monitor.DNS
if err := f.raw.GetMonitorAs(t.Context(), byKey(f.owned())["default/probe"].ID, &live); err != nil {
t.Fatal(err)
}
live.NotificationIDs = nil
if err := f.raw.UpdateMonitor(t.Context(), &live); err != nil {
t.Fatal(err)
}
eventually(t, 30*time.Second, "notifications removed by hand restored by a resync", carry(want...))
}
// A monitor that lost its ownership tag in the UI must not get a twin when the
// controller restarts: the resource reports NotOwned and nothing is created.
func TestE2ETagRemovedNeverDuplicates(t *testing.T) {
@@ -17,11 +17,8 @@ import (
"sigs.k8s.io/controller-runtime/pkg/builder"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
"sigs.k8s.io/controller-runtime/pkg/event"
"sigs.k8s.io/controller-runtime/pkg/handler"
"sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/predicate"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
v1 "gitea.renner.uno/konairius/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/kumaclient"
@@ -30,16 +27,15 @@ import (
const (
Finalizer = "uptime.renner.uno/kuma-sync"
ReasonSynced = "Synced"
ReasonParentPending = "ParentPending"
ReasonInvalidParent = "InvalidParent"
ReasonOwnerTagMissing = "OwnerTagMissing"
ReasonOwnerConflict = "OwnerConflict"
ReasonDuplicateOwner = "DuplicateOwner"
ReasonKumaUnavailable = "KumaUnavailable"
ReasonPossibleOrphan = "PossibleOrphan"
ReasonNotOwned = "NotOwned"
ReasonNotificationPending = "NotificationPending"
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
@@ -58,15 +54,6 @@ type Reconciler struct {
// Resync is how often a healthy resource is reconciled again, which repairs
// drift made in Kuma. 0 means 5 minutes.
Resync time.Duration
// RootGroup is the group KumaMonitor every monitor without a parentRef is
// placed under in Kuma; the zero value places them at the top level.
RootGroup types.NamespacedName
}
func (r *Reconciler) hasRoot() bool { return r.RootGroup.Name != "" }
func (r *Reconciler) isRoot(o client.Object) bool {
return r.hasRoot() && o.GetNamespace() == r.RootGroup.Namespace && o.GetName() == r.RootGroup.Name
}
func (r *Reconciler) resync() time.Duration {
@@ -78,19 +65,12 @@ func (r *Reconciler) resync() time.Duration {
// SetupWithManager ignores status-only updates, which keeps failure backoff
// intact. Deletion still reconciles: setting deletionTimestamp bumps
// metadata.generation. A KumaNotification whose ID changes enqueues the
// monitors referencing it; a change to the root group enqueues the monitors
// without a parentRef.
// metadata.generation.
func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error {
b := ctrl.NewControllerManagedBy(mgr).
return ctrl.NewControllerManagedBy(mgr).
For(&v1.KumaMonitor{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})).
Watches(&v1.KumaNotification{}, handler.EnqueueRequestsFromMapFunc(r.referencingMonitors),
builder.WithPredicates(predicate.Funcs{UpdateFunc: notificationIDChanged}))
if r.hasRoot() {
b = b.Watches(&v1.KumaMonitor{}, handler.EnqueueRequestsFromMapFunc(r.rootChildren),
builder.WithPredicates(r.rootPredicate()))
}
return b.Named("kumamonitor").Complete(r)
Named("kumamonitor").
Complete(r)
}
// failure is a Ready=False outcome. The message is fixed per reason; the cause
@@ -102,22 +82,18 @@ type failure struct {
}
const (
msgKumaUnavailable = "Kuma request failed"
msgListIncomplete = "monitor list incomplete: owner conflict on another monitor"
msgOwnerTagMissing = "kuma-sync tag missing in Kuma"
msgOwnerConflict = "monitor carries conflicting kuma-sync tag values"
msgPossibleOrphan = "create outcome unknown, an untagged monitor may exist in Kuma"
msgDuplicateOwner = "several monitors carry this owner key; syncing the lowest ID"
msgParentPending = "parent has no monitor ID yet"
msgParentNotGroup = "parent must be of type group"
msgParentCycle = "parentRef forms a cycle"
msgParentTooDeep = "parentRef chain is too deep"
msgRootPending = "root group has no monitor ID yet"
msgRootNotGroup = "root group must be of type group"
msgRootHasParent = "root group must not have a parentRef"
msgNotificationPending = "a referenced notification has no notification ID yet"
msgSynced = "monitor in sync"
msgNotOwned = "previous monitor exists without our ownership tag; restore the tag or delete it in Kuma"
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) {
@@ -135,43 +111,29 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
}
desired := Desired(km)
var p parentOutcome
var err error
switch {
case km.Spec.ParentRef != nil:
p, err = r.resolveParent(ctx, km)
case r.hasRoot() && !r.isRoot(km):
p, err = r.resolveRoot(ctx)
}
if err != nil {
return ctrl.Result{}, err
}
if p.reason != "" {
if err := r.setReady(ctx, km, metav1.ConditionFalse, p.reason, p.message, nil); err != nil {
if km.Spec.ParentRef != nil {
p, err := r.resolveParent(ctx, km)
if err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{RequeueAfter: p.requeue}, nil
}
if p.id != 0 {
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
}
notifications, pending, err := r.resolveNotifications(ctx, km)
if err != nil {
return ctrl.Result{}, err
}
if pending {
if err := r.setReady(ctx, km, metav1.ConditionFalse, ReasonNotificationPending, msgNotificationPending, nil); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{RequeueAfter: parentRetry}, nil
}
desired.NotificationIDs = notifications
res, fail := r.lookup(ctx, desired.OwnerKey)
if fail != nil {
return r.fail(ctx, km, fail)
}
notifications, err := r.Kuma.DefaultNotifications(ctx)
if err != nil {
return r.fail(ctx, km, &failure{ReasonKumaUnavailable, msgKumaUnavailable, err})
}
desired.NotificationIDs = notifications
var id int64
if res.mon == nil {
@@ -288,57 +250,6 @@ func (r *Reconciler) lookup(ctx context.Context, key string) (lookupResult, *fai
return res, &failure{ReasonKumaUnavailable, msgListIncomplete, err}
}
// resolveNotifications returns the Kuma IDs of the referenced
// KumaNotifications. pending reports a reference that does not exist, has no
// notification ID yet or is being deleted. No references yield nil, which
// detaches every notification in Kuma.
func (r *Reconciler) resolveNotifications(ctx context.Context, km *v1.KumaMonitor) (ids []int64, pending bool, err error) {
for _, ref := range km.Spec.NotificationRefs {
kn := &v1.KumaNotification{}
err := r.Client.Get(ctx, types.NamespacedName{Name: ref.Name}, kn)
if apierrors.IsNotFound(err) {
return nil, true, nil
}
if err != nil {
return nil, false, err
}
if kn.Status.NotificationID == 0 || !kn.DeletionTimestamp.IsZero() {
return nil, true, nil
}
ids = append(ids, kn.Status.NotificationID)
}
return ids, false, nil
}
// notificationIDChanged reports changes that alter what a referencing
// monitor attaches.
func notificationIDChanged(e event.UpdateEvent) bool {
o, ok1 := e.ObjectOld.(*v1.KumaNotification)
n, ok2 := e.ObjectNew.(*v1.KumaNotification)
if !ok1 || !ok2 {
return true
}
return o.Status.NotificationID != n.Status.NotificationID ||
o.DeletionTimestamp.IsZero() != n.DeletionTimestamp.IsZero()
}
// referencingMonitors enqueues the KumaMonitors of all namespaces that
// reference the notification.
func (r *Reconciler) referencingMonitors(ctx context.Context, o client.Object) []reconcile.Request {
var list v1.KumaMonitorList
if err := r.Client.List(ctx, &list); err != nil {
log.FromContext(ctx).Error(err, "list KumaMonitors to enqueue")
return nil
}
var reqs []reconcile.Request
for _, km := range list.Items {
if slices.ContainsFunc(km.Spec.NotificationRefs, func(ref v1.NotificationRef) bool { return ref.Name == o.GetName() }) {
reqs = append(reqs, reconcile.Request{NamespacedName: types.NamespacedName{Namespace: km.Namespace, Name: km.Name}})
}
}
return reqs
}
type parentOutcome struct {
id int64
reason string // empty: resolved
@@ -346,75 +257,8 @@ type parentOutcome struct {
requeue time.Duration
}
// rootChanged reports changes to the root group that alter what the monitors
// without a parentRef resolve to.
func rootChanged(e event.UpdateEvent) bool {
o, ok1 := e.ObjectOld.(*v1.KumaMonitor)
n, ok2 := e.ObjectNew.(*v1.KumaMonitor)
if !ok1 || !ok2 {
return true
}
return o.Status.MonitorID != n.Status.MonitorID ||
o.Spec.Type != n.Spec.Type ||
(o.Spec.ParentRef == nil) != (n.Spec.ParentRef == nil) ||
o.DeletionTimestamp.IsZero() != n.DeletionTimestamp.IsZero()
}
// rootPredicate passes events of the root group only.
func (r *Reconciler) rootPredicate() predicate.Funcs {
return predicate.Funcs{
CreateFunc: func(e event.CreateEvent) bool { return r.isRoot(e.Object) },
DeleteFunc: func(e event.DeleteEvent) bool { return r.isRoot(e.Object) },
GenericFunc: func(e event.GenericEvent) bool { return r.isRoot(e.Object) },
UpdateFunc: func(e event.UpdateEvent) bool { return r.isRoot(e.ObjectNew) && rootChanged(e) },
}
}
// rootChildren enqueues the KumaMonitors of all namespaces that have no
// parentRef, except the root group itself.
func (r *Reconciler) rootChildren(ctx context.Context, _ client.Object) []reconcile.Request {
var list v1.KumaMonitorList
if err := r.Client.List(ctx, &list); err != nil {
log.FromContext(ctx).Error(err, "list KumaMonitors to enqueue")
return nil
}
var reqs []reconcile.Request
for i := range list.Items {
km := &list.Items[i]
if km.Spec.ParentRef == nil && !r.isRoot(km) {
reqs = append(reqs, reconcile.Request{NamespacedName: types.NamespacedName{Namespace: km.Namespace, Name: km.Name}})
}
}
return reqs
}
// resolveRoot returns the Kuma ID of the root group for a monitor without a
// parentRef. The root must be a group without a parentRef: under one of its
// own ancestors it would close a cycle in Kuma.
func (r *Reconciler) resolveRoot(ctx context.Context) (parentOutcome, error) {
pending := parentOutcome{reason: ReasonParentPending, message: msgRootPending, requeue: parentRetry}
root := &v1.KumaMonitor{}
err := r.Client.Get(ctx, r.RootGroup, root)
if apierrors.IsNotFound(err) {
return pending, nil
}
if err != nil {
return parentOutcome{}, err
}
switch {
case root.Spec.Type != "group":
return parentOutcome{reason: ReasonInvalidParent, message: msgRootNotGroup, requeue: r.resync()}, nil
case root.Spec.ParentRef != nil:
return parentOutcome{reason: ReasonInvalidParent, message: msgRootHasParent, requeue: r.resync()}, nil
case root.Status.MonitorID == 0 || !root.DeletionTimestamp.IsZero():
return pending, nil
}
return parentOutcome{id: root.Status.MonitorID}, nil
}
// resolveParent checks the whole parentRef chain: every ancestor must exist
// and be a group, without cycles or more than maxParentDepth levels. The root
// group, which the topmost ancestor is placed under, counts as a level.
// 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
@@ -424,8 +268,7 @@ func (r *Reconciler) resolveParent(ctx context.Context, km *v1.KumaMonitor) (par
visited := map[string]bool{km.Name: true}
cur := km
var directID int64
depth := 0
for ; cur.Spec.ParentRef != nil; depth++ {
for depth := 0; cur.Spec.ParentRef != nil; depth++ {
if depth >= maxParentDepth {
return invalid(msgParentTooDeep)
}
@@ -450,9 +293,6 @@ func (r *Reconciler) resolveParent(ctx context.Context, km *v1.KumaMonitor) (par
}
cur = parent
}
if r.hasRoot() && !r.isRoot(cur) && depth >= maxParentDepth {
return invalid(msgParentTooDeep)
}
if directID == 0 {
return pending, nil
}
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"slices"
"testing"
"time"
@@ -310,6 +311,79 @@ func TestReconcileListErrorNoCreate(t *testing.T) {
}
}
func TestReconcileCreateGetsDefaultNotifications(t *testing.T) {
group := &v1.KumaMonitor{
ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "grp"},
Spec: v1.KumaMonitorSpec{Type: "group", DisplayName: "grp", Interval: 60, RetryInterval: 60},
}
e := newEnv(t, httpMonitor("a"), group)
e.kuma.SetDefaultNotifications(7, 3)
for _, name := range []string{"a", "grp"} {
if _, err := e.reconcile(t, name); err != nil {
t.Fatal(err)
}
got := e.kuma.Get(e.get(t, name).Status.MonitorID)
if got == nil || !slices.Equal(got.NotificationIDs, []int64{3, 7}) {
t.Errorf("%s: kuma monitor = %+v, want notifications [3 7]", name, got)
}
}
}
func TestReconcileRevertsNotificationDrift(t *testing.T) {
km := httpMonitor("a")
e := newEnv(t, km)
e.kuma.SetDefaultNotifications(1, 2)
drifted := Desired(km)
drifted.NotificationIDs = []int64{2, 9}
id, _ := e.kuma.Create(t.Context(), drifted)
if _, err := e.reconcile(t, "a"); err != nil {
t.Fatal(err)
}
if e.cnt.updates != 1 || e.cnt.creates != 0 {
t.Fatalf("updates=%d creates=%d", e.cnt.updates, e.cnt.creates)
}
if got := e.kuma.Get(id).NotificationIDs; !slices.Equal(got, []int64{1, 2}) {
t.Errorf("notifications = %v, want [1 2]", got)
}
}
func TestReconcileUnchangedNotificationsNoUpdate(t *testing.T) {
km := httpMonitor("a")
e := newEnv(t, km)
e.kuma.SetDefaultNotifications(4, 2)
existing := Desired(km)
existing.NotificationIDs = []int64{4, 2}
e.kuma.Create(t.Context(), existing)
for range 2 {
if _, err := e.reconcile(t, "a"); err != nil {
t.Fatal(err)
}
}
if e.cnt.updates != 0 || e.cnt.creates != 0 {
t.Fatalf("updates=%d creates=%d, want 0 and 0", e.cnt.updates, e.cnt.creates)
}
}
func TestReconcileNotificationListErrorNoWrite(t *testing.T) {
adopt := httpMonitor("b")
e := newEnv(t, httpMonitor("a"), adopt)
drifted := Desired(adopt)
drifted.Interval = 30
e.kuma.Create(t.Context(), drifted)
e.kuma.SetNotificationsErr(errors.New("boom"))
for _, name := range []string{"a", "b"} {
if _, err := e.reconcile(t, name); err == nil {
t.Fatalf("%s: expected error", name)
}
if c := ready(t, e.get(t, name)); c.Status != metav1.ConditionFalse || c.Reason != ReasonKumaUnavailable {
t.Errorf("%s: Ready = %+v", name, c)
}
}
if e.cnt.creates != 0 || e.cnt.updates != 0 {
t.Errorf("creates=%d updates=%d", e.cnt.creates, e.cnt.updates)
}
}
func TestReconcileOwnerTagMissing(t *testing.T) {
e := newEnv(t, httpMonitor("a"))
e.kuma.SetErrs(kumaclient.ErrOwnerTagMissing, nil, nil, nil)
@@ -24,15 +24,14 @@ func OwnerKey(km *v1.KumaMonitor) string {
func Desired(km *v1.KumaMonitor) kumaclient.Monitor {
s := km.Spec
m := kumaclient.Monitor{
Type: s.Type,
Name: s.DisplayName,
Description: s.Description,
Active: !s.Paused,
Interval: s.Interval,
RetryInterval: s.RetryInterval,
MaxRetries: s.MaxRetries,
ResendInterval: s.ResendInterval,
OwnerKey: OwnerKey(km),
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
@@ -57,8 +57,6 @@ func TestMappingAllTypes(t *testing.T) {
port.Port = &v1.PortSpec{Hostname: "p.com", Port: 443}
paused := base("group")
paused.Paused = true
resend := base("group")
resend.ResendInterval = 60
wantKW := withHTTP(common("keyword"))
wantKW.Keyword, wantKW.InvertKeyword = "ok", true
@@ -76,8 +74,6 @@ func TestMappingAllTypes(t *testing.T) {
wantPort.Hostname, wantPort.Port = "p.com", 443
wantPaused := common("group")
wantPaused.Active = false
wantResend := common("group")
wantResend.ResendInterval = 60
cases := []struct {
name string
@@ -92,7 +88,6 @@ func TestMappingAllTypes(t *testing.T) {
{"dns with conditions", dnsCond, wantDNSCond},
{"port", port, wantPort},
{"paused maps to inactive", paused, wantPaused},
{"resend interval", resend, wantResend},
}
for _, c := range cases {
got := Desired(mk(c.spec))
@@ -1,240 +0,0 @@
package controller
import (
"context"
"errors"
"reflect"
"strconv"
"strings"
"time"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/builder"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
"sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/predicate"
v1 "gitea.renner.uno/konairius/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/kumaclient"
)
const (
ReasonSecretMissing = "SecretMissing"
ReasonInvalidSecret = "InvalidSecret"
ReasonInvalidSpec = "InvalidSpec"
msgNotificationSynced = "notification in sync"
msgDuplicateNotification = "several notifications carry this name; syncing the lowest ID"
msgSecretMissing = "secret not found or not readable"
msgSecretKeyMissing = "secret lacks host, port, username or password"
msgInvalidSecretPort = "secret port is not a number from 1 to 65535"
msgInvalidType = "notification type is not supported"
// smtpsPort is the port that uses implicit TLS; any other uses STARTTLS.
smtpsPort = 465
)
// smtpSecretKeys are the keys the SMTP Secret must hold, none empty.
var smtpSecretKeys = []string{"host", "port", "username", "password"}
// +kubebuilder:rbac:groups=uptime.renner.uno,resources=kumanotifications,verbs=get;list;watch;update;patch
// +kubebuilder:rbac:groups=uptime.renner.uno,resources=kumanotifications/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=uptime.renner.uno,resources=kumanotifications/finalizers,verbs=update
// NotificationReconciler syncs KumaNotification resources to Kuma. The Kuma
// notification is owned by name: the lowest-ID notification with the
// resource's name is adopted, whoever made it.
type NotificationReconciler struct {
Client client.Client
// APIReader reads the SMTP Secret uncached, so no Secret watch is needed:
// only get on the referenced Secret.
APIReader client.Reader
Kuma kumaclient.Notifications
// Resync is how often a resource is reconciled again, which reverts edits
// made in Kuma and picks up Secret changes. 0 means 5 minutes.
Resync time.Duration
}
func (r *NotificationReconciler) resync() time.Duration {
if r.Resync > 0 {
return r.Resync
}
return resyncPeriod
}
// SetupWithManager ignores status-only updates. Secrets are not watched.
func (r *NotificationReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&v1.KumaNotification{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})).
Named("kumanotification").
Complete(r)
}
// specProblem is a Ready=False outcome that waits for a resync.
type specProblem struct {
reason string
message string
}
func (r *NotificationReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
kn := &v1.KumaNotification{}
if err := r.Client.Get(ctx, req.NamespacedName, kn); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
if !kn.DeletionTimestamp.IsZero() {
return r.reconcileDelete(ctx, kn)
}
if controllerutil.AddFinalizer(kn, Finalizer) {
if err := r.Client.Update(ctx, kn); err != nil {
return ctrl.Result{}, err
}
}
desired, problem, err := r.desired(ctx, kn)
if err != nil {
return ctrl.Result{}, err
}
if problem != nil {
err := r.setReady(ctx, kn, metav1.ConditionFalse, problem.reason, problem.message, nil)
return ctrl.Result{RequeueAfter: r.resync()}, err
}
list, err := r.Kuma.ListNotifications(ctx, kn.Name)
if err != nil {
return r.fail(ctx, kn, &failure{ReasonKumaUnavailable, msgKumaUnavailable, err})
}
var id int64
if len(list) == 0 {
id, err = r.Kuma.CreateNotification(ctx, desired)
if err != nil {
return r.fail(ctx, kn, &failure{ReasonKumaUnavailable, msgKumaUnavailable, err})
}
} else {
id = list[0].ID
desired.ID = id
if !desired.SameContent(list[0]) {
log.FromContext(ctx).Info("notification differs from spec, updating", "notificationID", id)
if err := r.Kuma.UpdateNotification(ctx, desired); err != nil {
return r.fail(ctx, kn, &failure{ReasonKumaUnavailable, msgKumaUnavailable, err})
}
}
}
if len(list) > 1 {
ids := make([]int64, 0, len(list))
for _, n := range list {
ids = append(ids, n.ID)
}
log.FromContext(ctx).Error(nil, "several Kuma notifications carry the same name, syncing the lowest ID",
"name", kn.Name, "notificationIDs", ids)
err := r.setReady(ctx, kn, metav1.ConditionFalse, ReasonDuplicateOwner, msgDuplicateNotification, &id)
return ctrl.Result{RequeueAfter: r.resync()}, err
}
if err := r.setReady(ctx, kn, metav1.ConditionTrue, ReasonSynced, msgNotificationSynced, &id); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{RequeueAfter: r.resync()}, nil
}
// reconcileDelete removes every notification carrying the name, duplicates
// included. Kuma detaches them from the monitors.
func (r *NotificationReconciler) reconcileDelete(ctx context.Context, kn *v1.KumaNotification) (ctrl.Result, error) {
if !controllerutil.ContainsFinalizer(kn, Finalizer) {
return ctrl.Result{}, nil
}
list, err := r.Kuma.ListNotifications(ctx, kn.Name)
if err != nil {
return r.fail(ctx, kn, &failure{ReasonKumaUnavailable, msgKumaUnavailable, err})
}
for _, n := range list {
err := r.Kuma.DeleteNotification(ctx, n.ID)
if err != nil && !errors.Is(err, kumaclient.ErrNotificationNotFound) {
return r.fail(ctx, kn, &failure{ReasonKumaUnavailable, msgKumaUnavailable, err})
}
}
controllerutil.RemoveFinalizer(kn, Finalizer)
return ctrl.Result{}, r.Client.Update(ctx, kn)
}
// desired builds the notification from the spec and the SMTP Secret. A
// missing or invalid Secret is a problem, not an error; the cause is never
// logged, as it may carry Secret data.
func (r *NotificationReconciler) desired(
ctx context.Context, kn *v1.KumaNotification,
) (kumaclient.Notification, *specProblem, error) {
s := kn.Spec.SMTP
if kn.Spec.Type != "smtp" || s == nil {
return kumaclient.Notification{}, &specProblem{ReasonInvalidSpec, msgInvalidType}, nil
}
secret := &corev1.Secret{}
err := r.APIReader.Get(ctx, types.NamespacedName{Namespace: s.SecretRef.Namespace, Name: s.SecretRef.Name}, secret)
if apierrors.IsNotFound(err) || apierrors.IsForbidden(err) {
return kumaclient.Notification{}, &specProblem{ReasonSecretMissing, msgSecretMissing}, nil
}
if err != nil {
return kumaclient.Notification{}, nil, err
}
for _, k := range smtpSecretKeys {
if len(secret.Data[k]) == 0 {
return kumaclient.Notification{}, &specProblem{ReasonSecretMissing, msgSecretKeyMissing}, nil
}
}
port, err := strconv.Atoi(strings.TrimSpace(string(secret.Data["port"])))
if err != nil || port < 1 || port > 65535 {
return kumaclient.Notification{}, &specProblem{ReasonInvalidSecret, msgInvalidSecretPort}, nil
}
return kumaclient.Notification{
Name: kn.Name,
Type: "smtp",
Active: true,
SMTP: kumaclient.SMTP{
Host: strings.TrimSpace(string(secret.Data["host"])),
Port: int32(port),
Secure: port == smtpsPort,
Username: string(secret.Data["username"]),
Password: string(secret.Data["password"]),
From: s.From,
To: s.To,
},
}, nil, nil
}
// fail records a Ready=False outcome and returns its cause so the request is
// requeued with backoff.
func (r *NotificationReconciler) fail(ctx context.Context, kn *v1.KumaNotification, f *failure) (ctrl.Result, error) {
log.FromContext(ctx).Info("reconcile failed", "reason", f.reason, "error", f.err.Error())
if serr := r.setReady(ctx, kn, metav1.ConditionFalse, f.reason, f.message, nil); serr != nil {
return ctrl.Result{}, errors.Join(f.err, serr)
}
return ctrl.Result{}, f.err
}
// setReady writes the Ready condition, and the notification ID when given,
// through the status subresource. It writes nothing if the status is
// unchanged.
func (r *NotificationReconciler) setReady(
ctx context.Context, kn *v1.KumaNotification, status metav1.ConditionStatus, reason, msg string, id *int64,
) error {
orig := kn.Status.DeepCopy()
if id != nil {
kn.Status.NotificationID = *id
kn.Status.ObservedGeneration = kn.Generation
}
meta.SetStatusCondition(&kn.Status.Conditions, metav1.Condition{
Type: v1.ConditionReady,
Status: status,
Reason: reason,
Message: msg,
ObservedGeneration: kn.Generation,
})
if reflect.DeepEqual(*orig, kn.Status) {
return nil
}
return r.Client.Status().Update(ctx, kn)
}
@@ -1,278 +0,0 @@
package controller
import (
"context"
"errors"
"testing"
"time"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
v1 "gitea.renner.uno/konairius/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/kumaclient"
)
// countingNotifs records notification writes on top of the Kuma fake.
type countingNotifs struct {
kumaclient.Notifications
creates, updates, deletes int
}
func (c *countingNotifs) CreateNotification(ctx context.Context, n kumaclient.Notification) (int64, error) {
c.creates++
return c.Notifications.CreateNotification(ctx, n)
}
func (c *countingNotifs) UpdateNotification(ctx context.Context, n kumaclient.Notification) error {
c.updates++
return c.Notifications.UpdateNotification(ctx, n)
}
func (c *countingNotifs) DeleteNotification(ctx context.Context, id int64) error {
c.deletes++
return c.Notifications.DeleteNotification(ctx, id)
}
type notifEnv struct {
r *NotificationReconciler
k8s client.Client
kuma *kumaclient.Fake
cnt *countingNotifs
}
func newNotifEnv(t *testing.T, objs ...client.Object) *notifEnv {
t.Helper()
s := runtime.NewScheme()
if err := clientgoscheme.AddToScheme(s); err != nil {
t.Fatal(err)
}
if err := v1.AddToScheme(s); err != nil {
t.Fatal(err)
}
k8s := fake.NewClientBuilder().WithScheme(s).WithObjects(objs...).
WithStatusSubresource(&v1.KumaNotification{}).Build()
kf := kumaclient.NewFake()
cnt := &countingNotifs{Notifications: kf}
return &notifEnv{
r: &NotificationReconciler{Client: k8s, APIReader: k8s, Kuma: cnt},
k8s: k8s, kuma: kf, cnt: cnt,
}
}
func smtpNotification(name string) *v1.KumaNotification {
return &v1.KumaNotification{
ObjectMeta: metav1.ObjectMeta{Name: name},
Spec: v1.KumaNotificationSpec{Type: "smtp", SMTP: &v1.SMTPSpec{
From: "kuma@renner.uno", To: "ops@renner.uno",
SecretRef: v1.SecretRef{Namespace: "kuma-sync", Name: "smtp"},
}},
}
}
func smtpSecret(port string) *corev1.Secret {
return &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Namespace: "kuma-sync", Name: "smtp"},
Data: map[string][]byte{
"host": []byte("mail.example.com\n"), "port": []byte(port),
"username": []byte("kuma"), "password": []byte("s3cret"),
},
}
}
func wantSMTP(name string, port int32, secure bool) kumaclient.Notification {
return kumaclient.Notification{
Name: name, Type: "smtp", Active: true,
SMTP: kumaclient.SMTP{
Host: "mail.example.com", Port: port, Secure: secure,
Username: "kuma", Password: "s3cret", From: "kuma@renner.uno", To: "ops@renner.uno",
},
}
}
func (e *notifEnv) reconcile(t *testing.T, name string) (ctrl.Result, error) {
t.Helper()
return e.r.Reconcile(t.Context(), ctrl.Request{NamespacedName: types.NamespacedName{Name: name}})
}
func (e *notifEnv) get(t *testing.T, name string) *v1.KumaNotification {
t.Helper()
kn := &v1.KumaNotification{}
if err := e.k8s.Get(t.Context(), types.NamespacedName{Name: name}, kn); err != nil {
t.Fatal(err)
}
return kn
}
func notifReady(t *testing.T, kn *v1.KumaNotification) *metav1.Condition {
t.Helper()
for i := range kn.Status.Conditions {
if kn.Status.Conditions[i].Type == v1.ConditionReady {
return &kn.Status.Conditions[i]
}
}
t.Fatal("no Ready condition")
return nil
}
func TestNotificationCreates(t *testing.T) {
for port, secure := range map[string]bool{"587": false, "465": true, " 25\n": false} {
e := newNotifEnv(t, smtpNotification("mail"), smtpSecret(port))
res, err := e.reconcile(t, "mail")
if err != nil {
t.Fatal(err)
}
if res.RequeueAfter != 5*time.Minute {
t.Errorf("RequeueAfter = %v", res.RequeueAfter)
}
kn := e.get(t, "mail")
if kn.Status.NotificationID == 0 || kn.Status.ObservedGeneration != kn.Generation || len(kn.Finalizers) != 1 {
t.Fatalf("resource = %+v", kn)
}
if c := notifReady(t, kn); c.Status != metav1.ConditionTrue || c.Reason != ReasonSynced {
t.Errorf("Ready = %+v", c)
}
want := wantSMTP("mail", map[string]int32{"587": 587, "465": 465, " 25\n": 25}[port], secure)
want.ID = kn.Status.NotificationID
if got := e.kuma.Notification(kn.Status.NotificationID); got == nil || *got != want {
t.Errorf("port %q: kuma notification = %+v, want %+v", port, got, want)
}
}
}
func TestNotificationAdoptsLowestAndRevertsDrift(t *testing.T) {
e := newNotifEnv(t, smtpNotification("email"), smtpSecret("587"))
drifted := wantSMTP("email", 25, false)
drifted.IsDefault = true
drifted.SMTP.Password = "old"
first := e.kuma.PutNotification(drifted)
if _, err := e.reconcile(t, "email"); err != nil {
t.Fatal(err)
}
if e.cnt.creates != 0 || e.cnt.updates != 1 {
t.Fatalf("creates=%d updates=%d, want 0 and 1", e.cnt.creates, e.cnt.updates)
}
want := wantSMTP("email", 587, false)
want.ID = first
if got := e.kuma.Notification(first); *got != want {
t.Errorf("kuma notification = %+v, want %+v", *got, want)
}
if got := e.get(t, "email").Status.NotificationID; got != first {
t.Errorf("notificationID = %d, want %d", got, first)
}
if _, err := e.reconcile(t, "email"); err != nil {
t.Fatal(err)
}
if e.cnt.updates != 1 {
t.Errorf("updates = %d after a second reconcile, want 1", e.cnt.updates)
}
}
func TestNotificationDuplicateName(t *testing.T) {
e := newNotifEnv(t, smtpNotification("email"), smtpSecret("587"))
first := e.kuma.PutNotification(wantSMTP("email", 587, false))
second := e.kuma.PutNotification(wantSMTP("email", 25, false))
if _, err := e.reconcile(t, "email"); err != nil {
t.Fatal(err)
}
kn := e.get(t, "email")
if kn.Status.NotificationID != first {
t.Errorf("notificationID = %d, want lowest %d", kn.Status.NotificationID, first)
}
if c := notifReady(t, kn); c.Status != metav1.ConditionFalse || c.Reason != ReasonDuplicateOwner {
t.Errorf("Ready = %+v", c)
}
if e.cnt.updates != 0 || e.kuma.Notification(second).SMTP.Port != 25 {
t.Error("only the lowest ID may be synced")
}
}
func TestNotificationSecretProblems(t *testing.T) {
noKey := smtpSecret("587")
delete(noKey.Data, "password")
for name, tc := range map[string]struct {
secret client.Object
reason string
message string
}{
"missing secret": {nil, ReasonSecretMissing, "secret not found or not readable"},
"missing key": {noKey, ReasonSecretMissing, "secret lacks host, port, username or password"},
"bad port": {smtpSecret("smtp"), ReasonInvalidSecret, "secret port is not a number from 1 to 65535"},
"port too large": {smtpSecret("70000"), ReasonInvalidSecret, "secret port is not a number from 1 to 65535"},
} {
objs := []client.Object{smtpNotification("mail")}
if tc.secret != nil {
objs = append(objs, tc.secret)
}
e := newNotifEnv(t, objs...)
res, err := e.reconcile(t, "mail")
if err != nil {
t.Fatalf("%s: %v", name, err)
}
if res.RequeueAfter != 5*time.Minute {
t.Errorf("%s: RequeueAfter = %v", name, res.RequeueAfter)
}
if c := notifReady(t, e.get(t, "mail")); c.Status != metav1.ConditionFalse || c.Reason != tc.reason || c.Message != tc.message {
t.Errorf("%s: Ready = %+v", name, c)
}
if e.cnt.creates != 0 || e.cnt.updates != 0 {
t.Errorf("%s: creates=%d updates=%d", name, e.cnt.creates, e.cnt.updates)
}
}
}
func TestNotificationKumaErrorNoWrite(t *testing.T) {
e := newNotifEnv(t, smtpNotification("mail"), smtpSecret("587"))
e.kuma.SetNotifErr(errors.New("boom"))
if _, err := e.reconcile(t, "mail"); err == nil {
t.Fatal("expected error")
}
if c := notifReady(t, e.get(t, "mail")); c.Reason != ReasonKumaUnavailable || c.Message != msgKumaUnavailable {
t.Errorf("Ready = %+v", c)
}
}
func TestNotificationDeleteRemovesAll(t *testing.T) {
kn := smtpNotification("email")
now := metav1.Now()
kn.DeletionTimestamp = &now
kn.Finalizers = []string{Finalizer}
e := newNotifEnv(t, kn)
first := e.kuma.PutNotification(wantSMTP("email", 587, false))
second := e.kuma.PutNotification(wantSMTP("email", 25, false))
other := e.kuma.PutNotification(wantSMTP("other", 25, false))
if _, err := e.reconcile(t, "email"); err != nil {
t.Fatal(err)
}
if e.kuma.Notification(first) != nil || e.kuma.Notification(second) != nil {
t.Error("every notification with the name must be deleted")
}
if e.kuma.Notification(other) == nil {
t.Error("other notification deleted")
}
if err := e.k8s.Get(t.Context(), client.ObjectKeyFromObject(kn), &v1.KumaNotification{}); !apierrors.IsNotFound(err) {
t.Errorf("finalizer not removed: %v", err)
}
}
func TestNotificationDeleteKumaErrorKeepsFinalizer(t *testing.T) {
kn := smtpNotification("email")
now := metav1.Now()
kn.DeletionTimestamp = &now
kn.Finalizers = []string{Finalizer}
e := newNotifEnv(t, kn)
e.kuma.SetNotifErr(errors.New("boom"))
if _, err := e.reconcile(t, "email"); err == nil {
t.Fatal("expected error")
}
if got := e.get(t, "email").Finalizers; len(got) != 1 {
t.Errorf("finalizers = %v", got)
}
}
@@ -1,98 +0,0 @@
//go:build integration
package controller_test
import (
"fmt"
"slices"
"testing"
"time"
"github.com/breml/go-uptime-kuma-client/monitor"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
v1 "gitea.renner.uno/konairius/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/kumaclient"
)
// A KumaNotification adopts the hand-made notification of its name, and only
// monitors referencing it carry it; a default notification is never attached.
func TestE2ENotificationRefs(t *testing.T) {
f, env := newE2E(t)
legacy := f.webhook("legacy", true)
adopted := f.webhook("mail", false)
f.start(env)
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "smtp"},
Data: map[string][]byte{
"host": []byte("mail.example.com"), "port": []byte("587"),
"username": []byte("kuma"), "password": []byte("s3cret"),
},
}
kn := &v1.KumaNotification{
ObjectMeta: metav1.ObjectMeta{Name: "mail"},
Spec: v1.KumaNotificationSpec{Type: "smtp", SMTP: &v1.SMTPSpec{
From: "kuma@example.com", To: "ops@example.com",
SecretRef: v1.SecretRef{Namespace: "default", Name: "smtp"},
}},
}
if err := f.k8s.Create(t.Context(), secret); err != nil {
t.Fatal(err)
}
if err := f.k8s.Create(t.Context(), kn); err != nil {
t.Fatal(err)
}
g := group("g", "")
g.Spec.NotificationRefs = []v1.NotificationRef{{Name: "mail"}}
f.apply(g, dns("probe", "g"))
want := kumaclient.Notification{
ID: adopted, Name: "mail", Type: "smtp", Active: true,
SMTP: kumaclient.SMTP{
Host: "mail.example.com", Port: 587, Username: "kuma", Password: "s3cret",
From: "kuma@example.com", To: "ops@example.com",
},
}
eventually(t, 30*time.Second, "notification adopted and converted", func() (bool, string) {
got, err := f.census.ListNotifications(t.Context(), "mail")
if err != nil {
return false, err.Error()
}
cur := &v1.KumaNotification{}
if err := f.k8s.Get(t.Context(), types.NamespacedName{Name: "mail"}, cur); err != nil {
return false, err.Error()
}
if len(got) != 1 || got[0] != want || cur.Status.NotificationID != adopted {
return false, fmt.Sprintf("kuma %+v, status ID %d", got, cur.Status.NotificationID)
}
return true, ""
})
carry := func(key string, want ...int64) func() (bool, string) {
return func() (bool, string) {
got, ok := byKey(f.owned())[key]
if !ok {
return false, key + " missing"
}
if !slices.Equal(got.NotificationIDs, want) {
return false, fmt.Sprintf("%s carries %v, want %v", key, got.NotificationIDs, want)
}
return true, ""
}
}
eventually(t, 30*time.Second, "group carries the referenced notification", carry("default/g", adopted))
eventually(t, 30*time.Second, "child carries none", carry("default/probe"))
var live monitor.Group
if err := f.raw.GetMonitorAs(t.Context(), byKey(f.owned())["default/g"].ID, &live); err != nil {
t.Fatal(err)
}
live.NotificationIDs = []int64{legacy}
if err := f.raw.UpdateMonitor(t.Context(), &live); err != nil {
t.Fatal(err)
}
eventually(t, 30*time.Second, "notifications changed by hand restored by a resync", carry("default/g", adopted))
}
@@ -1,160 +0,0 @@
package controller
import (
"slices"
"testing"
"time"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/event"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
v1 "gitea.renner.uno/konairius/homelab/kuma-sync/api/v1alpha1"
)
func kumaNotification(name string, id int64) *v1.KumaNotification {
return &v1.KumaNotification{
ObjectMeta: metav1.ObjectMeta{Name: name},
Spec: v1.KumaNotificationSpec{Type: "smtp"},
Status: v1.KumaNotificationStatus{NotificationID: id},
}
}
func withRefs(km *v1.KumaMonitor, names ...string) *v1.KumaMonitor {
for _, n := range names {
km.Spec.NotificationRefs = append(km.Spec.NotificationRefs, v1.NotificationRef{Name: n})
}
return km
}
func TestReconcileAttachesReferencedNotifications(t *testing.T) {
group := withRefs(&v1.KumaMonitor{
ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "grp"},
Spec: v1.KumaMonitorSpec{Type: "group", DisplayName: "grp", Interval: 60, RetryInterval: 60},
}, "mail", "ops")
e := newEnv(t, withRefs(httpMonitor("a"), "ops"), group, kumaNotification("mail", 7), kumaNotification("ops", 3))
for name, want := range map[string][]int64{"a": {3}, "grp": {3, 7}} {
if _, err := e.reconcile(t, name); err != nil {
t.Fatal(err)
}
got := e.kuma.Get(e.get(t, name).Status.MonitorID)
if got == nil || !slices.Equal(got.NotificationIDs, want) {
t.Errorf("%s: kuma monitor = %+v, want notifications %v", name, got, want)
}
}
}
func TestReconcileNotificationPending(t *testing.T) {
deleted := kumaNotification("going", 5)
now := metav1.Now()
deleted.DeletionTimestamp = &now
deleted.Finalizers = []string{Finalizer}
e := newEnv(t,
withRefs(httpMonitor("missing"), "nope"),
withRefs(httpMonitor("unsynced"), "new"), kumaNotification("new", 0),
withRefs(httpMonitor("deleting"), "going"), deleted,
)
for _, name := range []string{"missing", "unsynced", "deleting"} {
res, err := e.reconcile(t, name)
if err != nil {
t.Fatalf("%s: %v", name, err)
}
if res.RequeueAfter != 10*time.Second {
t.Errorf("%s: RequeueAfter = %v", name, res.RequeueAfter)
}
c := ready(t, e.get(t, name))
if c.Status != metav1.ConditionFalse || c.Reason != ReasonNotificationPending ||
c.Message != "a referenced notification has no notification ID yet" {
t.Errorf("%s: Ready = %+v", name, c)
}
}
if e.cnt.creates != 0 || e.cnt.updates != 0 {
t.Errorf("creates=%d updates=%d", e.cnt.creates, e.cnt.updates)
}
}
func TestReconcileNoRefsDetachesNotifications(t *testing.T) {
km := httpMonitor("a")
e := newEnv(t, km)
attached := Desired(km)
attached.NotificationIDs = []int64{2, 9}
id, _ := e.kuma.Create(t.Context(), attached)
if _, err := e.reconcile(t, "a"); err != nil {
t.Fatal(err)
}
if e.cnt.updates != 1 {
t.Fatalf("updates = %d", e.cnt.updates)
}
if got := e.kuma.Get(id).NotificationIDs; got != nil {
t.Errorf("notifications = %v, want none", got)
}
}
func TestReconcileRevertsNotificationDrift(t *testing.T) {
km := withRefs(httpMonitor("a"), "mail")
e := newEnv(t, km, kumaNotification("mail", 1))
drifted := Desired(km)
drifted.NotificationIDs = []int64{2, 9}
id, _ := e.kuma.Create(t.Context(), drifted)
if _, err := e.reconcile(t, "a"); err != nil {
t.Fatal(err)
}
if e.cnt.updates != 1 || e.cnt.creates != 0 {
t.Fatalf("updates=%d creates=%d", e.cnt.updates, e.cnt.creates)
}
if got := e.kuma.Get(id).NotificationIDs; !slices.Equal(got, []int64{1}) {
t.Errorf("notifications = %v, want [1]", got)
}
}
func TestReconcileUnchangedNotificationsNoUpdate(t *testing.T) {
km := withRefs(httpMonitor("a"), "mail", "ops")
e := newEnv(t, km, kumaNotification("mail", 4), kumaNotification("ops", 2))
existing := Desired(km)
existing.NotificationIDs = []int64{4, 2}
e.kuma.Create(t.Context(), existing)
for range 2 {
if _, err := e.reconcile(t, "a"); err != nil {
t.Fatal(err)
}
}
if e.cnt.updates != 0 || e.cnt.creates != 0 {
t.Fatalf("updates=%d creates=%d, want 0 and 0", e.cnt.updates, e.cnt.creates)
}
}
func TestReferencingMonitors(t *testing.T) {
other := withRefs(httpMonitor("b"), "mail")
other.Namespace = "other"
e := newEnv(t, withRefs(httpMonitor("a"), "ops", "mail"), other, httpMonitor("none"), withRefs(httpMonitor("c"), "ops"))
got := e.r.referencingMonitors(t.Context(), kumaNotification("mail", 1))
want := []reconcile.Request{
{NamespacedName: types.NamespacedName{Namespace: "default", Name: "a"}},
{NamespacedName: types.NamespacedName{Namespace: "other", Name: "b"}},
}
if !slices.Equal(got, want) {
t.Errorf("requests = %v, want %v", got, want)
}
}
func TestNotificationIDChanged(t *testing.T) {
base := kumaNotification("mail", 1)
changed := base.DeepCopy()
changed.Status.NotificationID = 2
cond := base.DeepCopy()
cond.Status.Conditions = []metav1.Condition{{Type: v1.ConditionReady, Status: metav1.ConditionTrue}}
deleting := base.DeepCopy()
now := metav1.Now()
deleting.DeletionTimestamp = &now
for name, tc := range map[string]struct {
n *v1.KumaNotification
want bool
}{
"id changed": {changed, true}, "condition only": {cond, false}, "deleting": {deleting, true},
} {
if got := notificationIDChanged(event.UpdateEvent{ObjectOld: base, ObjectNew: tc.n}); got != tc.want {
t.Errorf("%s: %v, want %v", name, got, tc.want)
}
}
}
@@ -1,209 +0,0 @@
package controller
import (
"fmt"
"slices"
"testing"
"time"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/event"
v1 "gitea.renner.uno/konairius/homelab/kuma-sync/api/v1alpha1"
)
const rootID = 7
var rootName = types.NamespacedName{Namespace: "default", Name: "root"}
func rootGroup() *v1.KumaMonitor {
g := group("root", "")
g.Status.MonitorID = rootID
return g
}
func newRootEnv(t *testing.T, objs ...client.Object) *env {
t.Helper()
e := newEnv(t, objs...)
e.r.RootGroup = rootName
return e
}
func (e *env) parentOf(t *testing.T, name string) *int64 {
t.Helper()
got := e.kuma.Get(e.get(t, name).Status.MonitorID)
if got == nil {
t.Fatalf("%s: no Kuma monitor", name)
}
return got.Parent
}
func TestRootGroupParentsParentless(t *testing.T) {
e := newRootEnv(t, rootGroup(), httpMonitor("a"))
if _, err := e.reconcile(t, "a"); err != nil {
t.Fatal(err)
}
if p := e.parentOf(t, "a"); p == nil || *p != rootID {
t.Errorf("parent = %v, want %d", p, rootID)
}
}
func TestRootGroupKeepsParentRef(t *testing.T) {
grp := group("grp", "")
grp.Status.MonitorID = 42
child := httpMonitor("child")
child.Spec.ParentRef = &v1.LocalRef{Name: "grp"}
e := newRootEnv(t, rootGroup(), grp, child)
if _, err := e.reconcile(t, "child"); err != nil {
t.Fatal(err)
}
if p := e.parentOf(t, "child"); p == nil || *p != 42 {
t.Errorf("parent = %v, want 42", p)
}
}
func TestRootGroupItselfHasNoParent(t *testing.T) {
root := rootGroup()
root.Status.MonitorID = 0
e := newRootEnv(t, root)
if _, err := e.reconcile(t, "root"); err != nil {
t.Fatal(err)
}
if p := e.parentOf(t, "root"); p != nil {
t.Errorf("parent = %d, want none", *p)
}
}
func TestRootGroupUnsetLeavesTopLevel(t *testing.T) {
e := newEnv(t, rootGroup(), httpMonitor("a"))
if _, err := e.reconcile(t, "a"); err != nil {
t.Fatal(err)
}
if p := e.parentOf(t, "a"); p != nil {
t.Errorf("parent = %d, want none", *p)
}
}
func TestRootGroupPending(t *testing.T) {
noID := rootGroup()
noID.Status.MonitorID = 0
deletingRoot := deleting(rootGroup())
cases := map[string][]client.Object{
"missing": nil,
"no ID": {noID},
"deleting": {deletingRoot},
"not group": {func() *v1.KumaMonitor { m := httpMonitor("root"); m.Status.MonitorID = rootID; return m }()},
"has parent": {func() *v1.KumaMonitor {
m := rootGroup()
m.Spec.ParentRef = &v1.LocalRef{Name: "x"}
return m
}()},
}
want := map[string]struct {
reason string
requeue time.Duration
}{
"missing": {ReasonParentPending, 10 * time.Second},
"no ID": {ReasonParentPending, 10 * time.Second},
"deleting": {ReasonParentPending, 10 * time.Second},
"not group": {ReasonInvalidParent, 5 * time.Minute},
"has parent": {ReasonInvalidParent, 5 * time.Minute},
}
for name, objs := range cases {
e := newRootEnv(t, append(objs, httpMonitor("a"))...)
res, err := e.reconcile(t, "a")
if err != nil {
t.Fatalf("%s: %v", name, err)
}
if res.RequeueAfter != want[name].requeue {
t.Errorf("%s: RequeueAfter = %v, want %v", name, res.RequeueAfter, want[name].requeue)
}
if c := ready(t, e.get(t, "a")); c.Status != metav1.ConditionFalse || c.Reason != want[name].reason {
t.Errorf("%s: Ready = %+v", name, c)
}
if e.cnt.creates != 0 {
t.Errorf("%s: creates = %d", name, e.cnt.creates)
}
}
}
// A chain of maxParentDepth groups is allowed on its own, but the root group
// adds one more level above it.
func TestRootGroupCountsTowardsDepth(t *testing.T) {
var objs []client.Object
for i := range maxParentDepth {
parent := ""
if i < maxParentDepth-1 {
parent = fmt.Sprintf("c%d", i+1)
}
objs = append(objs, group(fmt.Sprintf("c%d", i), parent))
}
deep := httpMonitor("deep")
deep.Spec.ParentRef = &v1.LocalRef{Name: "c0"}
objs = append(objs, deep)
if _, err := newEnv(t, objs...).reconcile(t, "deep"); err != nil {
t.Fatal(err)
}
e := newRootEnv(t, append(objs, rootGroup())...)
if _, err := e.reconcile(t, "deep"); err != nil {
t.Fatal(err)
}
if c := ready(t, e.get(t, "deep")); c.Reason != ReasonInvalidParent || c.Message != msgParentTooDeep {
t.Errorf("Ready = %+v", c)
}
// A chain ending in the root itself gets no extra level.
objs[maxParentDepth-1] = func() *v1.KumaMonitor { g := rootGroup(); g.Name = "c9"; return g }()
e = newEnv(t, objs...)
e.r.RootGroup = types.NamespacedName{Namespace: "default", Name: "c9"}
if _, err := e.reconcile(t, "deep"); err != nil {
t.Fatal(err)
}
if c := ready(t, e.get(t, "deep")); c.Status != metav1.ConditionTrue {
t.Errorf("chain ending in root: Ready = %+v", c)
}
}
func TestRootChildrenEnqueuesParentless(t *testing.T) {
child := httpMonitor("child")
child.Spec.ParentRef = &v1.LocalRef{Name: "grp"}
other := httpMonitor("other")
other.Namespace = "elsewhere"
e := newRootEnv(t, rootGroup(), group("grp", ""), child, httpMonitor("a"), other)
var got []string
for _, req := range e.r.rootChildren(t.Context(), rootGroup()) {
got = append(got, req.String())
}
slices.Sort(got)
want := []string{"default/a", "default/grp", "elsewhere/other"}
if !slices.Equal(got, want) {
t.Errorf("enqueued %v, want %v", got, want)
}
}
func TestRootPredicate(t *testing.T) {
r := &Reconciler{RootGroup: rootName}
p := r.rootPredicate()
old := rootGroup()
old.Status.MonitorID = 0
withID := rootGroup()
other := group("other", "")
if !p.Update(event.UpdateEvent{ObjectOld: old, ObjectNew: withID}) {
t.Error("root ID change must pass")
}
if p.Update(event.UpdateEvent{ObjectOld: withID, ObjectNew: withID.DeepCopy()}) {
t.Error("unchanged root must not pass")
}
if p.Update(event.UpdateEvent{ObjectOld: other, ObjectNew: other.DeepCopy()}) || p.Create(event.CreateEvent{Object: other}) {
t.Error("other monitors must not pass")
}
if !p.Create(event.CreateEvent{Object: withID}) || !p.Delete(event.DeleteEvent{Object: withID}) {
t.Error("root create and delete must pass")
}
}
@@ -13,6 +13,7 @@ import (
kuma "github.com/breml/go-uptime-kuma-client"
"github.com/breml/go-uptime-kuma-client/monitor"
"github.com/breml/go-uptime-kuma-client/notification"
"github.com/breml/go-uptime-kuma-client/tag"
)
@@ -46,9 +47,6 @@ type Breml struct {
opts []kuma.Option
token string
backoff0 time.Duration
// notifStale means a notification write may be missing from the
// session's cached list.
notifStale bool
}
var _ Client = (*Breml)(nil)
@@ -160,7 +158,7 @@ func (b *Breml) checkSession(ctx context.Context, err error) (rebuilt, dead bool
if ctx.Err() != nil {
return false, false
}
for _, app := range []error{ErrNotFound, ErrNotOwned, ErrOwnerTagMissing, ErrOwnerConflict, ErrStatusPageNotFound, ErrNotificationNotFound} {
for _, app := range []error{ErrNotFound, ErrNotOwned, ErrOwnerTagMissing, ErrOwnerConflict, ErrStatusPageNotFound} {
if errors.Is(err, app) {
return false, false
}
@@ -198,8 +196,6 @@ func (b *Breml) reconnect(ctx context.Context) error {
}
_ = b.c.Disconnect()
b.c = c
// A new session starts with a fresh notification list.
b.notifStale = false
if t := c.SessionToken(); t != "" {
b.token = t
}
@@ -378,7 +374,7 @@ func (b *Breml) update(ctx context.Context, m Monitor) error {
return fmt.Errorf("update %d: monitor carries owner key %q, not %q: %w", m.ID, key, m.OwnerKey, ErrNotOwned)
}
// Start from the full current object, also across a type change, so
// fields we do not manage (proxy, upside down, auth)
// fields we do not manage (proxy, resend interval, upside down, auth)
// survive the overwrite.
wire, err := toWire(m, &cur)
if err != nil {
@@ -409,6 +405,29 @@ func (b *Breml) Delete(ctx context.Context, id int64) error {
return err
}
// DefaultNotifications reads the notification list cached by the session.
// Kuma has no command to request it: it pushes the list at login and to every
// session of the user after each change.
func (b *Breml) DefaultNotifications(ctx context.Context) ([]int64, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
b.mu.Lock()
defer b.mu.Unlock()
return defaultIDs(b.c.GetNotifications(ctx)), nil
}
func defaultIDs(list []notification.Base) []int64 {
var ids []int64
for _, n := range list {
if n.IsDefault {
ids = append(ids, n.ID)
}
}
slices.Sort(ids)
return ids
}
// toDomain converts a monitor from the Kuma monitor list.
func toDomain(base monitor.Base, ownerKey string) (Monitor, error) {
m := Monitor{
@@ -420,7 +439,6 @@ func toDomain(base monitor.Base, ownerKey string) (Monitor, error) {
Interval: int32(base.Interval),
RetryInterval: int32(base.RetryInterval),
MaxRetries: int32(base.MaxRetries),
ResendInterval: int32(base.ResendInterval),
NotificationIDs: slices.Clone(base.NotificationIDs),
OwnerKey: ownerKey,
}
@@ -582,7 +600,6 @@ func writeBase(b *monitor.Base, m Monitor) {
b.Interval = int64(m.Interval)
b.RetryInterval = int64(m.RetryInterval)
b.MaxRetries = int64(m.MaxRetries)
b.ResendInterval = int64(m.ResendInterval)
b.IsActive = m.Active
b.NotificationIDs = slices.Clone(m.NotificationIDs)
}
@@ -127,7 +127,7 @@ func TestCreateFieldsIntact(t *testing.T) {
group := Monitor{
Type: "group", Name: "grp", Description: "a group", Active: true,
Interval: 60, RetryInterval: 60, MaxRetries: 0, ResendInterval: 60, OwnerKey: "default/grp",
Interval: 60, RetryInterval: 60, MaxRetries: 0, OwnerKey: "default/grp",
}
gid, err := b.Create(ctx, group)
if err != nil {
@@ -313,6 +313,7 @@ func setUnmanaged(t *testing.T, b *Breml, id int64) (notificationID int64) {
t.Fatal(err)
}
kw.UpsideDown = true
kw.ResendInterval = 7
kw.NotificationIDs = []int64{nid}
if err := b.c.UpdateMonitor(ctx, &kw); err != nil {
t.Fatal(err)
@@ -327,8 +328,8 @@ func assertUnmanaged(t *testing.T, c *Breml, id int64, when string) {
if err != nil {
t.Fatal(err)
}
if !base.UpsideDown {
t.Errorf("%s: unmanaged state lost: upsideDown=%v", when, base.UpsideDown)
if !base.UpsideDown || base.ResendInterval != 7 {
t.Errorf("%s: unmanaged state lost: upsideDown=%v resend=%d", when, base.UpsideDown, base.ResendInterval)
}
}
@@ -343,7 +344,7 @@ func TestUpdatePreservesUnmanagedAndClearsManaged(t *testing.T) {
}
orig := Monitor{
Type: "keyword", Name: "kw", Active: true, Parent: &gid,
Interval: 60, RetryInterval: 60, ResendInterval: 7, URL: "https://example.com",
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)
@@ -359,7 +360,6 @@ func TestUpdatePreservesUnmanagedAndClearsManaged(t *testing.T) {
cleared.Headers = nil
cleared.Keyword = ""
cleared.Interval = 90
cleared.ResendInterval = 0
if err := b.Update(ctx, cleared); err != nil {
t.Fatal(err)
}
@@ -369,7 +369,7 @@ func TestUpdatePreservesUnmanagedAndClearsManaged(t *testing.T) {
t.Fatal(err)
}
m := byID(got)[id]
if m.Parent != nil || m.Headers != nil || m.Keyword != "" || m.Interval != 90 || m.ResendInterval != 0 || m.NotificationIDs != nil {
if m.Parent != nil || m.Headers != nil || m.Keyword != "" || m.Interval != 90 || m.NotificationIDs != nil {
t.Errorf("managed fields not cleared: %+v", m)
}
assertUnmanaged(t, fresh, id, "after plain update")
@@ -391,6 +391,51 @@ func TestUpdatePreservesUnmanagedAndClearsManaged(t *testing.T) {
assertUnmanaged(t, fresh, id, "after type change")
}
func TestDefaultNotificationsAndWrite(t *testing.T) {
ctx := context.Background()
url := startKuma(t)
b := setupClient(t, url)
other := freshClient(t, url)
if ids, err := other.DefaultNotifications(ctx); err != nil || ids != nil {
t.Fatalf("no notifications: ids=%v err=%v", ids, err)
}
def := createWebhook(t, b, "default", true)
createWebhook(t, b, "opt-in", false)
// Kuma pushes the list to every session of the user, not only the writer.
deadline := time.Now().Add(10 * time.Second)
for {
ids, err := other.DefaultNotifications(ctx)
if err != nil {
t.Fatal(err)
}
if reflect.DeepEqual(ids, []int64{def}) {
break
}
if time.Now().After(deadline) {
t.Fatalf("other session sees defaults %v, want [%d]", ids, def)
}
time.Sleep(100 * time.Millisecond)
}
m := Monitor{
Type: "group", Name: "grp", Active: true, Interval: 60, RetryInterval: 60,
NotificationIDs: []int64{def}, OwnerKey: "default/grp",
}
id, err := b.Create(ctx, m)
if err != nil {
t.Fatal(err)
}
got, err := freshClient(t, url).ListOwned(ctx)
if err != nil {
t.Fatal(err)
}
m.ID = id
if !reflect.DeepEqual(byID(got)[id], m.Normalized()) {
t.Errorf("read-back differs:\n got %+v\n want %+v", byID(got)[id], m.Normalized())
}
}
func TestRestartAdoptsByTag(t *testing.T) {
ctx := context.Background()
url := startKuma(t)
@@ -1,202 +0,0 @@
package kumaclient
import (
"cmp"
"context"
"errors"
"fmt"
"maps"
"slices"
"strconv"
kuma "github.com/breml/go-uptime-kuma-client"
"github.com/breml/go-uptime-kuma-client/notification"
)
const typeSMTP = "smtp"
// ListNotifications reads the notification list cached by the session. Kuma
// has no command to request it: it pushes the list at login and to every
// session of the user after each change. After a write whose broadcast was
// missed the cache is rebuilt first, so a created notification is never
// reported absent.
func (b *Breml) ListNotifications(ctx context.Context, name string) ([]Notification, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
b.mu.Lock()
defer b.mu.Unlock()
if b.notifStale {
if err := b.c.Resync(ctx); err != nil && !b.recoverSession(ctx, err) {
return nil, fmt.Errorf("resync notifications: %w", err)
}
b.notifStale = false
}
var out []Notification
for _, base := range b.c.GetNotifications(ctx) {
if base.Name != name {
continue
}
n, err := toNotification(base)
if err != nil {
return nil, err
}
out = append(out, n)
}
slices.SortFunc(out, func(a, b Notification) int { return cmp.Compare(a.ID, b.ID) })
return out, nil
}
// CreateNotification is never retried: Kuma may have stored the notification
// although the ack was lost; the next list finds it by name.
func (b *Breml) CreateNotification(ctx context.Context, n Notification) (int64, error) {
if n.Type != typeSMTP {
return 0, fmt.Errorf("create notification: unsupported type %q", n.Type)
}
b.mu.Lock()
defer b.mu.Unlock()
wire := &notification.SMTP{
Base: notification.Base{Name: n.Name, IsActive: n.Active, IsDefault: n.IsDefault},
}
writeSMTP(&wire.SMTPDetails, n.SMTP)
id, err := b.c.CreateNotification(ctx, wire)
if err != nil {
b.notifStale = true
}
if createdDespite(id, err) {
return id, nil
}
b.recoverSession(ctx, err)
return 0, err
}
func (b *Breml) UpdateNotification(ctx context.Context, n Notification) error {
if n.Type != typeSMTP {
return fmt.Errorf("update notification %d: unsupported type %q", n.ID, n.Type)
}
b.mu.Lock()
defer b.mu.Unlock()
_, err := withRecovery(ctx, b, func() (struct{}, error) { return struct{}{}, b.updateNotification(ctx, n) })
return err
}
// updateNotification starts from the notification's current config, so keys
// kuma-sync does not manage are written back unchanged, also across a type
// change.
func (b *Breml) updateNotification(ctx context.Context, n Notification) error {
cur, err := b.c.GetNotification(ctx, n.ID)
if errors.Is(err, kuma.ErrNotFound) {
return fmt.Errorf("update notification %d: %w", n.ID, ErrNotificationNotFound)
}
if err != nil {
return err
}
var g notification.Generic
if err := cur.As(&g); err != nil {
return fmt.Errorf("notification %d: %w", n.ID, err)
}
g.Name = n.Name
g.IsActive = n.Active
g.IsDefault = n.IsDefault
g.ApplyExisting = false
g.TypeName = typeSMTP
g.GenericDetails = maps.Clone(g.GenericDetails)
if g.GenericDetails == nil {
g.GenericDetails = notification.GenericDetails{}
}
var d notification.SMTPDetails
writeSMTP(&d, n.SMTP)
g.GenericDetails["smtpHost"] = d.Host
g.GenericDetails["smtpPort"] = d.Port
g.GenericDetails["smtpSecure"] = d.Secure
g.GenericDetails["smtpIgnoreSTARTTLS"] = d.IgnoreSTARTTLS
g.GenericDetails["smtpUsername"] = d.Username
g.GenericDetails["smtpPassword"] = d.Password
g.GenericDetails["smtpFrom"] = d.From
g.GenericDetails["smtpTo"] = d.To
err = b.c.UpdateNotification(ctx, g)
if errors.Is(err, kuma.ErrUpdateEventTimeout) {
// Applied; only the broadcast is missing.
b.notifStale = true
return nil
}
return err
}
func (b *Breml) DeleteNotification(ctx context.Context, id int64) error {
b.mu.Lock()
defer b.mu.Unlock()
_, err := withRecovery(ctx, b, func() (struct{}, error) {
if _, err := b.c.GetNotification(ctx, id); errors.Is(err, kuma.ErrNotFound) {
return struct{}{}, fmt.Errorf("delete notification %d: %w", id, ErrNotificationNotFound)
}
err := b.c.DeleteNotification(ctx, id)
if errors.Is(err, kuma.ErrUpdateEventTimeout) {
b.notifStale = true
return struct{}{}, nil
}
return struct{}{}, err
})
return err
}
func writeSMTP(d *notification.SMTPDetails, s SMTP) {
d.Host = s.Host
d.Port = int(s.Port)
d.Secure = s.Secure
d.IgnoreSTARTTLS = s.IgnoreSTARTTLS
d.Username = s.Username
d.Password = s.Password
d.From = s.From
d.To = s.To
}
// toNotification reads the config loosely: a notification made in the Kuma UI
// may hold the port as a string.
func toNotification(base notification.Base) (Notification, error) {
n := Notification{
ID: base.ID, Name: base.Name, Type: base.Type(), IsDefault: base.IsDefault, Active: base.IsActive,
}
if n.Type != typeSMTP {
return n, nil
}
var g notification.Generic
if err := base.As(&g); err != nil {
return Notification{}, fmt.Errorf("notification %d: %w", base.ID, err)
}
d := g.GenericDetails
n.SMTP = SMTP{
Host: configString(d, "smtpHost"),
Port: configPort(d, "smtpPort"),
Secure: configBool(d, "smtpSecure"),
IgnoreSTARTTLS: configBool(d, "smtpIgnoreSTARTTLS"),
Username: configString(d, "smtpUsername"),
Password: configString(d, "smtpPassword"),
From: configString(d, "smtpFrom"),
To: configString(d, "smtpTo"),
}
return n, nil
}
func configString(d notification.GenericDetails, key string) string {
s, _ := d[key].(string)
return s
}
func configBool(d notification.GenericDetails, key string) bool {
v, _ := d[key].(bool)
return v
}
// configPort returns 0 for a missing or unreadable port, which never matches a
// desired one.
func configPort(d notification.GenericDetails, key string) int32 {
switch v := d[key].(type) {
case float64:
return int32(v)
case string:
p, _ := strconv.ParseInt(v, 10, 32)
return int32(p)
}
return 0
}
@@ -21,8 +21,6 @@ var (
ErrPossibleOrphan = errors.New("possible untagged orphan monitor left in Kuma")
// ErrStatusPageNotFound means no status page has the slug.
ErrStatusPageNotFound = errors.New("status page not found")
// ErrNotificationNotFound means no notification has the ID.
ErrNotificationNotFound = errors.New("notification not found")
)
// ConflictError reports a monitor carrying several different kuma-sync tag
@@ -141,9 +139,6 @@ type Monitor struct {
Interval int32
RetryInterval int32
MaxRetries int32
// ResendInterval counts consecutive DOWN heartbeats between repeated DOWN
// notifications; 0 disables resending.
ResendInterval int32
// Timeout is in seconds and only applies to http, keyword and json-query.
// 0 is written as round(0.8*Interval), because Kuma treats a stored 0 as a
// huge request timeout. For other types it is ignored and reads back as 0.
@@ -201,7 +196,9 @@ type Client interface {
// Delete removes an owned monitor. It wraps ErrNotFound for an unknown ID
// and ErrNotOwned for a monitor without the kuma-sync tag.
Delete(ctx context.Context, id int64) error
// DefaultNotifications returns the IDs of all notifications marked
// "default enabled" in Kuma, sorted. An empty result is valid.
DefaultNotifications(ctx context.Context) ([]int64, error)
StatusPages
Notifications
}
@@ -3,6 +3,7 @@ package kumaclient
import (
"context"
"fmt"
"slices"
"sort"
"sync"
)
@@ -20,6 +21,10 @@ type Fake struct {
UpdateErr error
DeleteErr error
ProbeErr error
// NotificationsErr is returned by DefaultNotifications.
NotificationsErr error
// Defaults are the IDs DefaultNotifications returns.
Defaults []int64
// ListPartial makes ListOwned return the stored monitors together with
// ListErr, as the real client does for owner conflicts.
ListPartial bool
@@ -30,11 +35,6 @@ type Fake struct {
nextGroupID int64
// PageErr, when set, is returned by every status page method.
PageErr error
notifications map[int64]Notification
nextNotifID int64
// NotifErr, when set, is returned by every notification method.
NotifErr error
}
// SetListPartial makes ListOwned return the stored monitors and err.
@@ -50,7 +50,6 @@ func NewFake() *Fake {
return &Fake{
monitors: map[int64]Monitor{}, nextID: 1,
pages: map[string]StatusPage{}, pageGroups: map[int64]string{}, nextPageID: 1, nextGroupID: 1,
notifications: map[int64]Notification{}, nextNotifID: 1,
}
}
@@ -106,6 +105,32 @@ func (f *Fake) Probe(_ context.Context, id int64) (bool, string, error) {
return ok, m.OwnerKey, nil
}
// SetDefaultNotifications sets the IDs DefaultNotifications returns.
func (f *Fake) SetDefaultNotifications(ids ...int64) {
f.mu.Lock()
defer f.mu.Unlock()
f.Defaults = slices.Clone(ids)
}
// SetNotificationsErr makes DefaultNotifications return err.
func (f *Fake) SetNotificationsErr(err error) {
f.mu.Lock()
defer f.mu.Unlock()
f.NotificationsErr = err
}
// DefaultNotifications returns Defaults, sorted.
func (f *Fake) DefaultNotifications(_ context.Context) ([]int64, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.NotificationsErr != nil {
return nil, f.NotificationsErr
}
ids := slices.Clone(f.Defaults)
slices.Sort(ids)
return ids, nil
}
func (f *Fake) allocID() int64 {
id := f.nextID
f.nextID++
@@ -1,107 +0,0 @@
package kumaclient
import (
"cmp"
"context"
"fmt"
"slices"
)
// SetNotifErr makes every notification method return err.
func (f *Fake) SetNotifErr(err error) {
f.mu.Lock()
defer f.mu.Unlock()
f.NotifErr = err
}
// Notification returns a copy of the stored notification, or nil.
func (f *Fake) Notification(id int64) *Notification {
f.mu.Lock()
defer f.mu.Unlock()
n, ok := f.notifications[id]
if !ok {
return nil
}
return &n
}
// PutNotification stores n as if made in the Kuma UI. A zero ID is assigned
// the next free one.
func (f *Fake) PutNotification(n Notification) int64 {
f.mu.Lock()
defer f.mu.Unlock()
if n.ID == 0 {
n.ID = f.nextNotifID
f.nextNotifID++
} else if n.ID >= f.nextNotifID {
f.nextNotifID = n.ID + 1
}
f.notifications[n.ID] = n
return n.ID
}
func (f *Fake) ListNotifications(_ context.Context, name string) ([]Notification, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.NotifErr != nil {
return nil, f.NotifErr
}
var out []Notification
for _, n := range f.notifications {
if n.Name == name {
out = append(out, n)
}
}
slices.SortFunc(out, func(a, b Notification) int { return cmp.Compare(a.ID, b.ID) })
return out, nil
}
func (f *Fake) CreateNotification(_ context.Context, n Notification) (int64, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.NotifErr != nil {
return 0, f.NotifErr
}
if n.Type != "smtp" {
return 0, fmt.Errorf("create notification: unsupported type %q", n.Type)
}
n.ID = f.nextNotifID
f.nextNotifID++
f.notifications[n.ID] = n
return n.ID, nil
}
func (f *Fake) UpdateNotification(_ context.Context, n Notification) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.NotifErr != nil {
return f.NotifErr
}
if _, ok := f.notifications[n.ID]; !ok {
return fmt.Errorf("update notification %d: %w", n.ID, ErrNotificationNotFound)
}
f.notifications[n.ID] = n
return nil
}
// DeleteNotification also detaches the notification from every monitor, as
// Kuma does.
func (f *Fake) DeleteNotification(_ context.Context, id int64) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.NotifErr != nil {
return f.NotifErr
}
if _, ok := f.notifications[id]; !ok {
return fmt.Errorf("delete notification %d: %w", id, ErrNotificationNotFound)
}
delete(f.notifications, id)
for mid, m := range f.monitors {
if i := slices.Index(m.NotificationIDs, id); i >= 0 {
m = m.clone()
m.NotificationIDs = slices.Delete(m.NotificationIDs, i, i+1)
f.monitors[mid] = m.Normalized()
}
}
return nil
}
@@ -118,6 +118,21 @@ func TestFakeInjectedErrors(t *testing.T) {
}
}
func TestFakeDefaultNotifications(t *testing.T) {
f := NewFake()
if ids, err := f.DefaultNotifications(context.Background()); err != nil || ids != nil {
t.Fatalf("empty: %v %v", ids, err)
}
f.SetDefaultNotifications(4, 1)
if ids, err := f.DefaultNotifications(context.Background()); err != nil || len(ids) != 2 || ids[0] != 1 || ids[1] != 4 {
t.Fatalf("set: %v %v", ids, err)
}
f.SetNotificationsErr(errors.New("boom"))
if _, err := f.DefaultNotifications(context.Background()); err == nil {
t.Error("expected injected error")
}
}
func TestFakeUpdateRejectsOtherOwnerKey(t *testing.T) {
f := NewFake()
id, _ := f.Create(context.Background(), Monitor{Name: "a", OwnerKey: "ns/a"})
@@ -7,6 +7,7 @@ import (
"testing"
kuma "github.com/breml/go-uptime-kuma-client"
"github.com/breml/go-uptime-kuma-client/notification"
"github.com/breml/go-uptime-kuma-client/tag"
)
@@ -181,3 +182,15 @@ func TestNormalizedNotificationIDs(t *testing.T) {
t.Errorf("empty notifications must be nil: %v", e.NotificationIDs)
}
}
func TestDefaultIDs(t *testing.T) {
got := defaultIDs([]notification.Base{
{ID: 5, IsDefault: true}, {ID: 1}, {ID: 3, IsDefault: true},
})
if !reflect.DeepEqual(got, []int64{3, 5}) {
t.Errorf("defaultIDs = %v, want [3 5]", got)
}
if got := defaultIDs(nil); got != nil {
t.Errorf("defaultIDs(nil) = %v, want nil", got)
}
}
@@ -1,54 +0,0 @@
package kumaclient
import (
"context"
)
// Notification is the domain view of a Kuma notification. Only smtp is
// modelled; for another type Type is set and SMTP is zero. Config keys
// kuma-sync does not manage are kept as they are in Kuma.
type Notification struct {
// ID is read only.
ID int64
Name string
Type string
IsDefault bool
Active bool
SMTP SMTP
}
// SMTP is the managed part of an smtp notification's config.
type SMTP struct {
Host string
Port int32
// Secure is implicit TLS; false is STARTTLS.
Secure bool
IgnoreSTARTTLS bool
Username string
Password string
From string
To string
}
// SameContent reports whether n and o hold the same managed content. IDs are
// ignored.
func (n Notification) SameContent(o Notification) bool {
n.ID, o.ID = 0, 0
return n == o
}
// Notifications manages Kuma notifications by name. Kuma does not enforce
// unique names.
type Notifications interface {
// ListNotifications returns the notifications named name, by ascending ID.
ListNotifications(ctx context.Context, name string) ([]Notification, error)
// CreateNotification creates the notification and returns its ID. It never
// applies the notification to existing monitors.
CreateNotification(ctx context.Context, n Notification) (int64, error)
// UpdateNotification overwrites the managed fields of notification n.ID.
// It wraps ErrNotificationNotFound for an unknown ID.
UpdateNotification(ctx context.Context, n Notification) error
// DeleteNotification removes the notification and detaches it from every
// monitor. It wraps ErrNotificationNotFound for an unknown ID.
DeleteNotification(ctx context.Context, id int64) error
}
@@ -1,108 +0,0 @@
//go:build integration
package kumaclient
import (
"context"
"errors"
"testing"
"time"
"github.com/breml/go-uptime-kuma-client/notification"
)
// eventuallyListed waits until c lists exactly want under name; Kuma pushes
// the list to every session of the user, not only the writer.
func eventuallyListed(t *testing.T, c *Breml, name string, want ...Notification) {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
for {
got, err := c.ListNotifications(context.Background(), name)
if err != nil {
t.Fatal(err)
}
if len(got) == len(want) {
same := true
for i := range got {
same = same && got[i] == want[i]
}
if same {
return
}
}
if time.Now().After(deadline) {
t.Fatalf("session lists %+v, want %+v", got, want)
}
time.Sleep(100 * time.Millisecond)
}
}
func TestNotificationRoundTrip(t *testing.T) {
ctx := context.Background()
url := startKuma(t)
b := setupClient(t, url)
other := freshClient(t, url)
n := Notification{
Name: "email", Type: "smtp", Active: true,
SMTP: SMTP{Host: "mail", Port: 587, Username: "u", Password: "p", From: "f@x", To: "t@x"},
}
id, err := b.CreateNotification(ctx, n)
if err != nil {
t.Fatal(err)
}
n.ID = id
eventuallyListed(t, b, "email", n)
eventuallyListed(t, other, "email", n)
n.SMTP.Port, n.SMTP.Secure, n.SMTP.Password = 465, true, "q"
if err := b.UpdateNotification(ctx, n); err != nil {
t.Fatal(err)
}
eventuallyListed(t, freshClient(t, url), "email", n)
if err := b.DeleteNotification(ctx, id); err != nil {
t.Fatal(err)
}
if err := b.DeleteNotification(ctx, id); !errors.Is(err, ErrNotificationNotFound) {
t.Errorf("second delete: %v", err)
}
if err := b.UpdateNotification(ctx, n); !errors.Is(err, ErrNotificationNotFound) {
t.Errorf("update deleted: %v", err)
}
eventuallyListed(t, freshClient(t, url), "email")
}
// An adopted notification made in the UI keeps the config kuma-sync does not
// manage, and loses its default flag.
func TestNotificationUpdatePreservesUnmanaged(t *testing.T) {
ctx := context.Background()
url := startKuma(t)
b := setupClient(t, url)
id, err := b.c.CreateNotification(ctx, &notification.SMTP{
Base: notification.Base{Name: "email", IsActive: true, IsDefault: true},
SMTPDetails: notification.SMTPDetails{
Host: "old", Port: 25, From: "f@x", To: "t@x", CustomSubject: "keep me",
},
})
if err != nil {
t.Fatal(err)
}
n := Notification{
ID: id, Name: "email", Type: "smtp", Active: true,
SMTP: SMTP{Host: "mail", Port: 587, Username: "u", Password: "p", From: "f@x", To: "t@x"},
}
if err := b.UpdateNotification(ctx, n); err != nil {
t.Fatal(err)
}
fresh := freshClient(t, url)
eventuallyListed(t, fresh, "email", n)
var got notification.SMTP
if err := fresh.c.GetNotificationAs(ctx, id, &got); err != nil {
t.Fatal(err)
}
if got.CustomSubject != "keep me" || got.IsDefault {
t.Errorf("customSubject = %q, isDefault = %v, want kept and false", got.CustomSubject, got.IsDefault)
}
}
@@ -1,125 +0,0 @@
package kumaclient
import (
"context"
"encoding/json"
"errors"
"slices"
"testing"
"github.com/breml/go-uptime-kuma-client/notification"
)
// listRow decodes a notification as Kuma sends it in notificationList.
func listRow(t *testing.T, row string) notification.Base {
t.Helper()
var b notification.Base
if err := json.Unmarshal([]byte(row), &b); err != nil {
t.Fatal(err)
}
return b
}
func TestToNotificationSMTP(t *testing.T) {
b := listRow(t, `{"id":3,"name":"email","active":true,"isDefault":true,"userId":1,`+
`"config":"{\"type\":\"smtp\",\"smtpHost\":\"mail\",\"smtpPort\":\"587\",\"smtpSecure\":false,`+
`\"smtpUsername\":\"u\",\"smtpPassword\":\"p\",\"smtpFrom\":\"f@x\",\"smtpTo\":\"t@x\",\"customSubject\":\"s\"}"}`)
got, err := toNotification(b)
if err != nil {
t.Fatal(err)
}
want := Notification{
ID: 3, Name: "email", Type: "smtp", IsDefault: true, Active: true,
SMTP: SMTP{Host: "mail", Port: 587, Username: "u", Password: "p", From: "f@x", To: "t@x"},
}
if got != want {
t.Errorf("got %+v\nwant %+v", got, want)
}
}
func TestToNotificationOtherType(t *testing.T) {
b := listRow(t, `{"id":4,"name":"hook","active":true,"isDefault":false,"userId":1,`+
`"config":"{\"type\":\"webhook\",\"webhookURL\":\"http://x\"}"}`)
got, err := toNotification(b)
if err != nil {
t.Fatal(err)
}
if got != (Notification{ID: 4, Name: "hook", Type: "webhook", Active: true}) {
t.Errorf("got %+v", got)
}
}
func TestNotificationSameContent(t *testing.T) {
a := Notification{ID: 1, Name: "n", Type: "smtp", Active: true, SMTP: SMTP{Host: "h", Port: 25}}
b := a
b.ID = 2
if !a.SameContent(b) {
t.Error("IDs must be ignored")
}
b.SMTP.Password = "x"
if a.SameContent(b) {
t.Error("password change not detected")
}
}
func TestFakeNotifications(t *testing.T) {
ctx := context.Background()
f := NewFake()
other := f.PutNotification(Notification{Name: "email", Type: "webhook"})
id, err := f.CreateNotification(ctx, Notification{Name: "email", Type: "smtp", Active: true})
if err != nil {
t.Fatal(err)
}
f.PutNotification(Notification{Name: "else", Type: "smtp"})
got, err := f.ListNotifications(ctx, "email")
if err != nil {
t.Fatal(err)
}
if len(got) != 2 || got[0].ID != other || got[1].ID != id {
t.Fatalf("list = %+v, want IDs [%d %d]", got, other, id)
}
if _, err := f.CreateNotification(ctx, Notification{Name: "x", Type: "webhook"}); err == nil {
t.Error("only smtp may be created")
}
up := *f.Notification(id)
up.SMTP.Host = "h"
if err := f.UpdateNotification(ctx, up); err != nil {
t.Fatal(err)
}
if f.Notification(id).SMTP.Host != "h" {
t.Error("not updated")
}
if err := f.UpdateNotification(ctx, Notification{ID: 99, Type: "smtp"}); !errors.Is(err, ErrNotificationNotFound) {
t.Errorf("update unknown: %v", err)
}
mid, _ := f.Create(ctx, Monitor{Name: "m", OwnerKey: "ns/m", NotificationIDs: []int64{other, id}})
if err := f.DeleteNotification(ctx, id); err != nil {
t.Fatal(err)
}
if f.Notification(id) != nil {
t.Error("not deleted")
}
if got := f.Get(mid).NotificationIDs; !slices.Equal(got, []int64{other}) {
t.Errorf("monitor notifications = %v, want [%d]", got, other)
}
if err := f.DeleteNotification(ctx, id); !errors.Is(err, ErrNotificationNotFound) {
t.Errorf("delete twice: %v", err)
}
boom := errors.New("boom")
f.SetNotifErr(boom)
if _, err := f.ListNotifications(ctx, "email"); !errors.Is(err, boom) {
t.Errorf("list: %v", err)
}
if _, err := f.CreateNotification(ctx, Notification{Type: "smtp"}); !errors.Is(err, boom) {
t.Errorf("create: %v", err)
}
if err := f.UpdateNotification(ctx, up); !errors.Is(err, boom) {
t.Errorf("update: %v", err)
}
if err := f.DeleteNotification(ctx, other); !errors.Is(err, boom) {
t.Errorf("delete: %v", err)
}
}
@@ -1,61 +0,0 @@
package kumaclient
import (
"encoding/json"
"testing"
"github.com/breml/go-uptime-kuma-client/monitor"
)
// resendOnWire writes m over existing (nil for a create) and returns the wire
// resendInterval and the value read back from it.
func resendOnWire(t *testing.T, m Monitor, existing *monitor.Base) (wire, back int64) {
t.Helper()
w, err := toWire(m, existing)
if err != nil {
t.Fatal(err)
}
raw, err := json.Marshal(w)
if err != nil {
t.Fatal(err)
}
var f struct {
ResendInterval int64 `json:"resendInterval"`
}
if err := json.Unmarshal(raw, &f); err != nil {
t.Fatal(err)
}
var base monitor.Base
if err := json.Unmarshal(raw, &base); err != nil {
t.Fatal(err)
}
got, err := toDomain(base, m.OwnerKey)
if err != nil {
t.Fatal(err)
}
return f.ResendInterval, int64(got.ResendInterval)
}
func TestResendIntervalRoundTrip(t *testing.T) {
for _, m := range []Monitor{
{ID: 1, Type: "group", Name: "g", Active: true, Interval: 60, RetryInterval: 60, ResendInterval: 60, OwnerKey: "ns/g"},
{ID: 2, Type: "http", Name: "h", Active: true, Interval: 60, RetryInterval: 60, ResendInterval: 3, URL: "https://e.com", OwnerKey: "ns/h"},
} {
wire, back := resendOnWire(t, m, nil)
if wire != int64(m.ResendInterval) || back != int64(m.ResendInterval) {
t.Errorf("%s: wire=%d read back=%d, want %d", m.Type, wire, back, m.ResendInterval)
}
}
}
// 0 must overwrite a resend interval stored in Kuma, not keep it.
func TestResendIntervalClearedOnUpdate(t *testing.T) {
var existing monitor.Base
if err := json.Unmarshal([]byte(`{"id":1,"type":"group","name":"g","resendInterval":7}`), &existing); err != nil {
t.Fatal(err)
}
m := Monitor{ID: 1, Type: "group", Name: "g", Active: true, Interval: 60, RetryInterval: 60, OwnerKey: "ns/g"}
if wire, back := resendOnWire(t, m, &existing); wire != 0 || back != 0 {
t.Errorf("wire=%d read back=%d, want 0", wire, back)
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
# domains -> TLS passthrough -> Traefik ClusterIP) from Ingresses carrying the
# public label. Standard library only, so the image is the interpreter plus one
# file; runs unprivileged as its own user.
FROM python:3.15-slim@sha256:94d3426f1def7db4a106a232ac121bfd975393e5dfd40a619b955fae45fe7913
FROM python:3.15-slim@sha256:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51
RUN useradd --system --no-create-home --shell /usr/sbin/nologin controller
COPY controller.py /opt/controller.py
@@ -1,58 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<array>
<dict>
<key>PayloadType</key>
<string>com.apple.wifi.managed</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>PayloadIdentifier</key>
<string>uno.renner.wifi.renner-test.network</string>
<key>PayloadUUID</key>
<string>c7814579-fc2b-4f38-bcc8-77e1123ab710</string>
<key>PayloadDisplayName</key>
<string>Renner Test Wi-Fi</string>
<key>PayloadOrganization</key>
<string>Renner</string>
<key>SSID_STR</key>
<string>Renner Test</string>
<key>HIDDEN_NETWORK</key>
<false/>
<key>AutoJoin</key>
<true/>
<key>EncryptionType</key>
<string>Any</string>
<key>EAPClientConfiguration</key>
<dict>
<key>OuterIdentity</key>
<string>anonymous</string>
<key>AcceptEAPTypes</key>
<array>
<integer>21</integer>
</array>
<key>TTLSInnerAuthentication</key>
<string>PAP</string>
<key>TLSTrustedServerNames</key>
<array>
<string>radius.renner.uno</string>
</array>
</dict>
</dict>
</array>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>PayloadIdentifier</key>
<string>uno.renner.wifi.renner-test</string>
<key>PayloadUUID</key>
<string>b35513d4-97e5-4804-a77b-5ca1f723a408</string>
<key>PayloadDisplayName</key>
<string>Renner Test Wi-Fi (802.1X)</string>
<key>PayloadOrganization</key>
<string>Renner</string>
</dict>
</plist>
-177
View File
@@ -1,177 +0,0 @@
# 802.1X via authentik
FreeRADIUS terminates EAP-TTLS and proxies the inner PAP request to the
authentik RADIUS outpost `802.1X Radius`. authentik checks the password and
the access policy and returns the VLAN.
## authentik objects
Defined in `kubernetes/apps/authentik/blueprints-configmap.yaml`, key
`dot1x-radius.yaml`:
| Object | Name | Purpose |
|---|---|---|
| Flow | `dot1x-authentication-flow` | identification, password, login; no MFA, no enrollment |
| Expression policy | `dot1x-has-vlan` | admits users with a valid `dot1x_vlan` group |
| RADIUS property mapping | `dot1x-vlan` | puts the VLAN into the Access-Accept |
| RADIUS provider | `802.1X Radius` | shared secret `DOT1X_OUTPOST_SECRET`, clients = cluster pod CIDRs |
| Application | `dot1x` | bound to `dot1x-has-vlan` |
| Outpost | `802.1X Radius` | radius, 2 replicas, ClusterIP, namespace `authentik` |
The outpost's Kubernetes objects are named from the slugified outpost name:
Service/Deployment `ak-outpost-8021x-radius`, pod label
`goauthentik.io/outpost-name: 8021x-radius`.
The shared secret is key `DOT1X_OUTPOST_SECRET` of Secret `radius-secrets`
(ExternalSecret from OpenBao `apps/radius`), passed to the authentik worker
only (it applies the blueprints) as env `DOT1X_OUTPOST_SECRET`, with
`optional: true`: a missing Secret fails only the dot1x blueprint, not
authentik. Reloader restarts the worker when `radius-secrets` changes.
On the FreeRADIUS → outpost hop the shared secret is the only guard: the
authentik namespace baseline admits all same-namespace pods, and the
provider's client_networks is the pod CIDR.
FreeRADIUS resolves the outpost Service name at startup; a re-created
outpost Service needs the FreeRADIUS pods restarted.
## VLAN convention
- A group carries the VLAN as attribute `dot1x_vlan`, an integer VLAN id
(1–4094), e.g. `dot1x_vlan: 50`. It must be an unquoted integer: a string
such as `"50"` is ignored and the user is denied.
- Membership counts through group inheritance (`User.all_groups()`).
- A user without any such group is rejected (policy `dot1x-has-vlan`).
- A user in several such groups gets the lowest `dot1x_vlan`.
- The same attribute applies to WiFi and wired 802.1X.
Groups and memberships are managed by the owner in authentik, not in
blueprints.
## Reply format
The mapping sets both:
- RFC 3580 `Tunnel-Type = VLAN`, `Tunnel-Medium-Type = IEEE-802`,
`Tunnel-Private-Group-Id = "<id>"` (raw attribute codes 64, 65, 81; the
dictionary bundled with authentik does not define them).
- `Cisco-AV-Pair = "dot1x-vlan=<id>"`.
Contract for FreeRADIUS: if the outpost reply carries the standard Tunnel-*
attributes, use them; otherwise translate `Cisco-AV-Pair = "dot1x-vlan=<id>"`
into `Tunnel-Type = VLAN`, `Tunnel-Medium-Type = IEEE-802`,
`Tunnel-Private-Group-Id = "<id>"`. An Access-Accept without a VLAN is
rejected.
## Test-phase entries
The FreeRADIUS clients `test` (10.2.50.0/24) and `test6` (fd02:50::/64) and
their CIDRs in `freeradius-cnp.yaml` are for the test phase only and must be
removed after the test.
## UniFi objects
Created through the UniFi Network API (site `default`):
| Object | ID | Settings |
|---|---|---|
| Firewall policy "Allow APs RADIUS" | `62c21e63-8c15-4720-adf8-b2333c090e2f` | zone Isolated `10.2.10.0/24` → zone External `10.2.99.91`, UDP `1812-1813`, ALLOW, IPv4 |
| RADIUS profile "Cluster 802.1X" | `3e5fcfed-63dc-4e7d-ad51-17cbc074e4bb` | auth `10.2.99.91:1812`, acct `10.2.99.91:1813`, secret `DOT1X_NAS_SECRET`, VLAN assignment on (`vlan_wlan_mode: optional`) |
| WLAN "Renner Test" | `3eba27e7-ddc2-41fb-ad3f-82add317847a` | copy of "Renner UniFi" (WPA2/WPA3 Enterprise transition, fast roaming, BSS transition, same AP group and bands), network (90) Guest as fallback, RADIUS profile "Cluster 802.1X" |
## Live login check
`tests/radius/live.sh` runs EAP-TTLS/PAP logins with `eapol_test` against the
deployed FreeRADIUS from a test client range and prints result, duration and
the VLAN attributes of the Access-Accept (hex) per login; secrets are read from
files and never printed:
```
LIVE_PASSWORD_VAR=CLAUDE_BOT_PASSWORD tests/radius/live.sh 10.2.99.91 claude-bot \
~/.config/claude-bot/authentik ~/.config/claude-bot/radius-secret 20
```
For IPv6 set `LIVE_SOURCE` to the workstation's `fd02:50::` address and use
`fd99::91`. `group-id=[3530]` is VLAN `50`, `tunnel-type=[0000000d]` VLAN,
`medium=[00000006]` IEEE-802.
## Client setup (SSID "Renner Test")
All clients use EAP-TTLS with inner PAP. The server certificate is a public
Let's Encrypt certificate for `radius.renner.uno`, so no CA needs to be
installed. Username and password are the authentik credentials. PAP runs
inside the TLS tunnel to FreeRADIUS and is never sent in clear over the air.
### Android
- EAP method: TTLS
- Phase 2 authentication: PAP
- CA certificate: Use system certificates
- Domain: `radius.renner.uno`
- Identity: authentik username
- Anonymous identity: `anonymous`
- Password: authentik password
### Linux (NetworkManager)
- Security: WPA & WPA2/3 Enterprise
- Authentication: Tunneled TLS (TTLS)
- Inner authentication: PAP
- CA certificate: system bundle (e.g. `/etc/ssl/certs/ca-certificates.crt`)
- Domain: `radius.renner.uno`
- Username / password: authentik credentials
### Windows 11
Settings > Network & Internet > Wi-Fi > Manage known networks > Add network:
- Network name: `Renner Test`
- Security type: WPA2-Enterprise AES
- EAP method: EAP-TTLS
- Authentication method: Unencrypted password (PAP)
- Server validation: where offered ("Verify server identity" / trusted
server name), set `radius.renner.uno`
- Do not use Windows sign-in credentials; enter the authentik credentials
On the first join, check the certificate details before connecting: it must
be issued to `radius.renner.uno` by Let's Encrypt. Otherwise cancel.
If EAP-TTLS is not offered, use Control Panel > "Manually connect to a
wireless network" instead.
### Apple (iOS, iPadOS, macOS)
Apple devices need the configuration profile
`docs/radius/clients/renner-test.mobileconfig`. Install it via AirDrop, Files
or Settings (Profile Downloaded). The device asks for username and password on
the first join. The profile sets security type Any (WPA2/WPA3), outer identity
`anonymous` and trusted server name `radius.renner.uno`; it contains no
password and no certificate. Apple
devices may show a one-time "trust certificate" prompt for `radius.renner.uno`
on the first join.
## Measurement: authentication latency per SSID
The UniFi access points log station events (`stahtd`) to Loki. The field
`radius_auth_delta` (microseconds) is in a JSON payload after
`stahtd_dump_event(): `.
VAP names per SSID are looked up through the UniFi API: `stat/device`,
`vap_table`, map `essid` to `name`.
The JSON mixes `"key":"v"` and `"key": "v"`, so the filters tolerate an
optional space. Values are microseconds. Run once per VAP of "Renner Test" and
of "Renner UniFi" and compare avg and max:
```
logcli instant-query --quiet \
'avg_over_time({source="unifi-syslog",app="stahtd"} |~ "\"vap\": ?\"<vap>\"" | regexp "radius_auth_delta\": ?\"?(?P<d>[0-9]+)" | unwrap d [24h])'
logcli instant-query --quiet \
'max_over_time({source="unifi-syslog",app="stahtd"} |~ "\"vap\": ?\"<vap>\"" | regexp "radius_auth_delta\": ?\"?(?P<d>[0-9]+)" | unwrap d [24h])'
```
| SSID | VAP(s) | Events | avg radius_auth_delta (us) | max radius_auth_delta (us) |
|---|---|---|---|---|
| Renner Test | | | | |
| Renner UniFi | | | | |
@@ -1,123 +0,0 @@
# 802.1X RADIUS via authentik — 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:** In-cluster FreeRADIUS that terminates EAP-TTLS/PAP for 802.1X and checks the password against a dedicated authentik RADIUS outpost, live behind a test SSID "Renner Test".
**Architecture:** FreeRADIUS (own Deployment, 2 replicas, LB `10.2.99.91`/`fd99::91`, Let's Encrypt cert `radius.renner.uno`) terminates EAP-TTLS, proxies the inner PAP request to a new blueprint-managed authentik RADIUS outpost "802.1X Radius" (2 replicas) whose provider returns the VLAN from the group attribute `dot1x_vlan`. UniFi gets a RADIUS profile, a test WLAN and a firewall policy via the UDM API.
**Tech Stack:** Flux/Kustomize manifests, authentik 2026.8 blueprints, `freeradius/freeradius-server:3.2.10-alpine`, cert-manager, External Secrets (OpenBao), Cilium LB-IPAM/BGP + CNP, UniFi Network API, `eapol_test` (workstation `/usr/bin/eapol_test`).
**Spec:** `docs/superpowers/specs/2026-11-03-wifi-radius-authentik-design.md`
## Global Constraints
- Work only in this worktree; commits as `git -c user.name='Claude Bot' -c user.email='claude-bot@renner.uno' commit …`; never push to `main`, open a PR, never self-merge.
- Never read Kubernetes Secret data; never print secret values; credentials only via env var on the local command (`VAR="$SECRET" tool`), never inside a string another program interprets.
- authentik users/groups are created by the owner, not in blueprints.
- Group attribute name: `dot1x_vlan` (integer). Test VLAN: `50`.
- LB: `lbipam.cilium.io/ips: "10.2.99.91,fd99::91"`, label `lb-pool: bgp`, `externalTrafficPolicy: Local`, `ipFamilyPolicy: PreferDualStack`.
- Hostname/cert: `radius.renner.uno`, ClusterIssuer `letsencrypt-cluster`.
- RADIUS clients: `10.2.10.0/24` (APs/switches). Test client: workstation network `10.2.50.0/24` with its own secret (test phase only).
- Existing "Unifi Radius" provider and "Local Radius" outpost: untouched.
- Main WLANs "Renner UniFi", "Renner IoT", "Renner Gast": untouched.
- Resource convention: requests set, memory limit only, no CPU limit; `allowPrivilegeEscalation: false`, `capabilities.drop: [ALL]`.
- New manifests must be listed in `kubernetes/apps/authentik/kustomization.yaml`.
## Review Focus
- User not in any `dot1x_vlan` group → must get Access-Reject, never an Accept without VLAN (would land in the Guest fallback).
- Wrong password → Access-Reject within < 1 s, no retry storm between FreeRADIUS and outpost.
- Outpost reject packets lack `Message-Authenticator` (upstream behaviour) → FreeRADIUS must still forward a Reject to the AP, not time out.
- One FreeRADIUS replica or one outpost replica down → logins keep working (Service routes to the other).
- Accounting packets from APs (UDP 1813) → answered locally, never forwarded to the outpost (which has no 1813).
---
### Task 1: Secrets and server certificate
**Files:**
- Create: `kubernetes/apps/authentik/radius-cert.yaml`
- Modify: `kubernetes/apps/authentik/kustomization.yaml`
**Interfaces:**
- Produces: Secret `radius-tls` (keys `tls.crt`, `tls.key`); OpenBao `apps/authentik` keys `DOT1X_OUTPOST_SECRET`, `DOT1X_NAS_SECRET`, `DOT1X_TEST_SECRET` (reach the cluster as keys of the existing Secret `authentik-secrets` via `externalsecret.yaml`).
- [ ] **Step 1:** Generate three secrets (`openssl rand -base64 48`, piped straight into `bao kv patch apps/authentik DOT1X_OUTPOST_SECRET=- …` without echoing) — if claude-bot's OpenBao policy cannot write `apps/authentik`, stop and hand the exact `bao kv patch` command to the owner.
- [ ] **Step 2:** Verify key names only: `bao kv get -format=json apps/authentik | jq -r '.data.data | keys[]' | grep DOT1X` → three names.
- [ ] **Step 3:** Write `radius-cert.yaml` (Certificate `radius-tls`, secretName `radius-tls`, dnsNames `[radius.renner.uno]`, issuerRef `letsencrypt-cluster`/ClusterIssuer), add to kustomization.
- [ ] **Step 4:** `kubectl kustomize kubernetes/apps/authentik >/dev/null` → exit 0; `kubectl apply --dry-run=server -f kubernetes/apps/authentik/radius-cert.yaml` → `created (server dry run)`.
- [ ] **Step 5:** Commit `feat(radius): server certificate for radius.renner.uno`.
### Task 2: authentik provider, VLAN mapping and outpost
**Files:**
- Modify: `kubernetes/apps/authentik/blueprints-configmap.yaml` (new key `dot1x-radius.yaml`)
- Modify: `kubernetes/apps/authentik/helmrelease.yaml` (worker env `DOT1X_OUTPOST_SECRET` from `authentik-secrets`, only if blueprints cannot already read it via `!Env`)
- Create: `docs/radius/dot1x.md` (owner steps + conventions)
**Interfaces:**
- Consumes: `DOT1X_OUTPOST_SECRET` (Task 1).
- Produces: outpost "802.1X Radius" → operator Service `ak-outpost-802-1x-radius` (ClusterIP, UDP 1812), pod labels `goauthentik.io/outpost-name: 802-1x-radius`; provider "802.1X Radius" with `client_networks` = cluster pod CIDRs (read from `kubectl get nodes -o jsonpath` podCIDRs / Cilium config, not guessed); reply carries the VLAN either as standard `Tunnel-Type`/`Tunnel-Medium-Type`/`Tunnel-Private-Group-Id` or, fallback, as `Cisco-AV-Pair = "dot1x-vlan=<id>"`.
- [ ] **Step 1: Blueprint objects** — expression policy `dot1x-has-vlan` (`return any(g.attributes.get("dot1x_vlan") for g in request.user.ak_groups.all())`), RADIUS property mapping `dot1x-vlan` (lowest numeric `dot1x_vlan` across the user's groups wins), provider "802.1X Radius" (`authorization_flow: ldap-authentication-flow` after Step 2 confirms it is non-interactive, `invalidation_flow: default-invalidation-flow`, `mfa_support: false`, `shared_secret: !Env DOT1X_OUTPOST_SECRET`, `property_mappings: [dot1x-vlan]`), application slug `dot1x` bound to `dot1x-has-vlan`, outpost "802.1X Radius" (type radius, `k8s-conn`, namespace `authentik`, `kubernetes_replicas: 2`, `kubernetes_service_type: ClusterIP`, providers: only "802.1X Radius").
- [ ] **Step 2:** Confirm `ldap-authentication-flow` contains only identification + password stages (authentik API/UI read); if not, add a blueprint flow `dot1x-authentication-flow` with exactly those two stages and use it.
- [ ] **Step 3:** Owner step (documented in `docs/radius/dot1x.md`, asked of the owner during execution): create group `802.1X Privat` with attribute `dot1x_vlan: 50`, add the owner and a test user `dot1x-test`.
- [ ] **Step 4:** After merge + reconcile: `kubectl -n authentik get deploy ak-outpost-802-1x-radius -o json | jq .status.readyReplicas` → `2`.
- [ ] **Step 5: VLAN attribute spike** — from a throwaway pod in `authentik` (`kubectl run radtest --rm -i --image=freeradius/freeradius-server:3.2.10-alpine --command -- radtest dot1x-test "$PW" ak-outpost-802-1x-radius 0 "$SECRET"` with values passed via `--env`, not in the string) expect `Access-Accept` containing `Tunnel-Private-Group-Id = "50"`. If the outpost cannot emit standard Tunnel attributes, switch the mapping to the `Cisco-AV-Pair` fallback and expect `Cisco-AV-Pair = "dot1x-vlan=50"`. Record which one in `docs/radius/dot1x.md`.
- [ ] **Step 6:** Same with a user outside any `dot1x_vlan` group and with a wrong password → `Access-Reject` (< 1 s).
- [ ] **Step 7:** Commit `feat(authentik): 802.1X RADIUS provider, VLAN mapping and outpost`.
### Task 3: FreeRADIUS service
**Files:**
- Create: `kubernetes/apps/authentik/freeradius-config.yaml` (ConfigMap: `radiusd.conf`, `clients.conf`, `proxy.conf`, `mods-enabled/eap`, `sites-enabled/default`, `sites-enabled/inner-tunnel`)
- Create: `kubernetes/apps/authentik/freeradius.yaml` (Deployment + Service `radius-lb`)
- Create: `kubernetes/apps/authentik/freeradius-cnp.yaml`
- Create: `kubernetes/infrastructure/image-automation/freeradius.yaml`; modify its `kustomization.yaml`
- Modify: `kubernetes/apps/authentik/kustomization.yaml`
**Interfaces:**
- Consumes: `radius-tls`, `authentik-secrets` keys `DOT1X_NAS_SECRET`, `DOT1X_TEST_SECRET`, `DOT1X_OUTPOST_SECRET`; outpost Service `ak-outpost-802-1x-radius:1812/UDP`; reply VLAN format from Task 2.
- Produces: `radius.renner.uno` → `10.2.99.91`/`fd99::91`, UDP 1812/1813.
- [ ] **Step 1: Config** — `clients.conf`: client `aps` `10.2.10.0/24` secret `$ENV{DOT1X_NAS_SECRET}`, client `test` `10.2.50.0/24` secret `$ENV{DOT1X_TEST_SECRET}`, both `require_message_authenticator = yes`. `eap`: `default_eap_type = ttls`, TLS from `/etc/raddb/certs/radius-tls/{tls.crt,tls.key}`, `ttls { virtual_server = inner-tunnel, use_tunneled_reply = yes }`; no PEAP/MSCHAP. `inner-tunnel`: PAP proxied to home server `authentik` (`ak-outpost-802-1x-radius.authentik.svc.cluster.local:1812`, secret `$ENV{DOT1X_OUTPOST_SECRET}`, `response_window = 3`, do not require Message-Authenticator on its rejects). `default` post-auth: if the Cisco-AV-Pair fallback is in use, translate `dot1x-vlan=<id>` into `Tunnel-Type := VLAN`, `Tunnel-Medium-Type := IEEE-802`, `Tunnel-Private-Group-Id := <id>`; reject an Accept that carries no VLAN. Accounting: answer locally, `detail`-free.
- [ ] **Step 2: Deployment** — image `freeradius/freeradius-server:3.2.10-alpine # {"$imagepolicy": "flux-system:freeradius"}`, args `-f -l stdout`, 2 replicas, `podAntiAffinity` required on `kubernetes.io/hostname`, env from the three secret keys (`secretKeyRef`, not `envFrom`), mounts ConfigMap + `radius-tls`, readiness/liveness via `radclient` Status-Server on 127.0.0.1 or a TCP-less exec check, requests `cpu 20m / memory 64Mi`, limit `memory 256Mi`, `stakater.com/auto: "true"` like other apps. Service `radius-lb` per Global Constraints, ports 1812/1813 UDP, annotation `external-dns.kubernetes.io/hostname: radius.renner.uno`.
- [ ] **Step 3: CNPs** — ingress to FreeRADIUS pods from `10.2.10.0/24` and `10.2.50.0/24` UDP 1812/1813; ingress to pods `goauthentik.io/outpost-name: 802-1x-radius` UDP 1812 only from FreeRADIUS pods (model on `ldap-outpost-cnp.yaml`, use container ports).
- [ ] **Step 4: Image automation** — `ImageRepository freeradius` (`docker.io/freeradius/freeradius-server`), `ImagePolicy freeradius` with `filterTags.pattern: '^(?P<v>3\.2\.\d+)-alpine$'`, `extract: '$v'`, `semver.range: "3.2.x"`.
- [ ] **Step 5:** `kubectl kustomize kubernetes/apps/authentik >/dev/null` and `kubectl apply --dry-run=server` on the new files → no errors.
- [ ] **Step 6:** After merge + reconcile: `kubectl -n authentik get deploy freeradius -o json | jq .status.readyReplicas` → `2`; `kubectl -n authentik get svc radius-lb -o json | jq -r '.status.loadBalancer.ingress[].ip'` → `10.2.99.91` and `fd99::91`.
- [ ] **Step 7: End-to-end from the workstation** — `eapol_test` config (TTLS, phase2 `auth=PAP`, `ca_cert` system bundle, `domain_suffix_match="radius.renner.uno"`, identity `dot1x-test`) run as `DOT1X_TEST_SECRET=… eapol_test -c ttls.conf -a 10.2.99.91 -s "$DOT1X_TEST_SECRET"` with the password supplied from an env var into the config file in the scratchpad (file deleted afterwards) → `SUCCESS`, `Tunnel-Private-Group-Id` `50` in the output; same with wrong password → `FAILURE` in < 1 s; user without group → `FAILURE`.
- [ ] **Step 8: Redundancy check without touching live pods** — confirm both FreeRADIUS replicas and both outpost replicas serve: `kubectl -n authentik get pods -l app.kubernetes.io/name=freeradius -o wide` shows two different nodes; loop `eapol_test` 20× → 20× `SUCCESS` and both pods log requests (`kubectl logs`). No rollout restart or pod deletion (house rule).
- [ ] **Step 9: Alert** — add rules to `kubernetes/apps/monitoring-rules/` (follow the existing rule files there): `kube_deployment_status_replicas_available{namespace="authentik",deployment=~"freeradius|ak-outpost-802-1x-radius"} < 1` for 2m → critical; `< 2` for 15m → warning. Verify `kubectl kustomize kubernetes/apps/monitoring-rules >/dev/null`.
- [ ] **Step 10:** Commit `feat(radius): FreeRADIUS EAP-TTLS frontend for authentik`.
### Task 4: UniFi RADIUS profile, test WLAN, firewall policy
**Files:**
- Modify: `docs/radius/dot1x.md` (record created objects, IDs, settings)
**Interfaces:**
- Consumes: `radius.renner.uno`/`10.2.99.91`, `DOT1X_NAS_SECRET`.
- Produces: UniFi RADIUS profile "Cluster 802.1X" (auth `10.2.99.91:1812`, acct `10.2.99.91:1813`, VLAN assignment enabled), WLAN "Renner Test", firewall policy "Allow APs RADIUS".
- [ ] **Step 1:** POST firewall policy (clone of "Allow APs syslog to Loki": source zone Isolated IP `10.2.10.0/24`, destination zone External IP `10.2.99.91`, UDP, ports `1812-1813`, ALLOW) via `/proxy/network/v2/api/site/default/firewall-policies`; verify it is listed.
- [ ] **Step 2:** Create RADIUS profile via `/proxy/network/api/s/default/rest/radiusprofile` (secret passed from env into a JSON file in the scratchpad, never on the command line); verify listed.
- [ ] **Step 3:** Create WLAN "Renner Test" via `/rest/wlanconf` cloned from "Renner UniFi" settings (security `wpaeap`, `wpa3_support`/`wpa3_transition` as main, `fast_roaming_enabled: true`, `bss_transition: true`, same `ap_group_ids`, `networkconf_id` (90) Guest as fallback) with the new `radiusprofile_id`; verify all APs broadcast it (`stat/device` vap_table essid).
- [ ] **Step 4:** Owner joins one Android device; verify in Loki stahtd `success` with `radius_auth_delta` and `vlan 50` in `stat/sta`.
- [ ] **Step 5:** Commit `docs(radius): document UniFi objects for the 802.1X test`.
### Task 5: Client configuration aids and measurement
**Files:**
- Create: `docs/radius/clients/renner-test.mobileconfig` (no password; EAP-TTLS, `TTLSInnerAuthentication: PAP`, `TLSTrustedServerNames: [radius.renner.uno]`)
- Modify: `docs/radius/dot1x.md` (Android/Linux/Windows steps; LogQL for `radius_auth_delta` per SSID; result table)
- [ ] **Step 1:** Write the profile; validate `plutil -lint`-equivalent: `xmllint --noout docs/radius/clients/renner-test.mobileconfig` → no output.
- [ ] **Step 2:** Document the comparison query (stahtd events, `radius_auth_delta`, grouped by VAP of "Renner Test" vs. "Renner UniFi") and run it once the owner's devices have joined; record avg/max.
- [ ] **Step 3:** Commit `docs(radius): client setup and measurement for the 802.1X test`.
### Task 6: PR
- [ ] **Step 1:** Push branch with claude-bot credentials; open PR via `tea pr create --login claude-bot` (description = the change; no session status).
- [ ] **Step 2:** Verify with `tea pr list --login claude-bot --state open`.
@@ -1,228 +0,0 @@
# 802.1X via authentik: in-cluster RADIUS (first test on a test SSID)
Date: 2026-11-03
## Intent
Full 802.1X logins on the main WLAN "Renner UniFi" spend 1.3–6 s in RADIUS
(avg ~2 s, every login >1 s; 0.23–0.43 s per RADIUS round trip). The RADIUS
server is the UDM's built-in FreeRADIUS, which runs Ubiquiti's
`/usr/sbin/pyauth_log.py` in authorize/authenticate/post-auth and spawns
`mca-ctrl` synchronously for every RADIUS packet. Every failed fast roam,
every arrival at home and every band change pays that cost. The owner has
been unhappy with the UDM RADIUS performance for a while.
This project stands up an in-cluster RADIUS whose password check and group
membership come from authentik, and puts it behind a **separate test SSID**.
The owner moves some of his own devices to it. If RADIUS time per login is
significantly better, a solution for the rest of the household follows in a
later project.
## Goals
- In-cluster RADIUS for **802.1X in general** (WiFi and wired switch ports;
owner: "802.1x ist das was relevant ist") that authenticates users with
their **authentik username and password** (central credentials; a password
change in authentik applies immediately).
- Password verification **by authentik itself** via the authentik RADIUS
outpost (owner decision).
- **VLAN per user from authentik groups.**
- Must work with **every client OS** (Android, iOS/iPadOS, macOS, Windows,
Linux).
- Client setup as automatic as possible; no client certificates, no private
CA. A device configured once should carry the credentials to new devices
through platform sync where the platform does so.
- A test SSID that points only at the new RADIUS; measurable result.
## Non-goals
- Migrating "Renner UniFi" or the UniFi Endpoint-provisioned accounts. The main
WLAN keeps using the UDM RADIUS unchanged.
- VPN authentication (OpenVPN "Renner SSL VPN" also uses the UDM RADIUS
profile; out of scope).
- Enabling 802.1X on switch ports in this project (no port has it today); the
server must be ready for it (switches as RADIUS clients); the first test is
the test SSID.
- An onboarding page that hands out Apple/Windows profiles — owner: leave it
out for the test (likely follow-up).
- RADIUS accounting (authentik supports authentication requests only).
- MFA for 802.1X.
## Anti-goals (a failure even if it technically works)
- Anything that changes behaviour of the existing "Renner UniFi", "Renner IoT"
or "Renner Gast" WLANs or breaks the UniFi Endpoint app.
- Client certificates (EAP-TLS) — owner: without central device management
unacceptable fiddling.
- NT hashes (PEAP-MSCHAPv2 via `sambaNTPassword` from authentik-samba-bridge)
— rejected by the owner: weak password-equivalent MD4 hash stored and
exposed, and dependency on the samba-bridge fork.
- A private CA that every client has to trust.
- Copying Ubiquiti's RADIUS certificate/private key off the UDM.
## Constraints and facts
- authentik RADIUS provider (outpost image `ghcr.io/goauthentik/radius:2026.8.4`)
supports **PAP** and **EAP-TLS** only (no PEAP/MSCHAPv2, no EAP-TTLS);
custom reply attributes via RADIUS property mappings (since 2024.8);
authentication requests only (docs.goauthentik.io, RADIUS provider). It
speaks plain RADIUS (no RadSec).
- APs/switches speak EAP, so an EAP server must terminate the outer method:
FreeRADIUS terminates **EAP-TTLS** and forwards the **inner PAP** request to
the authentik RADIUS outpost.
- Client OS support for EAP-TTLS/PAP (vendor docs: IronWiFi, JumpCloud,
Fortinet, Meraki; no Apple statement found):
- Android, Linux: normal join dialog (EAP method TTLS, phase 2 PAP,
identity/password, CA "system certificates", domain).
- Windows 11: no file needed, but via "add network manually": EAP-TTLS,
"Unencrypted password (PAP)" (Control Panel route if Settings lacks it).
- iOS/iPadOS/macOS: requires a configuration profile (inner auth PAP is
only selectable in a profile). For the test the profile is built and
installed by hand.
- authentik does not support EAP (maintainer, goauthentik/authentik#15498);
MSCHAPv2/PEAP will not be implemented (reversible password storage);
EAP-TLS needs the MTLS stage (enterprise-licensed per community project
wus-technik/authentik-freeradius). FreeRADIUS in front of the outpost is
the established pattern. Owner decision (2026-11-03): **FreeRADIUS as its
own service** — the alternatives (FreeRADIUS as sidecar in the outpost pod,
keeping the UDM RADIUS, another IdP) were rejected.
- Known outpost issues with `Message-Authenticator` (goauthentik/authentik
#17901, #22931: packets dropped as "invalid message authenticator"). The
builder must test the FreeRADIUS → outpost hop with Message-Authenticator
early and document the working combination.
- **The FreeRADIUS → authentik outpost hop is plain RADIUS**: the password is
only obfuscated with the shared secret (RFC 2865 MD5 hiding). Cilium has no
transparent encryption enabled (no WireGuard/IPsec in `cilium-config`).
The owner chose this path over LDAPS bind (2026-11-03). Mitigations below.
- Existing objects: RADIUS provider "Unifi Radius" (exists in the authentik
DB, only referenced by `!Find` in
`kubernetes/apps/authentik/blueprints-configmap.yaml`) and outpost
"Local Radius" (`ak-outpost-local-radius`, ClusterIP, 1 replica, namespace
`authentik`). What currently uses "Unifi Radius" is unknown; it must not
break.
- authentik users and groups are created by the owner in authentik, not in
blueprints. Designs read a convention.
- APs (and switches) send RADIUS from the management network VLAN 10
(`10.2.10.0/24`). VLAN 10 is dual-stack, `10.2.10.0/24` and
`fd02:10::/64` (verified live: all APs/switches have fd02:10:: addresses).
- LB services: pool `bgp-pool` (10.2.99.0/24, fd99::/64, label `lb-pool: bgp`,
`kubernetes/infrastructure/post-crds/cilium-bgp.yaml`), pinned IPs with the
same last byte in both families (`lbipam.cilium.io/ips`),
`externalTrafficPolicy: Local` (preserves the AP source IP),
`ipFamilyPolicy: PreferDualStack`. Live in use: .81–.90.
- UDM firewall: the LB range 10.2.99.0/24 is in the UDM "External" zone; the
existing policy "Allow APs syslog to Loki" (Isolated 10.2.10.0/24 → External
10.2.99.84 UDP 5514) is the template. Firewall policies and WLANs are not in
OpenTofu today (UI/API only; the UDM API key is available to the builder).
- House patterns: cert-manager `Certificate` with ClusterIssuer
`letsencrypt-cluster` (Cloudflare DNS-01), as `ldap-outpost-cert.yaml`;
secrets via External Secrets Operator from OpenBao (`ClusterSecretStore
openbao`, key `apps/<app>`, Reloader restarts pods); DNS via
`external-dns.kubernetes.io/hostname` on the Service (UDM split-horizon);
additive CNP for LAN ingress like `ldap-outpost-cnp.yaml`; namespace
baseline `infrastructure/network-policies/authentik-ns.yaml`.
## Design (decided)
### Authentication path
```
client --EAP-TTLS (outer TLS to FreeRADIUS, cert radius.renner.uno), inner PAP--> AP / switch
AP/switch --RADIUS UDP 1812--> FreeRADIUS (in cluster, LB 10.2.99.91)
FreeRADIUS (inner tunnel) --RADIUS PAP--> authentik RADIUS outpost (in cluster)
<-- Access-Accept + VLAN attributes (authentik property mapping) --
FreeRADIUS --Access-Accept + Tunnel attrs (copied from inner reply)--> AP/switch
```
- Owner decision: username + **authentik password** (central credentials).
- Owner decision: password check **directly by the authentik RADIUS outpost**.
- Owner decision: **separate test SSID**; test devices are the owner's own,
across all OSes, and must keep working normally.
- Owner decision: client certificates and NT hashes are out.
- Success threshold (proposed by me, accepted by the owner): RADIUS time per
full login **< 0.5 s** vs. ~2 s today — a data point for the follow-up
decision, not a hard pass/fail gate.
### Protecting the FreeRADIUS → outpost hop (my calls)
- CNP so the RADIUS outpost accepts RADIUS only from the FreeRADIUS pods.
- Long random shared secret for this hop (OpenBao).
- Require and send `Message-Authenticator` on both hops (BlastRADIUS).
### Server certificate
Public Let's Encrypt certificate for `radius.renner.uno` via cert-manager
(renewed automatically). Android/Linux validate with system certificates +
domain `radius.renner.uno`; Apple via the profile (CA/server name in the
profile); Windows shows the certificate on first join. No private CA.
(My call on the hostname.) The builder verifies and documents what clients do
after a certificate renewal (Windows may prompt again).
### VLAN from groups (convention, my call)
A group that grants 802.1X access (WiFi and wired) carries the group
attribute `dot1x_vlan` (integer VLAN ID, e.g. `50`). An authentik RADIUS
property mapping returns `Tunnel-Type = VLAN (13)`,
`Tunnel-Medium-Type = IEEE-802 (6)`, `Tunnel-Private-Group-Id = <dot1x_vlan>`.
A user without any group carrying `dot1x_vlan` is rejected. If a user has
several, the builder picks a deterministic rule and documents it. The owner
creates the groups and memberships in authentik; for the test the owner's
group maps to VLAN 50 ((50) Privat), like his current UDM account.
### authentik side (my calls)
- A dedicated RADIUS provider for 802.1X (blueprint-managed), with the VLAN
property mapping and a non-interactive authentication flow, attached to an
outpost with **2 replicas**. Whether that is the existing "Local Radius"
outpost or a new one is the builder's call, after finding out what uses
"Unifi Radius" today; nothing that already uses it may break.
- The provider's allowed client network: the FreeRADIUS pods only.
### Test SSID (my calls)
- Name: "Renner Test".
- WPA2/WPA3-Enterprise like "Renner UniFi", Fast Roaming on, BSS transition on,
same bands. Fallback network for unassigned clients: same as "Renner UniFi"
((90) Guest).
- RADIUS profile with **only** the cluster RADIUS (no UDM fallback), so the
measurement shows the real performance.
### Placement and exposure (my calls)
- FreeRADIUS in namespace `authentik`, files under `kubernetes/apps/authentik/`
(no new Flux Kustomization; egress to the outpost is already allowed by the
namespace baseline). **2 replicas on different nodes.**
- LB Service `radius-lb`, IPs `10.2.99.91,fd99::91`, UDP 1812 (+1813 so APs
don't wait on accounting), `external-dns` hostname `radius.renner.uno`.
- Additive CNP: ingress from `10.2.10.0/24` to the FreeRADIUS pods, UDP
1812/1813.
- RADIUS clients in FreeRADIUS: APs and switches (`10.2.10.0/24`).
- Secrets (AP/switch↔FreeRADIUS, FreeRADIUS↔outpost) in OpenBao `apps/radius`.
- UDM firewall policy "Allow APs RADIUS": Isolated `10.2.10.0/24` → External
`10.2.99.91` UDP 1812/1813, modelled on "Allow APs syslog to Loki".
- Accounting: FreeRADIUS accepts and discards.
### Measurement and test
- stahtd `STA_ASSOC_TRACKER` events in Loki (`{source="unifi-syslog",
app="stahtd"}`) carry `radius_auth_delta` (µs) per full login, end to end as
seen by the AP (including the authentik check). Compare the test SSID's VAPs
against "Renner UniFi" over the test period.
- Test matrix: the owner's devices — Android, iOS/iPadOS, macOS (with
hand-made profile), Windows, Linux — join "Renner Test"; each must connect,
land in VLAN 50, survive fast roaming between APs, and keep working
normally.
## Left to the builder
- FreeRADIUS image/version and configuration details (EAP-TTLS module,
inner-tunnel proxying of PAP to the outpost, copying inner reply
attributes / `use_tunneled_reply`, Message-Authenticator settings, TLS
settings), resource requests/limits.
- The authentik flow used for RADIUS password checks (must not require
interactive stages) and the exact property-mapping code.
- How the UniFi RADIUS profile, test WLAN and firewall policy are created
(UniFi API vs. OpenTofu); if not IaC, document them.
- The Apple profile for the test (`.mobileconfig` with TTLS/PAP, server name
`radius.renner.uno`), and the Windows manual steps.
- Verification steps and monitoring (metrics, alert on RADIUS unreachable).
-7
View File
@@ -22,13 +22,6 @@ Two rules, one per family, send every packet with a pod source to table 47
(`talos/patches/site-homelab.yaml`, priorities 1000/1001, below main). The
table decides by longest prefix; no rule carves out exceptions.
A reply from a LoadBalancer VIP is pod traffic as well, but it reaches the
routing decision with the VIP as its source. Two more rules (1002/1003) send
the LoadBalancer pool to table 47, so the reply leaves over `ingress0`, where
the request arrived as the BGP next hop. Without them main sent it out
`mgmt0`. The pool is a site fact: the site patch's `lb-pool-site` ConfigMap,
which `bgp-pool` takes by Flux substitution, and the rules beside it.
bird (`kubernetes/infrastructure/bird/`) owns table 47 and fills it with:
- the node's attached networks — `mgmt0`, `ingress0`, `pod0`, `node0` —
@@ -48,7 +48,7 @@ spec:
# cert when doing server-side OIDC token exchange. Bundle both
# roots into one PEM since NODE_EXTRA_CA_CERTS accepts one file.
- name: fetch-le-staging-roots
image: curlimages/curl:8.23.0@sha256:3fb7e13e5d179a845c97d78204f3f1064d1907037f26a3ebf91710571151fd93
image: curlimages/curl:8.23.0@sha256:11e64e28c5f58c8d93ee95fcf22ac055f48b0bc440e0ce7d8013bbb5ad6aa8ac
command:
- sh
- -c
@@ -103,16 +103,6 @@ data:
slug: paperless-proxy
state: absent
# syncthing.paperless.renner.uno is gated by authentik-gate-sync
# (kubernetes/apps/paperless/syncthing.yaml), which admits this group.
# Members are managed in Authentik.
- model: authentik_core.group
identifiers:
name: Paperless Admins
state: present
attrs:
is_superuser: false
# vscode.home.renner.uno is gated by authentik-gate-sync
# (kubernetes/apps/home-assistant/vscode-ingress.yaml), which admits this
# group. Members are managed in Authentik.
@@ -715,9 +705,8 @@ data:
# AND (&(sambaDomainName=...)(objectClass=sambaDomain))
# return entries instead of short-circuiting. Built from
# the samba-bridge repo at
# gitea.renner.uno/konairius/authentik-samba-bridge ; Flux
# substitutes the image from clusters/homelab/authentik.yaml.
container_image: ${SAMBA_BRIDGE_LDAP_OUTPOST_IMAGE}
# gitea.renner.uno/konairius/authentik-samba-bridge .
container_image: gitea.renner.uno/konairius/authentik-samba-bridge-ldap-outpost:2026.8.4-3c8f413
object_naming_template: "ak-outpost-%(name)s"
kubernetes_namespace: authentik
kubernetes_replicas: 1
@@ -1447,180 +1436,3 @@ data:
stage: !KeyOf sso-passkey-validation
order: 30
state: present
# 802.1X: FreeRADIUS terminates EAP-TTLS and proxies inner PAP to the
# "802.1X Radius" outpost. VLAN from group attribute dot1x_vlan; see
# docs/radius/dot1x.md.
dot1x-radius.yaml: |
version: 1
metadata:
name: 802.1X RADIUS (homelab)
entries:
# Non-interactive: identification, password, login. No MFA.
- id: dot1x-flow
model: authentik_flows.flow
identifiers:
slug: dot1x-authentication-flow
state: present
attrs:
name: 802.1X authentication
title: "802.1X"
designation: authentication
- id: dot1x-identification
model: authentik_stages_identification.identificationstage
identifiers:
name: dot1x-identification
state: present
attrs:
user_fields:
- username
- email
- id: dot1x-password
model: authentik_stages_password.passwordstage
identifiers:
name: dot1x-password
state: present
attrs:
backends:
- authentik.core.auth.InbuiltBackend
- id: dot1x-login
model: authentik_stages_user_login.userloginstage
identifiers:
name: dot1x-login
state: present
- model: authentik_flows.flowstagebinding
identifiers:
target: !KeyOf dot1x-flow
stage: !KeyOf dot1x-identification
order: 10
state: present
- model: authentik_flows.flowstagebinding
identifiers:
target: !KeyOf dot1x-flow
stage: !KeyOf dot1x-password
order: 20
state: present
- model: authentik_flows.flowstagebinding
identifiers:
target: !KeyOf dot1x-flow
stage: !KeyOf dot1x-login
order: 100
state: present
- id: dot1x-has-vlan
model: authentik_policies_expression.expressionpolicy
identifiers:
name: dot1x-has-vlan
state: present
attrs:
expression: |
for g in request.user.all_groups():
v = g.attributes.get("dot1x_vlan")
if type(v) is int and 1 <= v <= 4094:
return True
return False
# Lowest dot1x_vlan wins. Tunnel-* (RFC 3580) set by raw code: the
# bundled dictionary lacks them. Cisco-AV-Pair is the fallback.
- id: dot1x-vlan-mapping
model: authentik_providers_radius.radiusproviderpropertymapping
identifiers:
name: dot1x-vlan
state: present
attrs:
expression: |
vlans = [g.attributes.get("dot1x_vlan") for g in request.user.all_groups()]
vlans = [v for v in vlans if type(v) is int and 1 <= v <= 4094]
if not vlans:
return packet
vlan = str(min(vlans))
try:
packet[64] = [b"\x00\x00\x00\x0d"] # Tunnel-Type = VLAN
packet[65] = [b"\x00\x00\x00\x06"] # Tunnel-Medium-Type = IEEE-802
packet[81] = [vlan.encode()] # Tunnel-Private-Group-Id
except Exception:
pass
define_attribute(
vendor_code=9,
vendor_name="Cisco",
attribute_name="AV-Pair",
attribute_code=1,
attribute_type="string",
)
packet["Cisco-AV-Pair"] = f"dot1x-vlan={vlan}"
return packet
# client_networks: cluster pod CIDRs (kube-controller-manager
# --cluster-cidr, talos/patches/cluster.yaml podSubnets).
- id: dot1x-provider
model: authentik_providers_radius.radiusprovider
identifiers:
name: 802.1X Radius
state: present
attrs:
authorization_flow: !KeyOf dot1x-flow
invalidation_flow: !Find [authentik_flows.flow, [slug, default-invalidation-flow]]
shared_secret: !Env DOT1X_OUTPOST_SECRET
client_networks: "10.244.0.0/16,fd10:244::/48"
mfa_support: false
certificate: null
property_mappings:
- !KeyOf dot1x-vlan-mapping
- id: dot1x-application
model: authentik_core.application
identifiers:
slug: dot1x
state: present
attrs:
name: 802.1X network access
slug: dot1x
provider: !KeyOf dot1x-provider
meta_description: "WiFi and wired 802.1X via FreeRADIUS"
- model: authentik_policies.policybinding
identifiers:
target: !KeyOf dot1x-application
policy: !KeyOf dot1x-has-vlan
order: 0
state: present
attrs:
enabled: true
negate: false
failure_result: false
- model: authentik_outposts.outpost
identifiers:
name: 802.1X Radius
state: present
attrs:
type: radius
service_connection: !Find [authentik_outposts.kubernetesserviceconnection, [name, "Local Kubernetes Cluster"]]
providers:
- !KeyOf dot1x-provider
config:
log_level: info
authentik_host: "http://authentik-server.authentik.svc.cluster.local"
authentik_host_insecure: false
authentik_host_browser: "https://auth.renner.uno/"
refresh_interval: "minutes=5"
container_image: null
object_naming_template: "ak-outpost-%(name)s"
kubernetes_namespace: authentik
kubernetes_replicas: 2
kubernetes_service_type: ClusterIP
kubernetes_disabled_components: []
kubernetes_image_pull_secrets: []
kubernetes_ingress_annotations: {}
kubernetes_ingress_class_name: null
kubernetes_ingress_secret_name: "authentik-outpost-tls"
kubernetes_ingress_path_type: null
kubernetes_httproute_annotations: {}
kubernetes_httproute_parent_refs: []
kubernetes_json_patches: null
@@ -1,45 +0,0 @@
# RADIUS clients reach FreeRADIUS through radius-lb (externalTrafficPolicy
# Local keeps their source IP): APs 10.2.10.0/24 + fd02:10::/64, test
# clients 10.2.50.0/24 + fd02:50::/64 (test phase only).
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: freeradius-nas-ingress
namespace: authentik
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: freeradius
ingress:
- fromCIDR:
- 10.2.10.0/24
- fd02:10::/64
- 10.2.50.0/24
- fd02:50::/64
toPorts:
- ports:
- port: "1812"
protocol: UDP
- port: "1813"
protocol: UDP
---
# FreeRADIUS proxies inner PAP to the 802.1X outpost on its container port.
# Additive: default-deny-ingress already admits the whole namespace.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: dot1x-outpost-freeradius-ingress
namespace: authentik
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: authentik-outpost-radius
goauthentik.io/outpost-name: 8021x-radius
ingress:
- fromEndpoints:
- matchLabels:
app.kubernetes.io/name: freeradius
toPorts:
- ports:
- port: "1812"
protocol: UDP
@@ -1,334 +0,0 @@
# FreeRADIUS 3.2 config for 802.1X: EAP-TTLS terminated here, inner PAP
# proxied to the authentik outpost ak-outpost-8021x-radius. Replaces the
# image's raddb completely. Functional test: tests/radius/run.sh.
# VLAN contract: docs/radius/dot1x.md.
apiVersion: v1
kind: ConfigMap
metadata:
name: freeradius-config
namespace: authentik
annotations:
# FreeRADIUS uses ${...} itself; Flux postBuild substitution must skip it.
kustomize.toolkit.fluxcd.io/substitute: disabled
data:
radiusd.conf: |
prefix = /opt
exec_prefix = ${prefix}
sbindir = ${exec_prefix}/sbin
libdir = ${exec_prefix}/lib
raddbdir = /run/raddb/raddb
confdir = ${raddbdir}
modconfdir = ${confdir}/mods-config
certdir = ${confdir}/certs
cadir = ${confdir}/certs
db_dir = ${raddbdir}
# only writable path in the pod
localstatedir = /tmp
logdir = /tmp
radacctdir = /tmp
run_dir = /tmp
name = radiusd
pidfile = ${run_dir}/${name}.pid
max_request_time = 30
cleanup_delay = 5
# Greater than response_window (proxy.conf): never forward a NAS
# retransmit to the outpost. A TTLS inner request sent twice to the home
# server leaves a freed request in the proxy hash; radiusd segfaults.
proxy_dedup_window = 10
max_requests = 16384
hostname_lookups = no
log {
destination = stdout
colourise = no
stripped_names = no
auth = yes
auth_badpass = no
auth_goodpass = no
}
checkrad = ${sbindir}/checkrad
security {
allow_core_dumps = no
max_attributes = 200
# Rejects decided here wait 0.5 s; proxied rejects are not delayed.
reject_delay = 0.5
status_server = yes
require_message_authenticator = yes
limit_proxy_state = yes
}
proxy_requests = yes
$INCLUDE proxy.conf
$INCLUDE clients.conf
thread pool {
start_servers = 2
max_servers = 16
min_spare_servers = 2
max_spare_servers = 4
max_requests_per_server = 0
}
modules {
$INCLUDE mods-enabled/
}
instantiate {
}
policy {
}
$INCLUDE sites-enabled/
# Site-local dictionary; the image's dictionaries under /opt/share/freeradius
# are loaded first.
dictionary: |
# no local attributes
clients.conf: |
client aps {
ipaddr = 10.2.10.0/24
secret = $ENV{DOT1X_NAS_SECRET}
require_message_authenticator = yes
}
client aps6 {
ipv6addr = fd02:10::/64
secret = $ENV{DOT1X_NAS_SECRET}
require_message_authenticator = yes
}
# test, test6: test phase only (docs/radius/dot1x.md).
client test {
ipaddr = 10.2.50.0/24
secret = $ENV{DOT1X_TEST_SECRET}
require_message_authenticator = yes
}
client test6 {
ipv6addr = fd02:50::/64
secret = $ENV{DOT1X_TEST_SECRET}
require_message_authenticator = yes
}
proxy.conf: |
proxy server {
default_fallback = no
}
home_server authentik {
type = auth
ipaddr = ak-outpost-8021x-radius.authentik.svc.cluster.local
port = 1812
proto = udp
secret = $ENV{DOT1X_OUTPOST_SECRET}
# The outpost sends Message-Authenticator on Access-Accept only.
require_message_authenticator = no
# Less than proxy_dedup_window (radiusd.conf).
response_window = 5
zombie_period = 20
# The outpost does not answer Status-Server. Probe with an
# Access-Request for a user that does not exist in authentik; the
# reject counts as an answer. Sent only while zombie or dead.
status_check = request
username = "dot1x-status-probe"
password = "not-a-secret"
check_interval = 6
check_timeout = 4
num_answers_to_alive = 3
}
home_server_pool authentik {
type = fail-over
home_server = authentik
}
realm authentik {
auth_pool = authentik
nostrip
}
mods-enabled_always: |
always ok {
rcode = ok
}
always reject {
rcode = reject
}
always noop {
rcode = noop
}
mods-enabled_eap: |
eap {
default_eap_type = ttls
timer_expire = 60
ignore_unknown_eap_types = no
max_sessions = ${max_requests}
tls-config tls-common {
certificate_file = ${certdir}/radius-tls/tls.crt
private_key_file = ${certdir}/radius-tls/tls.key
# tls.crt from cert-manager carries leaf + intermediate.
auto_chain = yes
tls_min_version = "1.2"
tls_max_version = "1.2"
cipher_list = "DEFAULT"
ecdh_curve = ""
cache {
enable = no
}
}
ttls {
tls = tls-common
copy_request_to_tunnel = no
use_tunneled_reply = yes
virtual_server = inner-tunnel
}
}
sites-enabled_default: |
server default {
listen {
type = auth
ipaddr = *
port = 1812
}
listen {
type = acct
ipaddr = *
port = 1813
}
listen {
type = auth
ipv6addr = ::
port = 1812
}
listen {
type = acct
ipv6addr = ::
port = 1813
}
authorize {
# EAP-TTLS only.
if (!&EAP-Message) {
reject
}
eap {
ok = return
updated = return
}
Autz-Type Status-Server {
ok
}
}
authenticate {
Auth-Type EAP {
eap
}
}
# Answered here; the outpost has no accounting port.
preacct {
}
accounting {
ok
}
post-auth {
update reply {
&Cisco-AVPair !* ANY
}
Post-Auth-Type REJECT {
eap
}
}
pre-proxy {
}
# Runs the TTLS callback that hands the outpost reply back into the tunnel.
post-proxy {
eap
}
}
sites-enabled_inner-tunnel: |
server inner-tunnel {
authorize {
# PAP only.
if (!&User-Password) {
reject
}
update control {
&Proxy-To-Realm := authentik
}
}
authenticate {
}
# Runs on the outpost's Access-Accept. use_tunneled_reply copies this
# reply into the outer Access-Accept.
post-auth {
update reply {
&Message-Authenticator !* ANY
&Proxy-State !* ANY
}
if (!&reply:Tunnel-Private-Group-Id && &reply:Cisco-AVPair && &reply:Cisco-AVPair =~ /^dot1x-vlan=([0-9]+)$/) {
update reply {
&Tunnel-Type := VLAN
&Tunnel-Medium-Type := IEEE-802
&Tunnel-Private-Group-Id := "%{1}"
}
}
update reply {
&Cisco-AVPair !* ANY
}
# No VLAN would put the client in the WLAN's fallback network.
if (!&reply:Tunnel-Private-Group-Id) {
reject
}
}
pre-proxy {
}
post-proxy {
}
}
# Status-Server only, loopback only; used by the probes.
sites-enabled_status: |
server status {
listen {
type = status
ipaddr = 127.0.0.1
port = 18121
}
client probe {
ipaddr = 127.0.0.1
secret = status-probe
}
authorize {
ok
Autz-Type Status-Server {
ok
}
}
}
probe_status: |
Message-Authenticator = 0x00
-227
View File
@@ -1,227 +0,0 @@
# 802.1X RADIUS front end (radius.renner.uno). Config: freeradius-config.yaml.
apiVersion: apps/v1
kind: Deployment
metadata:
name: freeradius
namespace: authentik
labels:
app.kubernetes.io/name: freeradius
annotations:
configmap.reloader.stakater.com/reload: freeradius-config
secret.reloader.stakater.com/reload: radius-secrets,radius-tls
spec:
replicas: 2
selector:
matchLabels:
app.kubernetes.io/name: freeradius
template:
metadata:
labels:
app.kubernetes.io/name: freeradius
spec:
enableServiceLinks: false
affinity:
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app.kubernetes.io/name: freeradius
topologyKey: kubernetes.io/hostname
# The image has no radius user and its own raddb is root-only; the
# mounted raddb replaces it.
securityContext:
runAsNonRoot: true
runAsUser: 65534
runAsGroup: 65534
fsGroup: 65534
seccompProfile:
type: RuntimeDefault
# FreeRADIUS refuses a config dir that is world-writable; kubelet's
# volume root is, so the config is copied to a dir the pod owns.
initContainers:
- name: raddb-copy
image: docker.io/freeradius/freeradius-server:3.2.10-alpine # {"$imagepolicy": "flux-system:freeradius"}
command:
- /bin/sh
- -ec
args:
- mkdir -p /run/raddb/raddb && cp -rL /etc/raddb-src/. /run/raddb/raddb/ && chmod 0750 /run/raddb/raddb
resources:
requests:
cpu: 10m
memory: 16Mi
limits:
memory: 64Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
volumeMounts:
- name: raddb
mountPath: /etc/raddb-src
readOnly: true
- name: raddb-run
mountPath: /run/raddb
containers:
- name: freeradius
image: docker.io/freeradius/freeradius-server:3.2.10-alpine # {"$imagepolicy": "flux-system:freeradius"}
args:
- -f
- -l
- stdout
- -d
- /run/raddb/raddb
env:
- name: DOT1X_NAS_SECRET
valueFrom:
secretKeyRef:
name: radius-secrets
key: DOT1X_NAS_SECRET
- name: DOT1X_TEST_SECRET
valueFrom:
secretKeyRef:
name: radius-secrets
key: DOT1X_TEST_SECRET
- name: DOT1X_OUTPOST_SECRET
valueFrom:
secretKeyRef:
name: radius-secrets
key: DOT1X_OUTPOST_SECRET
ports:
- name: radius
containerPort: 1812
protocol: UDP
- name: radius-acct
containerPort: 1813
protocol: UDP
readinessProbe:
exec:
command:
- /opt/bin/radclient
- -d
- /run/raddb/raddb
- -q
- -r
- "1"
- -t
- "2"
- -f
- /run/raddb/raddb/probe/status
- 127.0.0.1:18121
- status
- status-probe
periodSeconds: 10
timeoutSeconds: 5
livenessProbe:
exec:
command:
- /opt/bin/radclient
- -d
- /run/raddb/raddb
- -q
- -r
- "1"
- -t
- "2"
- -f
- /run/raddb/raddb/probe/status
- 127.0.0.1:18121
- status
- status-probe
initialDelaySeconds: 10
periodSeconds: 30
timeoutSeconds: 5
resources:
requests:
cpu: 20m
memory: 64Mi
limits:
memory: 256Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
volumeMounts:
- name: raddb-run
mountPath: /run/raddb
readOnly: true
- name: tmp
mountPath: /tmp
volumes:
- name: raddb
projected:
defaultMode: 0440
sources:
- configMap:
name: freeradius-config
items:
- key: radiusd.conf
path: radiusd.conf
- key: dictionary
path: dictionary
- key: clients.conf
path: clients.conf
- key: proxy.conf
path: proxy.conf
- key: mods-enabled_always
path: mods-enabled/always
- key: mods-enabled_eap
path: mods-enabled/eap
- key: sites-enabled_default
path: sites-enabled/default
- key: sites-enabled_inner-tunnel
path: sites-enabled/inner-tunnel
- key: sites-enabled_status
path: sites-enabled/status
- key: probe_status
path: probe/status
- secret:
name: radius-tls
items:
- key: tls.crt
path: certs/radius-tls/tls.crt
- key: tls.key
path: certs/radius-tls/tls.key
- name: raddb-run
emptyDir:
medium: Memory
sizeLimit: 4Mi
# pidfile
- name: tmp
emptyDir:
medium: Memory
sizeLimit: 16Mi
---
apiVersion: v1
kind: Service
metadata:
name: radius-lb
namespace: authentik
labels:
lb-pool: bgp
annotations:
lbipam.cilium.io/ips: "10.2.99.91,fd99::91"
# LAN-side DNS only (external-dns-unifi); see ldap-outpost-lb.yaml.
external-dns.kubernetes.io/hostname: radius.renner.uno
spec:
type: LoadBalancer
externalTrafficPolicy: Local
ipFamilyPolicy: PreferDualStack
ipFamilies:
- IPv4
- IPv6
selector:
app.kubernetes.io/name: freeradius
ports:
- name: radius
port: 1812
targetPort: radius
protocol: UDP
- name: radius-acct
port: 1813
targetPort: radius-acct
protocol: UDP
+1 -1
View File
@@ -105,7 +105,7 @@ spec:
type: RuntimeDefault
containers:
- name: controller
image: gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-11-01-1059 # {"$imagepolicy": "flux-system:authentik-gate-sync"}
image: gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-10-27-0801 # {"$imagepolicy": "flux-system:authentik-gate-sync"}
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
+18 -17
View File
@@ -20,7 +20,7 @@ spec:
# Pinned to match the samba-bridge fork's app image (server.Dockerfile
# AUTHENTIK_VERSION). Bump in lockstep with the bridge rebuild — a
# floating range drifts the chart ahead of the pinned app image.
version: "2026.8.5"
version: "2026.8.4"
sourceRef:
kind: HelmRepository
name: authentik
@@ -50,7 +50,15 @@ spec:
# https://gitea.renner.uno/konairius/authentik-samba-bridge .
image:
repository: gitea.renner.uno/konairius/authentik-samba-bridge-server
tag: "2026-10-28-0655" # {"$imagepolicy": "flux-system:authentik-samba-bridge-server:tag"}
# 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
# without a manual rollout-restart (which is forbidden per
# "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/konairius/authentik-samba-bridge .
tag: "2026.8.4-3c8f413"
envFrom:
# SAMBA_BRIDGE_DOMAIN_SID / _ENABLED_GROUP / _UID_OFFSET
# consumed by the bridge module at Django app startup. Missing
@@ -80,8 +88,8 @@ spec:
# authentik_crypto.certificatekeypair the LDAP providers attach
# via attrs.certificate. Issued by cert-manager — see
# ldap-outpost-cert.yaml. Both pods get it because the chart's
# global.env propagates to server AND worker; a pod without the
# variable resolves `!Env` to None/the default (no KeyError).
# global.env propagates to server AND worker; if only the worker
# had it, blueprint apply would crash on startup with KeyError.
- name: LDAP_OUTPOST_TLS_CRT
valueFrom:
secretKeyRef:
@@ -106,6 +114,7 @@ spec:
# breaks. Comma-separated, no spaces (authentik list parsing).
- name: AUTHENTIK_LISTEN__TRUSTED_PROXY_CIDRS
value: "10.244.0.0/16,fd10:244::/48,127.0.0.0/8,::1/128"
authentik:
log_level: info
error_reporting:
@@ -121,6 +130,7 @@ spec:
password: ""
use_tls: true
from: "olymp@renner.uno"
server:
resources:
requests:
@@ -163,20 +173,8 @@ spec:
- secretName: authentik-tls
hosts:
- auth.renner.uno
worker:
# Overrides the global key; keep authentik-secrets in the list.
podAnnotations:
secret.reloader.stakater.com/reload: authentik-secrets,radius-secrets
# Shared secret of the 802.1X Radius provider, set via `!Env` in
# dot1x-radius.yaml. Worker only (blueprint applier); optional so a
# missing Secret fails only the dot1x blueprint entry.
env:
- name: DOT1X_OUTPOST_SECRET
valueFrom:
secretKeyRef:
name: radius-secrets
key: DOT1X_OUTPOST_SECRET
optional: true
resources:
requests:
# Same reasoning as server above, and the worker is burstier still:
@@ -192,10 +190,13 @@ spec:
minReplicas: 2
maxReplicas: 4
targetCPUUtilizationPercentage: 70
postgresql:
enabled: false
redis:
enabled: false
# Mount the homelab blueprints (K8s outpost integration, SSO and
# paperless flows) — every key of the ConfigMap is applied by
# the worker on boot and on each apply cycle. See
@@ -10,11 +10,6 @@ resources:
- ldap-outpost-cert.yaml
- ldap-outpost-lb.yaml
- ldap-outpost-cnp.yaml
- radius-cert.yaml
- radius-externalsecret.yaml
- freeradius-config.yaml
- freeradius.yaml
- freeradius-cnp.yaml
# ak-pg CNPG Postgres backend (moved here from the retired seed-authentik KS;
# each carries prune: disabled so ownership transferred without a teardown).
# authentik has no data PVCs — all state lives in ak-pg. This KS depends on
@@ -1,15 +0,0 @@
---
# EAP-TTLS server certificate for the FreeRADIUS service (radius.renner.uno).
# Issued via the cluster-wide Cloudflare DNS-01 ClusterIssuer.
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: radius-tls
namespace: authentik
spec:
secretName: radius-tls
issuerRef:
name: letsencrypt-cluster
kind: ClusterIssuer
dnsNames:
- radius.renner.uno
@@ -1,15 +0,0 @@
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: radius-secrets
namespace: authentik
spec:
refreshInterval: 10m
secretStoreRef:
kind: ClusterSecretStore
name: openbao
target:
name: radius-secrets
dataFrom:
- extract:
key: apps/radius
@@ -30,7 +30,7 @@ spec:
type: RuntimeDefault
containers:
- name: controller
image: gitea.renner.uno/gitea_admin/cloudflare-lb-sync:2026-10-29-1355 # {"$imagepolicy": "flux-system:cloudflare-lb-sync"}
image: gitea.renner.uno/gitea_admin/cloudflare-lb-sync:2026-10-27-0807 # {"$imagepolicy": "flux-system:cloudflare-lb-sync"}
env:
- name: CLOUDFLARE_API_TOKEN
valueFrom:
+1 -1
View File
@@ -28,7 +28,7 @@ spec:
enableServiceLinks: false
containers:
- name: evcc
image: evcc/evcc:0.318.2@sha256:7ef9bcb15a4fdbfe3bf75d7a0857c40b6272b8783ea833fcbb22f8f134b336c7
image: evcc/evcc:0.318.1@sha256:2f8163ab22f04863afd86cd511c09607ba71debd9eebd65511f3d654922a80d1
# evcc auto-discovers /etc/evcc.yaml on its default search path;
# the file is delivered (with the MQTT password) via the
# sops-encrypted evcc-config Secret mounted below.
+1 -1
View File
@@ -47,7 +47,7 @@ spec:
type: RuntimeDefault
containers:
- name: gatus
image: twinproduction/gatus:v5.38.0 # {"$imagepolicy": "flux-system:gatus"}
image: twinproduction/gatus:v5.37.0 # {"$imagepolicy": "flux-system:gatus"}
securityContext:
allowPrivilegeEscalation: false
capabilities:
+1 -1
View File
@@ -23,7 +23,7 @@ spec:
type: RuntimeDefault
containers:
- name: gitea-mcp
image: docker.gitea.com/gitea-mcp-server:1.12.0 # {"$imagepolicy": "flux-system:gitea-mcp"}
image: docker.gitea.com/gitea-mcp-server:1.11.0 # {"$imagepolicy": "flux-system:gitea-mcp"}
# Image has no ENTRYPOINT (binary is in CMD), so args alone would replace it.
command:
- /app/gitea-mcp
+7
View File
@@ -14,6 +14,13 @@ resources:
- ingressroute-ssh.yaml
- ingress.yaml
- networkpolicy.yaml
# Nightly prune of old CI image builds (REN-1203).
- package-retention.yaml
# OpenBao SSH CA public key for certificate logins.
- ssh-ca.yaml
- kumamonitors.yaml
configMapGenerator:
- name: gitea-package-retention
namespace: gitea
files:
- package-retention.py
+23
View File
@@ -93,3 +93,26 @@ spec:
- 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
# 6443. Its
# Gitea calls go to the in-cluster Service, which the baseline already allows
# inside the namespace.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: gitea-package-retention-kube-apiserver
namespace: gitea
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: gitea-package-retention
egress:
- toEntities:
- kube-apiserver
toPorts:
- ports:
- port: "6443"
protocol: TCP
+162
View File
@@ -0,0 +1,162 @@
"""Prune old CI image builds from Gitea's container registry (REN-1203).
See package-retention.yaml for what is pruned, what is kept, and why.
Standard library only.
"""
import json
import os
import re
import ssl
import sys
import urllib.parse
import urllib.request
from datetime import datetime
GITEA = os.environ["GITEA_URL"].rstrip("/")
TOKEN = os.environ["GITEA_TOKEN"]
REGISTRY_HOST = os.environ["REGISTRY_HOST"]
OWNERS = os.environ["OWNERS"].split(",")
KEEP = int(os.environ["KEEP_IMAGES"])
GROUP_SECONDS = int(os.environ["GROUP_SECONDS"])
MAX_DELETES = int(os.environ["MAX_DELETES"])
EXCLUDE = re.compile(os.environ["EXCLUDE_PACKAGES"]) if os.environ.get("EXCLUDE_PACKAGES") else None
DRY_RUN = os.environ.get("DRY_RUN", "false") == "true"
# A package is a CI build package if at least one of its tags looks like this.
BUILD_TAG = re.compile(r"^(build\.[0-9]+|main-[0-9a-f]{7,40})$")
ALWAYS_KEEP = {"main", "latest"}
SA = "/var/run/secrets/kubernetes.io/serviceaccount"
def log(msg):
print(msg, flush=True)
def gitea(method, path):
req = urllib.request.Request(
GITEA + path, method=method, headers={"Authorization": "token " + TOKEN}
)
with urllib.request.urlopen(req, timeout=60) as resp:
body = resp.read()
return json.loads(body) if body else None
def ts(s):
# Gitea returns RFC 3339, e.g. 2026-10-07T18:07:32Z or +02:00.
return datetime.fromisoformat(s.replace("Z", "+00:00")).timestamp()
def running_images():
"""Tags in use by any pod, per package. Packages referenced by digest only
cannot be matched to a tag, so they are returned in `unsafe` and skipped."""
ctx = ssl.create_default_context(cafile=SA + "/ca.crt")
with open(SA + "/token") as f:
sa_token = f.read().strip()
req = urllib.request.Request(
"https://kubernetes.default.svc/api/v1/pods",
headers={"Authorization": "Bearer " + sa_token},
)
with urllib.request.urlopen(req, timeout=60, context=ctx) as resp:
pods = json.load(resp)["items"]
in_use, unsafe = {}, set()
prefix = REGISTRY_HOST + "/"
for pod in pods:
spec = pod["spec"]
for c in (
spec.get("containers", [])
+ spec.get("initContainers", [])
+ spec.get("ephemeralContainers", [])
):
image = c["image"]
if not image.startswith(prefix):
continue
ref = image[len(prefix):].split("@", 1)[0]
repo, _, tag = ref.rpartition(":")
if not repo or "/" in tag:
unsafe.add(ref)
continue
in_use.setdefault(repo, set()).add(tag)
return in_use, unsafe
def list_packages(owner):
out, page = [], 1
while True:
batch = gitea(
"GET", f"/api/v1/packages/{owner}?type=container&limit=50&page={page}"
)
if not batch:
return out
out.extend(batch)
page += 1
def plan(versions, protected):
"""Group tags into images (one push writes all tags of an image within a
few seconds) and return the tags of the images to delete."""
tags = sorted(
(v for v in versions if not v["version"].startswith("sha256:")),
key=lambda v: ts(v["created_at"]),
reverse=True,
)
images, last = [], None
for v in tags:
t = ts(v["created_at"])
if last is None or last - t > GROUP_SECONDS:
images.append([])
images[-1].append(v["version"])
last = t
delete = []
for i, image in enumerate(images):
if i < KEEP or set(image) & (ALWAYS_KEEP | protected):
continue
delete.extend(image)
return images, delete
def main():
in_use, unsafe = running_images()
log(f"running tags from {REGISTRY_HOST}: {sum(len(t) for t in in_use.values())}")
work = []
for owner in OWNERS:
by_name = {}
for v in list_packages(owner):
by_name.setdefault(v["name"], []).append(v)
for name, versions in sorted(by_name.items()):
full = f"{owner}/{name}"
if EXCLUDE and EXCLUDE.search(full):
log(f"skip {full}: excluded")
continue
if not any(BUILD_TAG.match(v["version"]) for v in versions):
continue
if full in unsafe:
log(f"skip {full}: a pod pins it by digest only")
continue
protected = in_use.get(full, set())
images, delete = plan(versions, protected)
log(f"{full}: {len(images)} images, {len(delete)} tags to delete, running {sorted(protected)}")
work += [(owner, name, tag) for tag in delete]
if len(work) > MAX_DELETES:
log(f"abort: {len(work)} deletes exceed MAX_DELETES={MAX_DELETES}; nothing deleted")
return 1
failed = 0
for owner, name, tag in work:
if DRY_RUN:
log(f"dry-run: would delete {owner}/{name}:{tag}")
continue
path = "/api/v1/packages/{}/container/{}/{}".format(
owner, urllib.parse.quote(name, safe=""), urllib.parse.quote(tag, safe="")
)
try:
gitea("DELETE", path)
log(f"deleted {owner}/{name}:{tag}")
except Exception as e: # keep going; report at the end
failed += 1
log(f"FAILED {owner}/{name}:{tag}: {e}")
log(f"done: {len(work) - failed} deleted, {failed} failed, dry_run={DRY_RUN}")
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,162 @@
# Nightly retention for CI image builds in Gitea's container registry (REN-1203).
#
# Why: nothing pruned old builds, so the data volume filled up (REN-580, at
# 40Gi) and was down to 24% free again four days after the grow to 60Gi
# (REN-1203, about 2.2 GB/day). Gitea 1.21 has no API for per-owner cleanup
# rules, and the owners are human accounts, so the rules cannot live in Gitea.
# This job applies the rule Hermes ran by hand in REN-1204.
#
# What it prunes, per package of the owners in OWNERS:
# - only CI build packages: at least one tag is `build.<n>` or `main-<sha>`.
# Date-tagged utility images (alpine-jq, cni-plugins, ...) are not touched.
# - one image carries several tags (build.N, short and long sha, sometimes
# main/latest). Tags pushed within GROUP_SECONDS are one image, and the job
# deletes all tags of an image, because a sha tag left behind keeps the
# blobs alive.
# - keeps the KEEP_IMAGES newest images, any image tagged main or latest, and
# any image whose tag a pod in the cluster runs now. A package that a pod
# pins by digest only is skipped as a whole.
# - never touches gitea_admin/* (CI base images) or EXCLUDE_PACKAGES (an
# optional regex).
# - aborts without deleting if the plan exceeds MAX_DELETES.
# Freed blobs are removed by Gitea's own `cleanup_packages` cron (daily).
#
# Token: `gitea-hermes-bot-package-retention-token` on hermes-bot (site admin),
# scope `write:package` only (REN-1204). Admin and repo routes return 403 with
# it. Hermes owns the account and the token.
#
# Kubernetes access: list pods, cluster-wide, to find the running tags. Nothing
# else.
#
# Dry run: set DRY_RUN to "true"; the job then only logs the plan.
# Stop it: `spec.suspend: true` in git. Revert: delete this file, the
# configMapGenerator entry and the policy in networkpolicy.yaml. Deleted images
# cannot be restored; CI rebuilds one from its commit.
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: gitea-package-retention
namespace: gitea
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: openbao
target:
name: gitea-package-retention
data:
- secretKey: GITEA_TOKEN
remoteRef:
key: infrastructure/gitea/hermes-bot
property: gitea-hermes-bot-package-retention-token
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: gitea-package-retention
namespace: gitea
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: gitea-package-retention
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: gitea-package-retention
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: gitea-package-retention
subjects:
- kind: ServiceAccount
name: gitea-package-retention
namespace: gitea
---
apiVersion: batch/v1
kind: CronJob
metadata:
name: gitea-package-retention
namespace: gitea
labels:
app.kubernetes.io/name: gitea-package-retention
spec:
# Quiet hour for CI pushes. Gitea's own daily cleanup_packages cron frees
# the blobs afterwards.
schedule: "30 22 * * *"
timeZone: Etc/UTC
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 2
failedJobsHistoryLimit: 3
startingDeadlineSeconds: 3600
jobTemplate:
spec:
backoffLimit: 0
activeDeadlineSeconds: 1800
ttlSecondsAfterFinished: 604800
template:
metadata:
labels:
app.kubernetes.io/name: gitea-package-retention
spec:
restartPolicy: Never
enableServiceLinks: false
serviceAccountName: gitea-package-retention
securityContext:
runAsNonRoot: true
runAsUser: 65534
runAsGroup: 65534
seccompProfile:
type: RuntimeDefault
containers:
- name: retention
image: docker.io/library/python:3.15-alpine@sha256:d44b1254cb92329418d164b22b20d86bbd3869d48e80e09e476531a409a728bb
command: ["python3", "/app/package-retention.py"]
env:
# In-cluster Service; the public name hairpins off the ingress
# LB from a pod (see networkpolicy.yaml).
- name: GITEA_URL
value: http://gitea.gitea.svc:3000
- name: REGISTRY_HOST
value: gitea.renner.uno
- name: OWNERS
value: k.renner,konairius
- name: KEEP_IMAGES
value: "5"
# Tags of one push land within seconds; the smallest gap
# between two images was 379 s (REN-1204).
- name: GROUP_SECONDS
value: "60"
- name: MAX_DELETES
value: "300"
- name: DRY_RUN
value: "false"
- name: PYTHONDONTWRITEBYTECODE
value: "1"
envFrom:
- secretRef:
name: gitea-package-retention
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
memory: 128Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: script
mountPath: /app
readOnly: true
volumes:
- name: script
configMap:
name: gitea-package-retention
@@ -32,7 +32,7 @@ spec:
type: RuntimeDefault
containers:
- name: sync
image: gitea.renner.uno/konairius/hermes:2026-10-29-1342 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1793088058 # {"$imagepolicy": "flux-system:hermes"}
command:
- /opt/hermes/.venv/bin/python
- /opt/hermes-skills/gitea-issues/scripts/sync_webhook.py
+10 -10
View File
@@ -51,7 +51,7 @@ spec:
# connects from the pod network. No env equivalent exists, so it is
# set in config.yaml on every start (idempotent).
- name: config
image: gitea.renner.uno/konairius/hermes:2026-10-29-1342 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1793088058 # {"$imagepolicy": "flux-system:hermes"}
command:
- /opt/hermes/.venv/bin/hermes
- config
@@ -75,7 +75,7 @@ spec:
mountPath: /opt/data
# WebSocket keepalive ping interval in seconds (default 20).
- name: config-ws-ping
image: gitea.renner.uno/konairius/hermes:2026-10-29-1342 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1793088058 # {"$imagepolicy": "flux-system:hermes"}
command:
- /opt/hermes/.venv/bin/hermes
- config
@@ -99,7 +99,7 @@ spec:
mountPath: /opt/data
# Skills from git (ConfigMap hermes-skills) beside the PVC's skills/.
- name: skills-config
image: gitea.renner.uno/konairius/hermes:2026-10-29-1342 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1793088058 # {"$imagepolicy": "flux-system:hermes"}
command:
- /opt/hermes/.venv/bin/hermes
- config
@@ -130,7 +130,7 @@ spec:
# - alert-monitor: hourly; the script wakes the agent only for
# untriaged alerts.
- name: cron-jobs
image: gitea.renner.uno/konairius/hermes:2026-10-29-1342 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1793088058 # {"$imagepolicy": "flux-system:hermes"}
# Non-fatal: a broken jobs.json or a changed cron CLI after an image
# bump logs a warning instead of keeping Hermes from starting.
command:
@@ -187,7 +187,7 @@ spec:
readOnly: true
# Home Assistant MCP server; token resolved from HA_TOKEN at connect, writes need approval.
- name: config-mcp-homeassistant
image: gitea.renner.uno/konairius/hermes:2026-10-29-1342 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1793088058 # {"$imagepolicy": "flux-system:hermes"}
command:
- /opt/hermes/.venv/bin/hermes
- config
@@ -214,7 +214,7 @@ spec:
# skills, the Home Assistant MCP and the tea login are re-applied on
# every start. default gets the kanban toolset to hand cards to them.
- name: experts
image: gitea.renner.uno/konairius/hermes:2026-10-29-1342 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1793088058 # {"$imagepolicy": "flux-system:hermes"}
# Non-fatal like cron-jobs: a broken expert logs a warning.
command:
- sh
@@ -305,7 +305,7 @@ spec:
# Gitea issues -> kanban (skills/gitea-issues): route script, webhook route on default,
# completion shell hook plus its allowlist entry on every profile.
- name: gitea-issues
image: gitea.renner.uno/konairius/hermes:2026-10-29-1342 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1793088058 # {"$imagepolicy": "flux-system:hermes"}
# Non-fatal like cron-jobs: a failure logs a warning.
command:
- sh
@@ -368,7 +368,7 @@ spec:
readOnly: true
# Multiplexed default reads platform credentials only from .env (rationale: commit message).
- name: default-env
image: gitea.renner.uno/konairius/hermes:2026-10-29-1342 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1793088058 # {"$imagepolicy": "flux-system:hermes"}
# Fatal on a writer failure: without these Matrix and the API server stay down.
command:
- /opt/hermes/.venv/bin/python
@@ -428,7 +428,7 @@ spec:
# tea login hermes-bot, written from the mounted token into an
# emptyDir at the agent's ~/.config/tea.
- name: tea-login
image: gitea.renner.uno/konairius/hermes:2026-10-29-1342 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1793088058 # {"$imagepolicy": "flux-system:hermes"}
command:
- sh
- -ec
@@ -457,7 +457,7 @@ spec:
readOnly: true
containers:
- name: hermes
image: gitea.renner.uno/konairius/hermes:2026-10-29-1342 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1793088058 # {"$imagepolicy": "flux-system:hermes"}
args: ["gateway", "run"]
env:
- name: HERMES_DASHBOARD
@@ -84,7 +84,7 @@ missing permission and the one step that grants it.
9. **After the owner says "merged", verify the effect, not the merge.** Check
`GET /repos/<o>/<r>/commits/<merge_sha>/statuses` for the expected
`ci/woodpecker/push/<pipeline>` context (only cron contexts = the push pipeline never
triggered), the registry tag list for a new `YYYY-MM-DD-HHMM` tag, `flux get images policy <name>`,
triggered), the registry tag list for a new `build.<ts>`, `flux get images policy <name>`,
and the Deployment image. Report each as observed; say plainly what you couldn't see
(e.g. Woodpecker UI) instead of guessing the cause.
- With several PRs in flight, query the state of every one; "merged" may mean only one
@@ -7,8 +7,8 @@ Use when a homelab change adds or modifies an image built from `ci/images/<name>
| Concern | Owner | How |
|---|---|---|
| Versions of base images and bundled tools | **Renovate** (`renovate.json`, `dockerfile` manager) | Express every version as an image reference: `FROM <img>:<tag>@<digest>` and `COPY --from=<img>:<tag>@<digest> <path> /usr/local/bin/<tool>`. Renovate opens the bump PR; merging it is the trigger. |
| Building the image | **Woodpecker**, on push to the default branch of `ci/images/<name>/**` (+ `event: manual`) | kaniko, exactly one tag `$(date +%Y-%m-%d-%H%M)`. No `latest`, `main` or sha tags: Gitea's konairius cleanup rule counts each tag as a version and keeps only the newest. |
| Rolling a built tag out | **Flux image automation** | `ImageRepository` + `ImagePolicy` (`filterTags.pattern '^\d{4}-\d{2}-\d{2}(-\d{4})?$'`, `alphabetical asc`) and the `# {"$imagepolicy": "flux-system:<name>"}` marker on every image line. No hand-written pins. |
| Building the image | **Woodpecker**, on push to the default branch of `ci/images/<name>/**` (+ `event: manual`) | kaniko, tag `build.$(date +%s)` + `latest`. |
| Rolling a built tag out | **Flux image automation** | `ImageRepository` + `ImagePolicy` (`filterTags.pattern '^build\.(?P<ts>\d+)$'`, `extract '$ts'`, `numerical asc`) and the `# {"$imagepolicy": "flux-system:<name>"}` marker on every image line. |
Never add cron polling / "decide" steps that compare upstream releases — that duplicates
Renovate. Never curl binaries with hand-maintained sha256 lines when the tool ships an
@@ -47,4 +47,4 @@ image: `COPY --from` a digest-pinned image is tracked and needs no checksums.
- Check what the base image's last `USER` is before adding one (`grep -n '^USER'` in the
upstream Dockerfile).
- Rollout order is two PRs: image + pipeline first; the manifest switch only with a real
date tag (see SKILL.md, Multi-stage changes).
`build.<ts>` (see SKILL.md, Multi-stage changes).
@@ -12,11 +12,6 @@ monitor, and its history starts over.
namespace.
- External targets go in `kubernetes/apps/monitoring/kumamonitors.yaml`. That covers the NetBird
proxies, the Cloudflare-served apex `renner.uno` and third-party sites.
- A monitor alerts only through its `notificationRefs` to cluster-scoped `KumaNotification`
objects (`kubernetes/apps/kuma-sync/notification-*.yaml`). Without refs it sends nothing.
- Parentless monitors hang under the root group `kuma-sync/renner-uno` (controller env
`KUMA_ROOT_GROUP`). Only the root carries `notificationRefs`; don't add `notificationRefs`
to service monitors.
- The CRD lives in `infrastructure/kuma-sync-crds`. Every consuming Flux Kustomization needs
the KumaMonitor `healthCheckExprs` entry with `current: "true"` (see SKILL.md pitfalls).

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