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