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
145 changed files with 1135 additions and 4322 deletions
+12 -9
View File
@@ -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
@@ -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
@@ -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
+4 -4
View File
@@ -35,25 +35,25 @@ when:
steps:
- name: model-selection-tests
depends_on: []
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.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
+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
+4 -3
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
@@ -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
@@ -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
+24 -237
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""authentik-gate-sync: the Authentik side of the gate Middleware and of OIDC clients.
"""authentik-gate-sync: the Authentik side of the gate Middleware.
An Ingress is gated when its traefik.ingress.kubernetes.io/router.middlewares
annotation names the gate Middleware (authentik/gate, a ForwardAuth against the
@@ -18,16 +18,6 @@ Annotations (all optional):
auth.renner.uno/authorization-flow authorization flow slug (default below)
auth.renner.uno/skip-path provider skip_path_regex
OIDC clients: each OIDCClient (auth.renner.uno/v1alpha1, CRD in
infrastructure/authentik-gate-sync-crds) gets a confidential OAuth2/OpenID
provider oidc-<slug> (client ID = slug, secret generated by Authentik) and an
application with that slug, so the issuer is <authentik>/application/o/<slug>/;
with spec.group the same group policy as gates. Its status carries Ready and
the issuer. OIDC slugs and gate names are kept apart: a slug that is a gate's
name, or an existing application without this controller's OAuth2 provider,
is refused. An unassigned provider named oidc-<slug> is adopted (the remains
of an interrupted teardown, which deletes the application first).
Fail closed: until the provider exists, or after it is removed (e.g. the named
group is gone), the embedded outpost has no provider for the host and rejects
the request; Traefik answers 500 when the outpost is unreachable.
@@ -40,7 +30,6 @@ import hashlib
import json
import logging
import os
import re
import sys
import time
import urllib.error
@@ -57,10 +46,6 @@ DEFAULT_AUTHZ_FLOW = os.environ.get("DEFAULT_AUTHORIZATION_FLOW", "default-provi
INVALIDATION_FLOW = os.environ.get("INVALIDATION_FLOW", "default-provider-invalidation-flow")
COOKIE_DOMAIN = os.environ.get("COOKIE_DOMAIN", "renner.uno")
AK_BROWSER_URL = os.environ.get("AUTHENTIK_HOST_BROWSER", "https://auth.renner.uno/")
SIGNING_KEY = os.environ.get("OIDC_SIGNING_KEY", "authentik Self-signed Certificate")
OIDC_SCOPES = tuple(f"goauthentik.io/providers/oauth2/scope-{s}" for s in ("openid", "profile", "email"))
OIDC_GRANTS = ["authorization_code", "refresh_token"]
SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,48}[a-z0-9])?$")
INTERVAL = int(os.environ.get("INTERVAL_SECONDS", "60"))
HTTP_TIMEOUT = 20
@@ -249,74 +234,6 @@ def provider_body(gate, flows):
}
OIDC_API = "/apis/auth.renner.uno/v1alpha1"
def oidc_slug(cr):
return ((cr.get("spec") or {}).get("slug") or cr["metadata"]["name"]).strip()
def oidc_spec(cr):
"""Translate an OIDCClient into the client it asks for. Raises GateError."""
meta, spec = cr["metadata"], cr.get("spec") or {}
slug = oidc_slug(cr)
if not SLUG_RE.match(slug):
raise GateError(f"slug {slug!r} is not a slug (a-z, 0-9, -; max 50)")
launch = spec.get("launchURL", "")
if not launch.startswith(("https://", "http://")):
raise GateError(f"launchURL {launch!r} is not an http(s) URL")
redirects = [{"matching_mode": r.get("matchingMode", "strict"), "url": r.get("url", ""),
"redirect_uri_type": "authorization"}
for r in spec.get("redirectURIs") or []]
if not redirects or any(not r["url"] or r["matching_mode"] not in ("strict", "regex") for r in redirects):
raise GateError("redirectURIs needs at least one entry with url and matchingMode strict|regex")
for r in redirects:
if r["matching_mode"] == "regex":
try:
re.compile(r["url"])
except re.error as e:
raise GateError(f"redirect regex {r['url']!r}: {e}") from None
return {
# name: provider and policy name; slug: application slug and client ID.
"name": f"oidc-{slug}",
"slug": slug,
"namespace": meta["namespace"],
"resource": meta["name"],
"launch_url": launch,
"group": spec.get("group") or None,
"authorization_flow": spec.get("authorizationFlow") or DEFAULT_AUTHZ_FLOW,
"redirect_uris": redirects,
}
def oidc_status(cr, ok, message):
cond = {"type": "Ready", "status": "True" if ok else "False",
"reason": "Reconciled" if ok else "Failed", "message": message[:1000],
"observedGeneration": cr["metadata"].get("generation", 0)}
old = next((c for c in (cr.get("status") or {}).get("conditions") or [] if c.get("type") == "Ready"), {})
same = all(old.get(k) == cond[k] for k in ("status", "reason", "message", "observedGeneration"))
cond["lastTransitionTime"] = old.get("lastTransitionTime") if same and old.get("lastTransitionTime") \
else time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
status = {"conditions": [cond], "observedGeneration": cond["observedGeneration"]}
if ok:
status["issuer"] = f"{AK_BROWSER_URL}application/o/{oidc_slug(cr)}/"
return status
def oauth2_body(spec, flows, refs):
return {
"name": spec["name"],
"authorization_flow": flows[spec["authorization_flow"]],
"invalidation_flow": flows[INVALIDATION_FLOW],
"client_type": "confidential",
"client_id": spec["slug"],
"grant_types": OIDC_GRANTS,
"signing_key": refs["signing_key"],
"property_mappings": sorted(refs["scopes"]),
"redirect_uris": spec["redirect_uris"],
}
def drift(current, desired):
return {k: v for k, v in desired.items() if current.get(k) != v}
@@ -347,18 +264,6 @@ class K8s:
return http("PATCH", K8S_API + path, obj, headers=self._h(), cafile=K8S_CA_FILE,
content_type="application/apply-patch+yaml")
def patch_status(self, path, status):
return http("PATCH", K8S_API + path + "/status", {"status": status}, headers=self._h(),
cafile=K8S_CA_FILE, content_type="application/merge-patch+json")
def oidc_clients(self):
try:
return self.get(f"{OIDC_API}/oidcclients").get("items", [])
except ApiError as e:
if e.code == 404: # CRD not installed: no clients, gates unaffected
return []
raise
def delete(self, obj):
try:
http("DELETE", K8S_API + object_path(obj), headers=self._h(), cafile=K8S_CA_FILE)
@@ -542,120 +447,42 @@ def ensure_access(ak, gate, target):
ak.req("DELETE", f"policies/expression/{stale['pk']}/")
def oidc_refs(ak):
"""Signing key and scope mappings every OIDC client uses; matched here, not
by server-side filters, so an ignored filter cannot pick another object."""
keys = [k["pk"] for k in ak.list("crypto/certificatekeypairs/") if k.get("name") == SIGNING_KEY]
if len(keys) != 1:
raise ApiError(f"expected one certificate {SIGNING_KEY!r}, found {len(keys)}")
scopes = {m.get("managed"): m["pk"] for m in ak.list("propertymappings/provider/scope/")
if m.get("managed") in OIDC_SCOPES}
missing = [s for s in OIDC_SCOPES if s not in scopes]
if missing:
raise ApiError(f"scope mappings not found: {missing}")
return {"signing_key": keys[0], "scopes": list(scopes.values())}
def ensure_oidc(ak, spec, flows, refs):
name, slug = spec["name"], spec["slug"]
if spec["group"] and ak.one("core/groups/", name=spec["group"]) is None:
raise GateError(f"group {spec['group']!r} not found in Authentik")
app = get_application(ak, slug)
if app is not None and app.get("meta_publisher") != MANAGER:
raise GateError(f"application {slug} exists and is not managed by {MANAGER}")
want = oauth2_body(spec, flows, refs)
provider = next((p for p in ak.list("providers/oauth2/")
if is_plain_oauth2(p) and p.get("name", "").lower() == name), None)
if app is not None and (provider is None or app.get("provider") != provider["pk"]):
# E.g. a gate's application: never re-point it at an OAuth2 provider.
raise GateError(f"application {slug} exists without OAuth2 provider {name}")
if provider is None:
# No client_secret: Authentik generates it (generate_client_secret).
provider = ak.req("POST", "providers/oauth2/", want)
LOG.info("%s: created OAuth2 provider %s", name, provider["pk"])
elif provider.get("assigned_application_slug") not in (None, slug) or \
(app is not None and app.get("provider") != provider["pk"]):
raise GateError(f"OAuth2 provider {name} belongs to another application")
else:
current = dict(provider, property_mappings=sorted(provider.get("property_mappings") or []))
if drift(current, want):
ak.req("PATCH", f"providers/oauth2/{provider['pk']}/", drift(current, want))
LOG.info("%s: updated OAuth2 provider %s", name, sorted(drift(current, want)))
want_app = {"name": f"{slug} (oidc)", "slug": slug, "provider": provider["pk"],
"meta_launch_url": spec["launch_url"], "meta_publisher": MANAGER,
"meta_description": f"OIDCClient {spec['namespace']}/{spec['resource']}"}
if app is None:
app = ak.req("POST", "core/applications/", want_app)
LOG.info("%s: created application %s", name, slug)
elif drift(app, want_app):
ak.req("PATCH", f"core/applications/{slug}/", drift(app, want_app))
LOG.info("%s: updated application %s", name, sorted(drift(app, want_app)))
ensure_access(ak, spec, app["pbm_uuid"])
PROVIDER_PATHS = ("providers/proxy/", "providers/oauth2/")
OAUTH2_MODEL = "authentik_providers_oauth2.oauth2provider"
def is_plain_oauth2(p):
# ProxyProvider subclasses OAuth2Provider, so providers/oauth2/ lists proxy providers too.
return p.get("meta_model_name") == OAUTH2_MODEL
def policy_name(app):
return f"oidc-{app['slug']}" if app.get("_provider_path") == "providers/oauth2/" else app["slug"]
def remove_gate(ak, app):
"""Delete an owned gate or OIDC client: the application (bindings cascade;
its provider is PROTECTed while referenced), then the provider (a proxy
provider leaves the embedded outpost with it), the policy. Ownership is
found through the application, so until it is gone a failed step is
retried on the next pass."""
"""Delete an owned gate: the application (bindings cascade; its provider is
PROTECTed while referenced), then the provider (which leaves the embedded
outpost with it), the policy. Ownership is found through the application,
so until it is gone a failed step is retried on the next pass."""
name, provider = app["slug"], app.get("provider")
path = app.get("_provider_path", "providers/proxy/")
if path == "providers/proxy/":
remove_dedicated_outposts(ak, name, provider)
remove_dedicated_outposts(ak, name, provider)
ak.req("DELETE", f"core/applications/{name}/")
if provider:
ak.req("DELETE", f"{path}{provider}/")
policy = ak.one("policies/expression/", name=policy_name(app))
ak.req("DELETE", f"providers/proxy/{provider}/")
policy = ak.one("policies/expression/", name=name)
if policy is not None:
ak.req("DELETE", f"policies/expression/{policy['pk']}/")
LOG.info("%s: removed %s from Authentik", name,
"OIDC client" if path == "providers/oauth2/" else "gate")
LOG.info("%s: removed gate from Authentik", name)
def owned_applications(ak):
"""Applications this controller owns, found through the (unfiltered) proxy
and OAuth2 provider lists and confirmed one by one."""
for path in PROVIDER_PATHS:
for p in ak.list(path):
if path == "providers/oauth2/" and not is_plain_oauth2(p):
continue
slug = p.get("assigned_application_slug")
if not slug:
continue
app = get_application(ak, slug)
if app and app.get("meta_publisher") == MANAGER and app.get("provider") == p["pk"]:
yield dict(app, _provider_path=path)
provider list and confirmed one by one."""
for p in ak.list("providers/proxy/"):
slug = p.get("assigned_application_slug")
if not slug:
continue
app = get_application(ak, slug)
if app and app.get("meta_publisher") == MANAGER and app.get("provider") == p["pk"]:
yield app
# --- reconcile --------------------------------------------------------------
def reconcile(k8s=None, ak=None):
k8s = k8s or K8s()
if ak is None:
with open(AK_TOKEN_FILE) as f:
ak = Authentik(f.read().strip())
def reconcile():
k8s = K8s()
with open(AK_TOKEN_FILE) as f:
ak = Authentik(f.read().strip())
ingresses = k8s.get("/apis/networking.k8s.io/v1/ingresses").get("items", [])
sources = [i for i in ingresses if is_gated(i)]
clients = k8s.oidc_clients()
sources = [i for i in k8s.get("/apis/networking.k8s.io/v1/ingresses").get("items", []) if is_gated(i)]
shared = shared_hosts(sources)
flows = {f["slug"]: f["pk"] for f in ak.list("flows/instances/")}
ensure_embedded_config(ak)
@@ -699,44 +526,6 @@ def reconcile(k8s=None, ak=None):
held.add(gate_name(ing))
failed += 1
gate_names = {gate_name(i) for i in sources}
keep_clients = set() # OIDC slugs whose Authentik side should exist
slugs = [oidc_slug(i) for i in clients]
refs = None
for cr in sorted(clients, key=oidc_slug):
ref, slug = f'{cr["metadata"]["namespace"]}/{cr["metadata"]["name"]}', oidc_slug(cr)
ok, message = False, ""
try:
spec = oidc_spec(cr)
if slugs.count(slug) > 1:
raise GateError(f"slug {slug} is claimed by more than one OIDCClient")
if slug in gate_names:
raise GateError(f"slug {slug} is the name of a gate")
for flow in (spec["authorization_flow"], INVALIDATION_FLOW):
if flow not in flows:
raise GateError(f"flow {flow!r} not found in Authentik")
keep_clients.add(slug)
refs = refs or oidc_refs(ak)
ensure_oidc(ak, spec, flows, refs)
ok, message = True, f"provider {spec['name']}, application {slug}"
except GateError as e:
LOG.error("OIDCClient %s: %s", ref, e)
failed += 1
keep_clients.discard(slug)
message = str(e)
except Exception as e:
LOG.error("OIDCClient %s: %s", ref, e)
keep_clients.add(slug)
failed += 1
message = f"transient: {e}"
try:
path = f'{OIDC_API}/namespaces/{cr["metadata"]["namespace"]}/oidcclients/{cr["metadata"]["name"]}'
status = oidc_status(cr, ok, message)
if status != cr.get("status"):
k8s.patch_status(path, status)
except ApiError as e:
LOG.error("OIDCClient %s: status: %s", ref, e)
removed = 0
for obj in managed_objects(k8s):
key = (obj["kind"], obj["metadata"]["namespace"], obj["metadata"]["name"])
@@ -747,13 +536,11 @@ def reconcile(k8s=None, ak=None):
removed += 1
for app in list(owned_applications(ak)):
keep = keep_clients if app["_provider_path"] == "providers/oauth2/" else gates
if app["slug"] not in keep:
if app["slug"] not in gates:
remove_gate(ak, app)
removed += 1
LOG.info("reconciled: %d gates, %d OIDC clients, %d failed, %d objects removed",
len(sources), len(clients), failed, removed)
LOG.info("reconciled: %d gates, %d failed, %d objects removed", len(sources), failed, removed)
def main():
@@ -176,10 +176,8 @@ class FakeAuthentik:
"""In-memory stand-in for the endpoints the controller uses; records calls."""
def __init__(self):
self.db = {"providers/proxy/": {}, "providers/oauth2/": {}, "core/applications/": {},
"outposts/instances/": {}, "policies/bindings/": {}, "policies/expression/": {},
"core/groups/": {}, "crypto/certificatekeypairs/": {},
"propertymappings/provider/scope/": {}}
self.db = {"providers/proxy/": {}, "core/applications/": {}, "outposts/instances/": {},
"policies/bindings/": {}, "policies/expression/": {}, "core/groups/": {}}
self.calls = []
self.next = 1
@@ -196,9 +194,6 @@ class FakeAuthentik:
return coll, rest or None
raise AssertionError(path)
def _provider(self, pk):
return self.db["providers/proxy/"].get(pk) or self.db["providers/oauth2/"][pk]
def _find(self, coll, key):
if coll == "core/applications/":
return next((a for a in self.db[coll].values() if a["slug"] == key), None)
@@ -206,13 +201,7 @@ class FakeAuthentik:
def list(self, path, **q):
out = []
objs = list(self.db[path].values())
if path == "providers/oauth2/":
# ProxyProvider subclasses OAuth2Provider: the real list endpoint returns both.
objs = [dict(o, meta_model_name=c.OAUTH2_MODEL) for o in objs] + \
[dict(o, meta_model_name="authentik_providers_proxy.proxyprovider")
for o in self.db["providers/proxy/"].values()]
for o in objs:
for o in self.db[path].values():
ok = True
for k, v in q.items():
field = k.replace("__iexact", "")
@@ -237,7 +226,7 @@ class FakeAuthentik:
obj = self.add(coll, dict(body))
if coll == "core/applications/":
obj["pbm_uuid"] = f"pbm-{obj['pk']}"
self._provider(obj["provider"])["assigned_application_slug"] = obj["slug"]
self.db["providers/proxy/"][obj["provider"]]["assigned_application_slug"] = obj["slug"]
return copy.deepcopy(obj)
obj = self._find(coll, key)
if method == "PATCH":
@@ -250,7 +239,7 @@ class FakeAuthentik:
if method == "DELETE":
del self.db[coll][obj["pk"]]
if coll == "core/applications/":
self._provider(obj["provider"])["assigned_application_slug"] = None
self.db["providers/proxy/"][obj["provider"]]["assigned_application_slug"] = None
for b in [b for b in self.db["policies/bindings/"].values() if b["target"] == obj["pbm_uuid"]]:
del self.db["policies/bindings/"][b["pk"]]
return None
@@ -393,253 +382,5 @@ class Reconcile(unittest.TestCase):
self.assertEqual(self.ak.get(f"core/applications/{self.g['name']}/")["provider"], app["provider"])
YOUTRACK = {
"metadata": {"name": "youtrack", "namespace": "youtrack", "generation": 3},
"spec": {
"launchURL": "https://youtrack.renner.uno",
"group": "YouTrack Users",
"redirectURIs": [{"matchingMode": "regex", "url": r"^https://youtrack\.renner\.uno/.*$"}],
},
}
class OidcSpec(unittest.TestCase):
def test_youtrack(self):
s = c.oidc_spec(YOUTRACK)
self.assertEqual((s["name"], s["slug"], s["launch_url"], s["group"]),
("oidc-youtrack", "youtrack", "https://youtrack.renner.uno", "YouTrack Users"))
self.assertEqual(s["redirect_uris"],
[{"matching_mode": "regex", "url": r"^https://youtrack\.renner\.uno/.*$",
"redirect_uri_type": "authorization"}])
def test_slug_overrides_name(self):
cr = copy.deepcopy(YOUTRACK)
cr["spec"]["slug"] = "yt"
self.assertEqual(c.oidc_spec(cr)["slug"], "yt")
def test_strict_is_default(self):
cr = copy.deepcopy(YOUTRACK)
cr["spec"]["redirectURIs"] = [{"url": "https://youtrack.renner.uno/cb"}]
self.assertEqual(c.oidc_spec(cr)["redirect_uris"][0]["matching_mode"], "strict")
def test_rejects_bad_slug(self):
cr = copy.deepcopy(YOUTRACK)
cr["spec"]["slug"] = "You Track"
with self.assertRaises(c.GateError):
c.oidc_spec(cr)
def test_rejects_missing_redirects(self):
cr = copy.deepcopy(YOUTRACK)
cr["spec"]["redirectURIs"] = []
with self.assertRaises(c.GateError):
c.oidc_spec(cr)
def test_rejects_bad_regex(self):
cr = copy.deepcopy(YOUTRACK)
cr["spec"]["redirectURIs"] = [{"matchingMode": "regex", "url": "("}]
with self.assertRaises(c.GateError):
c.oidc_spec(cr)
def test_status(self):
st = c.oidc_status(YOUTRACK, True, "ok")
self.assertEqual(st["issuer"], c.AK_BROWSER_URL + "application/o/youtrack/")
self.assertEqual((st["conditions"][0]["status"], st["observedGeneration"]), ("True", 3))
cr = dict(YOUTRACK, status=st)
self.assertEqual(c.oidc_status(cr, True, "ok")["conditions"][0]["lastTransitionTime"],
st["conditions"][0]["lastTransitionTime"])
self.assertNotIn("issuer", c.oidc_status(cr, False, "group missing"))
class ReconcileOidc(unittest.TestCase):
def setUp(self):
self.ak = FakeAuthentik()
self.ak.add("crypto/certificatekeypairs/", {"name": c.SIGNING_KEY, "pk": "key-1"})
self.ak.add("crypto/certificatekeypairs/", {"name": "other", "pk": "key-2"})
for i, scope in enumerate(c.OIDC_SCOPES):
self.ak.add("propertymappings/provider/scope/", {"pk": f"m{i}", "managed": scope})
self.ak.add("propertymappings/provider/scope/", {"pk": "m9", "managed": "goauthentik.io/x"})
self.ak.add("core/groups/", {"name": "YouTrack Users"})
self.s = c.oidc_spec(YOUTRACK)
def run_once(self):
c.ensure_oidc(self.ak, self.s, FLOWS, c.oidc_refs(self.ak))
def test_creates_owned_client(self):
self.run_once()
app = self.ak.get("core/applications/youtrack/")
self.assertEqual(app["meta_publisher"], c.MANAGER)
p = self.ak.db["providers/oauth2/"][app["provider"]]
self.assertEqual((p["name"], p["client_id"], p["client_type"], p["signing_key"]),
("oidc-youtrack", "youtrack", "confidential", "key-1"))
self.assertNotIn("client_secret", p)
self.assertEqual(p["grant_types"], ["authorization_code", "refresh_token"])
self.assertEqual(p["property_mappings"], ["m0", "m1", "m2"])
policy = self.ak.one("policies/expression/", name="oidc-youtrack")
self.assertIn("'YouTrack Users'", policy["expression"])
self.assertEqual([b["target"] for b in self.ak.list("policies/bindings/")], [app["pbm_uuid"]])
def test_second_pass_is_a_noop(self):
self.run_once()
before = len(self.ak.calls)
self.run_once()
self.assertEqual(self.ak.calls[before:], [])
def test_missing_group_is_a_gate_error(self):
self.ak.db["core/groups/"].clear()
with self.assertRaises(c.GateError):
self.run_once()
self.assertEqual([p for p in self.ak.list("providers/oauth2/") if c.is_plain_oauth2(p)], [])
def test_refuses_unowned_application(self):
self.ak.add("core/applications/", {"slug": "youtrack", "meta_publisher": "", "provider": None})
with self.assertRaises(c.GateError):
self.run_once()
def test_redirect_change_patches_provider(self):
self.run_once()
self.s = dict(self.s, redirect_uris=[{"matching_mode": "strict", "url": "https://youtrack.renner.uno/cb",
"redirect_uri_type": "authorization"}])
self.run_once()
p = next(iter(self.ak.db["providers/oauth2/"].values()))
self.assertEqual(p["redirect_uris"], self.s["redirect_uris"])
def test_missing_signing_key_is_transient(self):
self.ak.db["crypto/certificatekeypairs/"].clear()
with self.assertRaises(c.ApiError):
c.oidc_refs(self.ak)
def test_ownership_scan_and_removal(self):
foreign = self.ak.add("providers/oauth2/", {"name": "gitea", "assigned_application_slug": "gitea"})
self.ak.add("core/applications/", {"slug": "gitea", "meta_publisher": "", "provider": foreign["pk"]})
self.run_once()
owned = list(c.owned_applications(self.ak))
self.assertEqual([a["slug"] for a in owned], ["youtrack"])
c.remove_gate(self.ak, owned[0])
self.assertIsNone(self.ak.get("core/applications/youtrack/"))
self.assertEqual([p["name"] for p in self.ak.list("providers/oauth2/")], ["gitea"])
self.assertIsNone(self.ak.one("policies/expression/", name="oidc-youtrack"))
def test_refuses_application_without_its_provider(self):
gate_provider = self.ak.add("providers/proxy/", {"name": "g", "assigned_application_slug": "youtrack"})
self.ak.add("core/applications/", {"slug": "youtrack", "meta_publisher": c.MANAGER,
"provider": gate_provider["pk"], "pbm_uuid": "pbm-x"})
with self.assertRaises(c.GateError):
self.run_once()
self.assertEqual([p for p in self.ak.list("providers/oauth2/") if c.is_plain_oauth2(p)], [])
self.assertEqual(self.ak.get("core/applications/youtrack/")["provider"], gate_provider["pk"])
class FakeK8s:
def __init__(self, ingresses=(), clients=(), services=None):
self.ingresses, self.clients = list(ingresses), list(clients)
self.services = services or {}
self.status, self.applied = {}, []
def get(self, path):
if path == "/apis/networking.k8s.io/v1/ingresses":
return {"items": copy.deepcopy(self.ingresses)}
if "labelSelector=" in path:
return {"items": []}
if path.startswith("/api/v1/namespaces/") and "/services/" in path:
return copy.deepcopy(self.services[path.rsplit("/", 1)[1]])
raise AssertionError(path)
def oidc_clients(self):
return copy.deepcopy(self.clients)
def apply(self, obj):
self.applied.append(obj)
def patch_status(self, path, status):
self.status[path.rsplit("/", 1)[1]] = status
for cr in self.clients:
if cr["metadata"]["name"] == path.rsplit("/", 1)[1]:
cr["status"] = status
class ReconcileLoop(unittest.TestCase):
def setUp(self):
self.ak = FakeAuthentik()
self.ak.db["flows/instances/"] = {}
for slug, pk in FLOWS.items():
self.ak.add("flows/instances/", {"slug": slug, "pk": pk})
self.ak.add("outposts/instances/", {"name": "authentik Embedded Outpost", "managed": c.EMBEDDED_OUTPOST,
"providers": [], "config": {}})
self.ak.add("crypto/certificatekeypairs/", {"name": c.SIGNING_KEY, "pk": "key-1"})
for i, scope in enumerate(c.OIDC_SCOPES):
self.ak.add("propertymappings/provider/scope/", {"pk": f"m{i}", "managed": scope})
self.ak.add("core/groups/", {"name": "YouTrack Users"})
def client(self, name="youtrack", **spec):
cr = copy.deepcopy(YOUTRACK)
cr["metadata"]["name"] = name
cr["spec"].update(spec)
return cr
def test_creates_then_quiet(self):
k8s = FakeK8s(clients=[self.client()])
c.reconcile(k8s, self.ak)
self.assertEqual(k8s.status["youtrack"]["conditions"][0]["status"], "True")
self.assertIsNotNone(self.ak.get("core/applications/youtrack/"))
before, k8s.status = len(self.ak.calls), {}
c.reconcile(k8s, self.ak)
self.assertEqual(self.ak.calls[before:], [])
self.assertEqual(k8s.status, {}) # unchanged status is not re-written
def test_duplicate_slug_withholds_both(self):
k8s = FakeK8s(clients=[self.client(), self.client("other", slug="youtrack")])
c.reconcile(k8s, self.ak)
self.assertIsNone(self.ak.get("core/applications/youtrack/"))
self.assertEqual({k: v["conditions"][0]["status"] for k, v in k8s.status.items()},
{"youtrack": "False", "other": "False"})
def test_transient_error_keeps_client(self):
k8s = FakeK8s(clients=[self.client()])
c.reconcile(k8s, self.ak)
self.ak.db["crypto/certificatekeypairs/"].clear()
c.reconcile(k8s, self.ak)
self.assertIsNotNone(self.ak.get("core/applications/youtrack/"))
self.assertIn("transient", k8s.status["youtrack"]["conditions"][0]["message"])
def test_sweeps_client_whose_resource_is_gone(self):
k8s = FakeK8s(clients=[self.client()])
c.reconcile(k8s, self.ak)
k8s.clients = []
c.reconcile(k8s, self.ak)
self.assertIsNone(self.ak.get("core/applications/youtrack/"))
self.assertEqual([p for p in self.ak.list("providers/oauth2/") if c.is_plain_oauth2(p)], [])
def test_gates_survive_alongside_oidc(self):
k8s = FakeK8s(ingresses=[LLAMA], services={"llama-server": SERVICE}, clients=[self.client()])
c.reconcile(k8s, self.ak)
gate = c.gate_name(LLAMA)
app = self.ak.get(f"core/applications/{gate}/")
before = len(self.ak.calls)
c.reconcile(k8s, self.ak)
self.assertEqual(self.ak.calls[before:], [])
self.assertEqual(self.ak.get(f"core/applications/{gate}/")["provider"], app["provider"])
self.assertIsNotNone(self.ak.get("core/applications/youtrack/"))
def test_gate_only_is_stable(self):
k8s = FakeK8s(ingresses=[LLAMA], services={"llama-server": SERVICE})
c.reconcile(k8s, self.ak)
before = len(self.ak.calls)
c.reconcile(k8s, self.ak)
self.assertEqual([m for m, _ in self.ak.calls[before:] if m != "GET"], [])
self.assertIsNotNone(self.ak.get(f"core/applications/{c.gate_name(LLAMA)}/"))
def test_slug_equal_to_gate_name_leaves_gate_alone(self):
k8s = FakeK8s(ingresses=[LLAMA], services={"llama-server": SERVICE})
c.reconcile(k8s, self.ak)
gate = c.gate_name(LLAMA)
app = self.ak.get(f"core/applications/{gate}/")
k8s.clients = [self.client("evil", slug=gate)]
c.reconcile(k8s, self.ak)
self.assertEqual(self.ak.get(f"core/applications/{gate}/")["provider"], app["provider"])
self.assertEqual([p for p in self.ak.list("providers/oauth2/") if c.is_plain_oauth2(p)], [])
self.assertEqual(k8s.status["evil"]["conditions"][0]["status"], "False")
if __name__ == "__main__":
unittest.main()
@@ -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)
}
}
@@ -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
@@ -1262,12 +1251,6 @@ data:
- authentik_policies_expression.add_expressionpolicy
- authentik_policies_expression.change_expressionpolicy
- authentik_policies_expression.delete_expressionpolicy
- authentik_providers_oauth2.view_oauth2provider
- authentik_providers_oauth2.add_oauth2provider
- authentik_providers_oauth2.change_oauth2provider
- authentik_providers_oauth2.delete_oauth2provider
- authentik_providers_oauth2.view_scopemapping
- authentik_crypto.view_certificatekeypair
- authentik_core.view_group
- authentik_flows.view_flow
- authentik_outposts.view_outpostserviceconnection
+1 -7
View File
@@ -62,12 +62,6 @@ rules:
- apiGroups: ["cilium.io"]
resources: ["ciliumnetworkpolicies"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: ["auth.renner.uno"]
resources: ["oidcclients"]
verbs: ["get", "list", "watch"]
- apiGroups: ["auth.renner.uno"]
resources: ["oidcclients/status"]
verbs: ["get", "patch", "update"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
@@ -111,7 +105,7 @@ spec:
type: RuntimeDefault
containers:
- name: controller
image: gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-11-01-0959 # {"$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
+15 -1
View File
@@ -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
@@ -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,6 +173,7 @@ spec:
- secretName: authentik-tls
hosts:
- auth.renner.uno
worker:
resources:
requests:
@@ -179,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
@@ -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
@@ -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).
@@ -44,9 +44,6 @@ spec:
# reachable over net1. The mDNS resolver is a separate
# ZeroconfResolver socket set and is unaffected.
k8s.v1.cni.cncf.io/networks: shelly-manager-iot
# Changing this rolls the pod, which is the only way a change to the
# shelly-manager-iot attachment takes effect.
net.renner.uno/iot-attachment-config: "tuning-no-ra"
spec:
# eth0 (Cilium-managed pod network) is still the primary — it
# carries the ingress, mosquitto LB return path, etc. The macvlan
@@ -64,17 +61,11 @@ spec:
type: RuntimeDefault
containers:
- name: shelly-manager
image: gitea.renner.uno/konairius/shelly-manager:2026-10-30-1343 # {"$imagepolicy": "flux-system:shelly-manager"}
image: gitea.renner.uno/k.renner/shelly-manager:build.1792997278 # {"$imagepolicy": "flux-system:shelly-manager"}
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 8080
# Firmware images only (/fw/...), for the devices: Service shelly-manager-firmware.
- name: firmware
containerPort: 8082
# /metrics only: Service shelly-manager-metrics.
- name: metrics
containerPort: 9090
env:
- name: TZ
value: Europe/Berlin
@@ -92,23 +83,8 @@ spec:
valueFrom:
fieldRef:
fieldPath: status.podIP
# 8082 and 9090 are separate pipelines in the app that answer only
# /fw/... and /metrics; the UI and API stay on 8080.
- name: ASPNETCORE_URLS
value: "http://$(POD_IP):8080;http://$(POD_IP):8082;http://$(POD_IP):9090"
# Both pod addresses (IPv4,IPv6). When set, the app binds all three
# ports to exactly these and ignores ASPNETCORE_URLS, so nothing
# listens on net1 and the firmware LoadBalancer gets an IPv6 listener.
- name: POD_IPS
valueFrom:
fieldRef:
fieldPath: status.podIPs
# The URL devices download firmware from: Service shelly-manager-firmware.
- name: Firmware__PublicBaseUrl
value: http://firmware.shelly-manager.renner.uno
# Battery devices get their update when they call in.
- name: Firmware__SleepyRollout
value: "true"
value: "http://$(POD_IP):8080"
- name: ShellyManager__DnsSuffix
value: iot.renner.uno
# BLE radios: ESP32 esphome bluetooth_proxy nodes (e.g. the M5 Atom
@@ -44,12 +44,6 @@ spec:
# the pod goes NotReady and its ingress returns 504. Static IPAM used to
# dodge this by simply never setting a gateway; leased addresses come with
# one, so it has to be confined instead of omitted.
#
# sbr covers IPv4 only. With Router Advertisements net1 gets global IPv6
# addresses plus on-link and default routes in the main table, and replies
# to IPv6 clients on v30iot leave via net1, bypassing Cilium's LoadBalancer
# reverse NAT. tuning turns RA processing off on net1; the link-local
# address mDNS uses stays.
config: |
{
"cniVersion": "0.3.1",
@@ -63,13 +57,6 @@ spec:
"type": "dhcp"
}
},
{
"type": "tuning",
"sysctl": {
"net.ipv6.conf.IFNAME.accept_ra": "0",
"net.ipv6.conf.IFNAME.autoconf": "0"
}
},
{
"type": "sbr"
}
@@ -29,56 +29,3 @@ spec:
port: 80
targetPort: api
protocol: TCP
---
# Firmware downloads for the Shelly fleet. Devices on the IoT VLAN reach it over
# the UDM's BGP routes (like mosquitto-iot) by the name
# firmware.shelly-manager.renner.uno, which shelly-manager hands them in the
# update URL.
# The app serves only /fw/... on this port. Ingress: shelly-manager-firmware-
# world-ingress in network-policies/home-assistant-ns.yaml.
apiVersion: v1
kind: Service
metadata:
name: shelly-manager-firmware
namespace: home-assistant
labels:
app.kubernetes.io/name: shelly-manager
lb-pool: bgp
dns.renner.uno/internal: "true"
annotations:
# Pinned like the other bgp-pool Services, which auto-assignment would give
# the pool's base address 10.2.99.0 (see trmnl/device-service.yaml).
lbipam.cilium.io/ips: "10.2.99.90,fd99::8a"
external-dns.kubernetes.io/hostname: firmware.shelly-manager.renner.uno
spec:
type: LoadBalancer
externalTrafficPolicy: Local
ipFamilyPolicy: PreferDualStack
ipFamilies:
- IPv4
- IPv6
selector:
app.kubernetes.io/name: shelly-manager
ports:
- name: http
port: 80
targetPort: firmware
protocol: TCP
---
# Scrape target for monitoring-rules/shelly-manager-servicemonitor.yaml.
apiVersion: v1
kind: Service
metadata:
name: shelly-manager-metrics
namespace: home-assistant
labels:
app.kubernetes.io/name: shelly-manager
app.kubernetes.io/component: metrics
spec:
selector:
app.kubernetes.io/name: shelly-manager
ports:
- name: metrics
port: 9090
targetPort: metrics
protocol: TCP
@@ -1,11 +1,10 @@
---
# Mosquitto broker LoadBalancer on the BGP-routed off-link pool. IoT
# devices reach the broker via the UDM's BGP-learned routes — no
# on-link L2 announce on v30iot. The Shelly fleet's MQTT server is set
# by shelly-manager's root group setting `mqtt.server` (Groups → All
# Shellies → settings) and reconciled to every device, so each device's
# stored MQTT server resolves to `mqtt.renner.uno`, not a bare IP — that
# way future IP changes only need DNS updates, no reflash sweep.
# on-link L2 announce on v30iot. Companion Shelly fleet is updated via
# shelly-manager's `/api/v1/mqtt/push` so each device's stored MQTT
# server resolves to `mqtt.renner.uno`, not a bare IP — that way future
# IP changes only need DNS updates, no reflash sweep.
apiVersion: v1
kind: Service
metadata:
@@ -223,7 +223,9 @@ spec:
type: RuntimeDefault
containers:
- name: controller
image: gitea.renner.uno/konairius/mqtt-account-controller:2026-10-29-1634 # {"$imagepolicy": "flux-system:mqtt-account-controller"}
# Woodpecker builds main-<sha> on every merge to main; no sortable
# tag, so bumps are manual (same as ical-proxy).
image: gitea.renner.uno/konairius/mqtt-account-controller:main-b1f294d@sha256:8081adfee171c36cfa83734e5e7ccc3aad3debf1c0154488de70143674cde85f
env:
- name: BROKER_URL
value: ssl://mosquitto.home-assistant.svc.cluster.local:8884
@@ -66,7 +66,7 @@ spec:
# (machine.registries.mirrors, applied by bootstrap.sh), and containerd
# keeps the repository path when it falls through — so the path has to
# exist on both registries. Steady state still pulls from Gitea.
image: gitea.renner.uno/konairius/alpine-jq:2026-10-29-1348 # {"$imagepolicy": "flux-system:alpine-jq"}
image: gitea.renner.uno/konairius/alpine-jq:2026-09-04
command:
- sh
- -c
+38 -1
View File
@@ -17,7 +17,44 @@ spec:
spec:
containers:
- name: ical-proxy
image: gitea.renner.uno/konairius/ical-proxy:2026-10-28-0651 # {"$imagepolicy": "flux-system:ical-proxy"}
# Pinned to a commit build rather than :latest.
#
# :latest never rolls. The tag moves when a build publishes, but
# nothing tells the Deployment about it: the pod template is
# unchanged, so Kubernetes has no reason to replace anything, and
# this image is deliberately NOT enrolled in Flux image automation
# — see kubernetes/infrastructure/image-automation/README.md, which
# lists gitea.renner.uno/konairius/ical-proxy as having "only main, latest,
# and main-<sha> commit builds; no release tags", so there is no
# ImagePolicy watching it. The pods therefore sat on a 21-day-old
# image while :latest moved underneath them, and the only way to
# pick up a new build was a manual restart.
#
# A pinned tag makes the rollout a git change: Flux applies the new
# pod template and Kubernetes rolls it, which is also what makes
# what is RUNNING recoverable from this repo rather than "whatever
# :latest meant the last time a pod happened to restart".
#
# The image comes from the Gitea package registry, built by
# Woodpecker (.woodpecker.yml in konairius/ical-proxy) on every
# push to main. It is amd64-only and pulls without credentials, so
# no imagePullSecret. main-4783abe adds two hardening changes on
# top of main-db90a59: an SSRF guard (upstreams and redirect hops
# resolving to private/loopback/link-local addresses are refused;
# public redirects are still followed, up to 5) and a cap of 20
# concurrent /proxy upstream fetches (over the cap: 429 +
# Retry-After). main-73b5d97 then raises the server WriteTimeout
# from 10s to 40s (the 30s upstream fetch timeout + 10s headroom),
# so a slow-but-legitimate upstream is no longer cut off before
# /proxy can answer. main-926ead0 caps the upstream response body
# at 2 MiB (larger feeds get 413 instead of being buffered whole,
# REN-230) and logs upstream fetch failures with the host,
# answering 502 instead of 500. The summary_match/summary_replace
# parameters this deployment needs are unchanged.
#
# Bumping this is a manual, reviewed edit. The tag format is
# main-<short-sha>, the first 7 characters of the main commit.
image: gitea.renner.uno/konairius/ical-proxy:main-926ead0
ports:
- containerPort: 8080
name: http
+1 -3
View File
@@ -30,7 +30,7 @@ spec:
type: RuntimeDefault
containers:
- name: controller
image: gitea.renner.uno/gitea_admin/kuma-sync:2026-10-30-1314 # {"$imagepolicy": "flux-system:kuma-sync"}
image: gitea.renner.uno/gitea_admin/kuma-sync:2026-10-27-0801 # {"$imagepolicy": "flux-system:kuma-sync"}
env:
- name: KUMA_URL
value: https://kuma.renner.uno
@@ -44,8 +44,6 @@ spec:
secretKeyRef:
name: kuma-sync-kuma
key: password
- name: KUMA_ROOT_GROUP
value: kuma-sync/renner-uno
ports:
- name: metrics
containerPort: 8080
@@ -21,34 +21,3 @@ spec:
remoteRef:
key: infrastructure/kuma-sync
property: password
---
# SMTP submission for the KumaNotification "email"; OpenBao homelab/infrastructure/uptime-kuma/smtp.
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: kuma-sync-smtp
namespace: kuma-sync
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: openbao
target:
name: kuma-sync-smtp
data:
- secretKey: host
remoteRef:
key: infrastructure/uptime-kuma/smtp
property: server
- secretKey: port
remoteRef:
key: infrastructure/uptime-kuma/smtp
property: port
- secretKey: username
remoteRef:
key: infrastructure/uptime-kuma/smtp
property: username
- secretKey: password
remoteRef:
key: infrastructure/uptime-kuma/smtp
property: token
@@ -1,14 +0,0 @@
# Root group (KUMA_ROOT_GROUP): parent of every monitor without a parentRef; the only one with notificationRefs.
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: renner-uno
namespace: kuma-sync
spec:
type: group
displayName: renner.uno
interval: 60
# heartbeats, not minutes: 60 x 60s = hourly reminder while down
resendInterval: 60
notificationRefs:
- name: email
@@ -5,5 +5,3 @@ resources:
- rbac.yaml
- deployment.yaml
- statuspage-renner.yaml
- notification-email.yaml
- kumamonitor-root.yaml
@@ -1,14 +0,0 @@
---
# Referenced by KumaMonitor spec.notificationRefs; unreferenced, it sends nothing.
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaNotification
metadata:
name: email
spec:
type: smtp
smtp:
from: uptime@renner.uno
to: admin@renner.uno
secretRef:
namespace: kuma-sync
name: kuma-sync-smtp
+4 -30
View File
@@ -7,20 +7,20 @@ metadata:
labels:
app.kubernetes.io/name: kuma-sync
---
# Cluster-wide: KumaMonitor and KumaStatusPage objects live in any namespace; KumaNotification is cluster-scoped.
# Cluster-wide: KumaMonitor and KumaStatusPage objects live in any namespace.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: kuma-sync
rules:
- apiGroups: ["uptime.renner.uno"]
resources: ["kumamonitors", "kumastatuspages", "kumanotifications"]
resources: ["kumamonitors", "kumastatuspages"]
verbs: ["get", "list", "watch", "update", "patch"]
- apiGroups: ["uptime.renner.uno"]
resources: ["kumamonitors/status", "kumastatuspages/status", "kumanotifications/status"]
resources: ["kumamonitors/status", "kumastatuspages/status"]
verbs: ["get", "update", "patch"]
- apiGroups: ["uptime.renner.uno"]
resources: ["kumamonitors/finalizers", "kumastatuspages/finalizers", "kumanotifications/finalizers"]
resources: ["kumamonitors/finalizers", "kumastatuspages/finalizers"]
verbs: ["update"]
- apiGroups: [""]
resources: ["events"]
@@ -41,29 +41,3 @@ subjects:
- kind: ServiceAccount
name: kuma-sync
namespace: kuma-sync
---
# SMTP credentials of the KumaNotification "email", read uncached.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: kuma-sync-smtp
namespace: kuma-sync
rules:
- apiGroups: [""]
resources: ["secrets"]
resourceNames: ["kuma-sync-smtp"]
verbs: ["get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: kuma-sync-smtp
namespace: kuma-sync
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: kuma-sync-smtp
subjects:
- kind: ServiceAccount
name: kuma-sync
namespace: kuma-sync
@@ -63,9 +63,6 @@ resources:
# deadlocked the whole apps layer. See the file for the cycle.
- rook-servicemonitors.yaml
- home-assistant-servicemonitor.yaml
# shelly-manager firmware rollout: halts, index freshness, config.
- shelly-manager-servicemonitor.yaml
- prometheusrule-shelly-manager.yaml
- immich-servicemonitor.yaml
- loki-servicemonitor.yaml
- smartctl-exporter-servicemonitor.yaml
@@ -142,4 +142,4 @@ spec:
- "[fd02:47::1]:53"
- "10.2.47.1:53"
labels:
dns_name: sync.paperless.renner.uno
dns_name: syncthing.paperless.renner.uno
@@ -1,54 +0,0 @@
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: shelly-manager
namespace: monitoring
labels:
# kube-prometheus-stack's Prometheus resource selects on this label;
# without it the rule is created but never loaded into Prometheus.
release: kube-prometheus-stack
spec:
groups:
- name: shelly-manager.rules
rules:
# Metrics from shelly-manager's /metrics (ServiceMonitor
# shelly-manager-servicemonitor.yaml). Tests: tests/shelly-manager_test.yaml.
- alert: ShellyFirmwareRolloutHalted
# 1 per device type while its rollout is halted; cleared by Resume or
# Skip version on the Firmware page.
expr: |
max by (type) (shelly_manager_firmware_halted) == 1
labels:
severity: warning
namespace: home-assistant
annotations:
summary: "Shelly firmware rollout halted for {{ $labels.type }}"
description: "A firmware update of a {{ $labels.type }} device failed, so shelly-manager stopped rolling out to that type. The Firmware page names the device and the reason; Resume retries that device, Skip version leaves the version out."
- alert: ShellyFirmwareIndexStale
# Set only by a refresh in which every index source (Gen1 and Gen2+)
# succeeded, and restored from the stored releases on start. The
# index is read every 6 h.
expr: |
time() - max(shelly_manager_firmware_index_last_success_timestamp_seconds) > 172800
for: 30m
labels:
severity: warning
namespace: home-assistant
annotations:
summary: "Shelly firmware index not read successfully for 48 h"
description: "shelly-manager has not completed a refresh of all firmware index sources for 48 hours, so new firmware is not picked up. Check its logs for index errors (TLS pin, egress to *.shelly.cloud, HTTP errors); shelly_manager_firmware_index_errors_total shows which source fails."
- alert: ShellyFirmwareConfigInvalid
# Firmware:Window or Firmware:TimeZone cannot be read; the rollout
# does not start while this is 0.
expr: |
max(shelly_manager_firmware_config_valid) == 0
for: 10m
labels:
severity: warning
namespace: home-assistant
annotations:
summary: "Shelly firmware rollout disabled by invalid config"
description: "Firmware__Window or Firmware__TimeZone in kubernetes/apps/home-assistant-iot/shelly-manager-deployment.yaml (or the image defaults) is invalid, so shelly-manager starts no automatic firmware updates."
@@ -1,19 +0,0 @@
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: shelly-manager
namespace: home-assistant
labels:
app.kubernetes.io/name: shelly-manager
# Not required for discovery — serviceMonitorSelector is empty — but kept
# for consistency with the other monitors.
release: kube-prometheus-stack
spec:
selector:
matchLabels:
app.kubernetes.io/name: shelly-manager
app.kubernetes.io/component: metrics
endpoints:
- port: metrics
path: /metrics
interval: 60s
@@ -34,7 +34,6 @@ retired the depth rule in favour of one persistence rule (`> 5` for `20m`).
| `gitea_test.yaml` | Hand-written suite for `GiteaDataVolumeLow` in `prometheusrule-gitea.yaml` (REN-583). |
| `uptime-kuma_test.yaml` | Hand-written suite for `prometheusrule-uptime-kuma.yaml` and the kuma exclusion in `prometheusrule-blackbox.yaml`. |
| `k8s-gateway_test.yaml` | Hand-written suite for `prometheusrule-k8s-gateway.yaml` (#1290). |
| `shelly-manager_test.yaml` | Hand-written suite for `prometheusrule-shelly-manager.yaml`. |
| `mutations.txt` | Negative controls. Each must make the suite fail. |
## Adding a suite
@@ -1,80 +0,0 @@
# Suite for prometheusrule-shelly-manager.yaml.
rule_files:
- ../prometheusrule-shelly-manager.yaml
evaluation_interval: 1m
tests:
# A halted type fires at once, per type; a type at 0 stays silent.
- interval: 1m
input_series:
- series: 'shelly_manager_firmware_halted{type="rpc:Plus1PM",pod="shelly-manager-a"}'
values: '0 0 1 1 1'
- series: 'shelly_manager_firmware_halted{type="gen1:SHSW-1",pod="shelly-manager-a"}'
values: '0 0 0 0 0'
alert_rule_test:
- eval_time: 1m
alertname: ShellyFirmwareRolloutHalted
exp_alerts: []
- eval_time: 2m
alertname: ShellyFirmwareRolloutHalted
exp_alerts:
- exp_labels:
severity: warning
namespace: home-assistant
type: rpc:Plus1PM
exp_annotations:
summary: "Shelly firmware rollout halted for rpc:Plus1PM"
description: "A firmware update of a rpc:Plus1PM device failed, so shelly-manager stopped rolling out to that type. The Firmware page names the device and the reason; Resume retries that device, Skip version leaves the version out."
# Last success at t=0: stale from 48h01m (first evaluation past 172800 s),
# fires 30 m later.
- interval: 1m
input_series:
- series: 'shelly_manager_firmware_index_last_success_timestamp_seconds{pod="shelly-manager-a"}'
values: '0+0x3000'
alert_rule_test:
- eval_time: 48h
alertname: ShellyFirmwareIndexStale
exp_alerts: []
- eval_time: 48h30m
alertname: ShellyFirmwareIndexStale
exp_alerts: []
- eval_time: 48h31m
alertname: ShellyFirmwareIndexStale
exp_alerts:
- exp_labels:
severity: warning
namespace: home-assistant
exp_annotations:
summary: "Shelly firmware index not read successfully for 48 h"
description: "shelly-manager has not completed a refresh of all firmware index sources for 48 hours, so new firmware is not picked up. Check its logs for index errors (TLS pin, egress to *.shelly.cloud, HTTP errors); shelly_manager_firmware_index_errors_total shows which source fails."
# A success that keeps up with time stays silent.
- interval: 1m
input_series:
- series: 'shelly_manager_firmware_index_last_success_timestamp_seconds{pod="shelly-manager-a"}'
values: '0+60x6000'
alert_rule_test:
- eval_time: 100h
alertname: ShellyFirmwareIndexStale
exp_alerts: []
# Invalid config fires after 10 m; valid config stays silent.
- interval: 1m
input_series:
- series: 'shelly_manager_firmware_config_valid{pod="shelly-manager-a"}'
values: '1 1 0+0x20'
alert_rule_test:
- eval_time: 11m
alertname: ShellyFirmwareConfigInvalid
exp_alerts: []
- eval_time: 12m
alertname: ShellyFirmwareConfigInvalid
exp_alerts:
- exp_labels:
severity: warning
namespace: home-assistant
exp_annotations:
summary: "Shelly firmware rollout disabled by invalid config"
description: "Firmware__Window or Firmware__TimeZone in kubernetes/apps/home-assistant-iot/shelly-manager-deployment.yaml (or the image defaults) is invalid, so shelly-manager starts no automatic firmware updates."
@@ -158,69 +158,43 @@ spec:
relabel_rules = loki.relabel.syslog.rules
}
// UniFi activity logging — UDP/5514, one target for two payloads:
// the UDM SIEM events in CEF ("<bsd-ts> <device-name> CEF:0|...")
// and plain syslog from the UDM (log_all_contents) and from every
// adopted AP/switch (debug). raw listener captures verbatim;
// loki.process.udm_cef sets `source` per payload type.
// UniFi UDM SIEM Server (Network app) — UDP/5514. UDM emits CEF
// (Common Event Format), not RFC syslog: payload starts with
// "<bsd-ts> <iso-ts> <device-name> CEF:0|Vendor|Product|...".
// raw listener captures verbatim; loki.process.udm_cef extracts
// CEF header + UNIFI* extension fields and promotes a curated
// low-cardinality set to labels.
loki.source.syslog "udp_5514" {
listener {
address = "0.0.0.0:5514"
protocol = "udp"
syslog_format = "raw"
labels = { job = "syslog", listener = "udp-5514" }
labels = { job = "syslog", listener = "udp-5514", source = "udm-siem" }
}
forward_to = [loki.process.udm_cef.receiver]
}
// CEF parser for UDM SIEM payload. The listener also receives the
// UDM's plain syslog (log_all_contents), which does not match and
// passes through unlabelled. CEF extension keys differ per event
// type, so the regex anchors only on the header
// (vendor|product|product_version|sig_id|name|sev), UNIFIcategory,
// UNIFIhost and the trailing msg. The full raw line stays the log
// content.
loki.process "udm_cef" {
forward_to = [loki.write.default.receiver]
// CEF: extension keys differ per event type, so the regex
// anchors only on the header
// (vendor|product|product_version|sig_id|name|sev),
// UNIFIcategory, UNIFIhost and the trailing msg. The full raw
// line stays the log content.
stage.match {
selector = "{listener=\"udp-5514\"} |= \"CEF:\""
stage.static_labels {
values = { source = "udm-siem" }
}
stage.regex {
expression = "CEF:\\d+\\|(?P<vendor>[^|]*)\\|(?P<product>[^|]*)\\|(?P<product_version>[^|]*)\\|(?P<signature_id>[^|]*)\\|(?P<event_name>[^|]*)\\|(?P<cef_severity>[^|]*)\\|UNIFIcategory=(?P<category>.+?) UNIFIhost=(?P<unifi_host>.+?) \\S+=.* msg=(?P<msg>.+)$"
}
stage.labels {
values = {
vendor = "vendor",
product = "product",
signature_id = "signature_id",
event_name = "event_name",
category = "category",
}
}
stage.regex {
expression = "CEF:\\d+\\|(?P<vendor>[^|]*)\\|(?P<product>[^|]*)\\|(?P<product_version>[^|]*)\\|(?P<signature_id>[^|]*)\\|(?P<event_name>[^|]*)\\|(?P<cef_severity>[^|]*)\\|UNIFIcategory=(?P<category>.+?) UNIFIhost=(?P<unifi_host>.+?) \\S+=.* msg=(?P<msg>.+)$"
}
// Plain syslog. UDM: "<ts> <host> <host> <app>[pid]: ...";
// AP/switch: "<ts> <host> <mac>,<model>-<fw>: [: ]<app>[pid]: ...".
stage.match {
selector = "{listener=\"udp-5514\"} != \"CEF:\""
stage.static_labels {
values = { source = "unifi-syslog" }
}
stage.regex {
expression = "^\\S+ +\\d+ \\S+ (?P<host>\\S+) (?:[0-9a-f]{12},\\S+:(?: :)? |\\S+ )(?P<app>[A-Za-z0-9_./+-]+)"
}
stage.labels {
values = {
host = "host",
app = "app",
}
stage.labels {
values = {
vendor = "vendor",
product = "product",
signature_id = "signature_id",
event_name = "event_name",
category = "category",
}
}
}
@@ -136,7 +136,7 @@ spec:
prober: dns
timeout: 5s
dns:
query_name: sync.paperless.renner.uno
query_name: syncthing.paperless.renner.uno
query_type: A
valid_rcodes: [NOERROR]
validate_answer_rrs:

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