Compare commits
259
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ecd10702e | ||
|
|
a6fdcf54f7 | ||
|
|
1a3368e70d | ||
|
|
82165d6704 | ||
|
|
2ff8e216e1 | ||
|
|
4589ef3747 | ||
|
|
dee425fd2f | ||
|
|
59cc813a87 | ||
|
|
979e3910ca | ||
|
|
6a0dbf9e26 | ||
|
|
e25f1a43f8 | ||
|
|
a4e5f99ca1 | ||
|
|
17839eeeab | ||
|
|
f6fc79fce9 | ||
|
|
583fc044cb | ||
|
|
f7deb40a2b | ||
|
|
b3bf09ebc3 | ||
|
|
5afb29dcb2 | ||
|
|
e4b0b13c3e | ||
|
|
00e443c397 | ||
|
|
b2b4cd4ab9 | ||
|
|
6baf3139b9 | ||
|
|
ff6ad64783 | ||
|
|
07e1684bd1 | ||
|
|
44cdf84f99 | ||
|
|
ca20206bbf | ||
|
|
293f11612c | ||
|
|
7602c755cf | ||
|
|
faff22bebe | ||
|
|
7459aa9e59 | ||
|
|
41efcd9f91 | ||
|
|
30704634b3 | ||
|
|
43861d34c1 | ||
|
|
3252a6f9ee | ||
|
|
cf80a096a6 | ||
|
|
41e272f188 | ||
|
|
8dc0986d1d | ||
|
|
edb4aa804c | ||
|
|
9777e147e8 | ||
|
|
37dc17065d | ||
|
|
570c93559b | ||
|
|
71ab916ff7 | ||
|
|
9aecaacbb2 | ||
|
|
744ea79933 | ||
|
|
c4f32c7a97 | ||
|
|
0ab63f46d4 | ||
|
|
cbc538ec7d | ||
|
|
c541190e8e | ||
|
|
6de87eba4c | ||
|
|
2beb9a4e14 | ||
|
|
767b3cdac4 | ||
|
|
ccb69bb109 | ||
|
|
69f7075c7c | ||
|
|
1b06a922a9 | ||
|
|
d457c7e639 | ||
|
|
a29e10e313 | ||
|
|
15d98d2702 | ||
|
|
4b7960c18c | ||
|
|
bc50d58723 | ||
|
|
f65015a423 | ||
|
|
db0c5a8d96 | ||
|
|
7f78f48ec6 | ||
|
|
73ede3d227 | ||
|
|
9c42c72037 | ||
|
|
5d0e705b30 | ||
|
|
6986f36237 | ||
|
|
4316d503d2 | ||
|
|
1b2684c35b | ||
|
|
ec91122621 | ||
|
|
fc720823db | ||
|
|
8d47e085cb | ||
|
|
54ae8ce463 | ||
|
|
cd2e7864a4 | ||
|
|
fec9f6e0e5 | ||
|
|
55fce76a2d | ||
|
|
5fcd6dc5c4 | ||
|
|
3a35e8481a | ||
|
|
a5be653044 | ||
|
|
1d5a262a84 | ||
|
|
ad6fc66875 | ||
|
|
861e9a3193 | ||
|
|
9a1dc89326 | ||
|
|
d505ca2062 | ||
|
|
28e864cfc4 | ||
|
|
e7d45a5c31 | ||
|
|
7a8791c3b0 | ||
|
|
b6acbc6e79 | ||
|
|
b1b58e397c | ||
|
|
fdaf20aa3f | ||
|
|
4e61d543a8 | ||
|
|
302b177ba2 | ||
|
|
531e192e0c | ||
|
|
3416cd476d | ||
|
|
8a41b80217 | ||
|
|
a05f1431ca | ||
|
|
5718c9bae2 | ||
|
|
2c79edabea | ||
|
|
6e12a608b3 | ||
|
|
5718492e2b | ||
|
|
3bfb38ebd7 | ||
|
|
1de63af07b | ||
|
|
6c2c278f65 | ||
|
|
21625685b4 | ||
|
|
29da21c28f | ||
|
|
c781e263fc | ||
|
|
b1be3181ef | ||
|
|
08b087337e | ||
|
|
8610853a6b | ||
|
|
1b724ef114 | ||
|
|
ee1a421dc0 | ||
|
|
1e66d793d5 | ||
|
|
0996609ce4 | ||
|
|
4b27a5f273 | ||
|
|
ea195d0e4f | ||
|
|
1ae259e474 | ||
|
|
796d8459b6 | ||
|
|
a6be8247eb | ||
|
|
826042f3ea | ||
|
|
aa2543369c | ||
|
|
5add98190f | ||
|
|
1643b305b2 | ||
|
|
657e3d0d92 | ||
|
|
ac2d5d13a1 | ||
|
|
ed3be3156b | ||
|
|
05d2fcac71 | ||
|
|
697856cbf8 | ||
|
|
4962af5ed1 | ||
|
|
bc698ea22d | ||
|
|
d42862e2d8 | ||
|
|
73dd6f6362 | ||
|
|
7434894f14 | ||
|
|
7798a17ba5 | ||
|
|
61fc16ff67 | ||
|
|
1e785e028e | ||
|
|
265b745429 | ||
|
|
014fe5efc0 | ||
|
|
22fd0f8e54 | ||
|
|
c382831464 | ||
|
|
49017ac5d7 | ||
|
|
64e19b4087 | ||
|
|
f9eb38e2da | ||
|
|
3ed987dd74 | ||
|
|
da58096453 | ||
|
|
06a916ddba | ||
|
|
335c423ca6 | ||
|
|
7e95f4cc7d | ||
|
|
c6467a54ef | ||
|
|
eb7949b0f0 | ||
|
|
f1d0dde23b | ||
|
|
e33abd320f | ||
|
|
a8affe6955 | ||
|
|
6757eaf361 | ||
|
|
da1dc5b00e | ||
|
|
b05c84ac20 | ||
|
|
378496541c | ||
|
|
50d862d214 | ||
|
|
fae0276045 | ||
|
|
d8000634a9 | ||
|
|
1d4bc326d8 | ||
|
|
6a62bf54e4 | ||
|
|
27a34fa5f5 | ||
|
|
e633b1a0c5 | ||
|
|
da60eba710 | ||
|
|
ed59b8a32a | ||
|
|
46d91c8fad | ||
|
|
c7e018e7b6 | ||
|
|
cd5e947baf | ||
|
|
ce292d629b | ||
|
|
e5d0ffc3e5 | ||
|
|
dae7550e6f | ||
|
|
1177bb839d | ||
|
|
3c9c001685 | ||
|
|
c68befa4a4 | ||
|
|
68e4d42f77 | ||
|
|
eb035613ab | ||
|
|
e8645d11ed | ||
|
|
f3c9188807 | ||
|
|
c68b9ad7c2 | ||
|
|
59c9cc29fe | ||
|
|
3501586841 | ||
|
|
9e1452cfe7 | ||
|
|
fe397f906d | ||
|
|
48a28d1c8f | ||
|
|
a05dcfdc94 | ||
|
|
9616ee2ec6 | ||
|
|
e0e07c0327 | ||
|
|
50307fe748 | ||
|
|
fd0d2ef301 | ||
|
|
141b11a9ad | ||
|
|
03cf6a0e45 | ||
|
|
88576f7022 | ||
|
|
8c85a49ae5 | ||
|
|
8fa68bc9cd | ||
|
|
20d928aeba | ||
|
|
60dfcf5382 | ||
|
|
5e814c525a | ||
|
|
1a99c02996 | ||
|
|
09a03dfd76 | ||
|
|
f76defb1e1 | ||
|
|
3e0557cfe8 | ||
|
|
8739efe026 | ||
|
|
e518b46045 | ||
|
|
d7a6c2bdc7 | ||
|
|
53d7e716f8 | ||
|
|
61e4ee2ba8 | ||
|
|
b447c57e78 | ||
|
|
4f3cf541a4 | ||
|
|
6463c38f68 | ||
|
|
b86aaf3f88 | ||
|
|
c9944cfce8 | ||
|
|
717d1ad619 | ||
|
|
40f30412aa | ||
|
|
3f73e7cd3a | ||
|
|
a7543cb78b | ||
|
|
229e0cf5d8 | ||
|
|
b7cf7632c6 | ||
|
|
b77b0a4d0d | ||
|
|
2485b3ad38 | ||
|
|
a49c852666 | ||
|
|
3aa2cf7616 | ||
|
|
0a36c324da | ||
|
|
8d3578f020 | ||
|
|
10f1a88802 | ||
|
|
4dd46a8e7b | ||
|
|
859d1a7c65 | ||
|
|
0575a50d9e | ||
|
|
33b7b58849 | ||
|
|
dab7c10eca | ||
|
|
378f7e3429 | ||
|
|
fdc08827fb | ||
|
|
3d504c2f62 | ||
|
|
d56deb096e | ||
|
|
222965ba47 | ||
|
|
394bf91c37 | ||
|
|
2f7bc33b84 | ||
|
|
6c045e2e56 | ||
|
|
811178e321 | ||
|
|
6a39dd1f6e | ||
|
|
70a9c66902 | ||
|
|
dcf902f53a | ||
|
|
83235b2b07 | ||
|
|
b656422890 | ||
|
|
9f5acfd3a6 | ||
|
|
30db24335e | ||
|
|
0e33efa3b1 | ||
|
|
9fcf5259cb | ||
|
|
ff6a96438d | ||
|
|
2e3d055310 | ||
|
|
b741f4bdb0 | ||
|
|
1efbfd1424 | ||
|
|
9a061ff4ce | ||
|
|
a593df3bb4 | ||
|
|
cd39bf648a | ||
|
|
e4362bbd69 | ||
|
|
63b2c3cc50 | ||
|
|
6dc27eb31c | ||
|
|
1dfdb104c5 | ||
|
|
70bca9ed52 | ||
|
|
22353bac5a |
@@ -41,13 +41,14 @@ when:
|
||||
- "ci/images/**"
|
||||
- "secret-rotator/**"
|
||||
- "review-bot/**"
|
||||
- "wlan-setup/**"
|
||||
- ".woodpecker/build-ci-images.yml"
|
||||
- event: deployment
|
||||
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "build-ci-images"'
|
||||
|
||||
steps:
|
||||
- name: build-ansible
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
|
||||
directory: ci/images/ansible
|
||||
# Only rebuild when THIS image's source (or the pipeline file) changed, or
|
||||
# on a manual run. Without this every step ran on any matching push, so a
|
||||
@@ -74,7 +75,7 @@ steps:
|
||||
--destination gitea.renner.uno/gitea_admin/ci-ansible:latest
|
||||
|
||||
- name: build-k8s
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
|
||||
directory: ci/images/k8s
|
||||
when:
|
||||
- event: deployment
|
||||
@@ -97,7 +98,7 @@ steps:
|
||||
--destination gitea.renner.uno/gitea_admin/ci-k8s:latest
|
||||
|
||||
- name: build-vmtest
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
|
||||
directory: ci/images/vmtest
|
||||
when:
|
||||
- event: deployment
|
||||
@@ -120,7 +121,7 @@ steps:
|
||||
--destination gitea.renner.uno/gitea_admin/ci-vmtest:latest
|
||||
|
||||
- name: build-tofu
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
|
||||
directory: ci/images/tofu
|
||||
when:
|
||||
- event: deployment
|
||||
@@ -150,7 +151,7 @@ steps:
|
||||
# Runtime image, not CI tooling: a static musl binary in a FROM-scratch image,
|
||||
# used as the base for secret-rotation CronJobs. Replaces per-job inline Python.
|
||||
- name: build-secret-rotator
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
|
||||
directory: secret-rotator
|
||||
when:
|
||||
- event: deployment
|
||||
@@ -175,7 +176,7 @@ steps:
|
||||
# review-bot: the Go service that reviews pull requests with a Mistral agent (webhook plus sweep).
|
||||
# Runs after Gitea is up, so no ghcr mirror.
|
||||
- name: build-review-bot
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
|
||||
directory: review-bot
|
||||
when:
|
||||
- event: deployment
|
||||
@@ -197,6 +198,31 @@ steps:
|
||||
--destination gitea.renner.uno/gitea_admin/review-bot:$TAG \
|
||||
--destination gitea.renner.uno/gitea_admin/review-bot:latest
|
||||
|
||||
# wlan-setup: the Go service behind wlan.renner.uno (802.1X/Passpoint setup page with per-device
|
||||
# authentik app passwords). Runs after Gitea is up, so no ghcr mirror.
|
||||
- name: build-wlan-setup
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
|
||||
directory: wlan-setup
|
||||
when:
|
||||
- event: deployment
|
||||
- event: push
|
||||
path: ["wlan-setup/**", ".woodpecker/build-ci-images.yml"]
|
||||
environment:
|
||||
GITEA_USER: gitea_admin
|
||||
GITEA_TOKEN:
|
||||
from_secret: ci_images_token
|
||||
commands:
|
||||
- mkdir -p /kaniko/.docker
|
||||
- AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n')
|
||||
- printf '{"auths":{"gitea.renner.uno":{"auth":"%s"}}}' "$AUTH" > /kaniko/.docker/config.json
|
||||
- TAG=$(date +%Y-%m-%d-%H%M)
|
||||
- |
|
||||
/kaniko/executor \
|
||||
--context . \
|
||||
--dockerfile Containerfile \
|
||||
--destination gitea.renner.uno/gitea_admin/wlan-setup:$TAG \
|
||||
--destination gitea.renner.uno/gitea_admin/wlan-setup:latest
|
||||
|
||||
# Runtime image, not CI tooling: a general-purpose alpine + jq base for
|
||||
# workloads that need to read JSON from a shell script. Named for what it IS,
|
||||
# not for who uses it — its first consumer is the zigbee2mqtt seed-data init
|
||||
@@ -225,7 +251,7 @@ steps:
|
||||
#
|
||||
# The gitea_admin/ copy stays until nothing references it any more.
|
||||
- name: build-alpine-jq
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
|
||||
directory: ci/images/alpine-jq
|
||||
when:
|
||||
- event: deployment
|
||||
@@ -276,7 +302,7 @@ steps:
|
||||
# note on build-alpine-jq. Anything an app needs while the cluster is still
|
||||
# being built cannot live only in that cluster's own registry.
|
||||
- name: build-cni-plugins
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
|
||||
directory: ci/images/cni-plugins
|
||||
when:
|
||||
- event: deployment
|
||||
@@ -316,7 +342,7 @@ steps:
|
||||
# gitea.renner.uno is not there to serve it. A cold-rebuild rehearsal left
|
||||
# paperless in Init:ErrImagePull on exactly this image.
|
||||
- name: build-alpine-openssl
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
|
||||
directory: ci/images/alpine-openssl
|
||||
when:
|
||||
- event: deployment
|
||||
@@ -351,7 +377,7 @@ steps:
|
||||
# Ansible converge. Not a ci-* image -- it is a runtime artifact -- but it
|
||||
# builds the same way and lands in the same registry so the audit sees it.
|
||||
- name: build-heating-can
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
|
||||
directory: ci/images/heating-can
|
||||
when:
|
||||
- event: deployment
|
||||
@@ -377,7 +403,7 @@ steps:
|
||||
# services (public Ingress hosts -> TLS passthrough -> Traefik ClusterIP).
|
||||
# Runtime artifact like heating-can; same registry so the audit tracks it.
|
||||
- name: build-netbird-service-sync
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
|
||||
directory: ci/images/netbird-service-sync
|
||||
when:
|
||||
- event: deployment
|
||||
@@ -402,7 +428,7 @@ steps:
|
||||
# authentik-gate-sync: the authentik-gate ingress class (Ingress -> Authentik
|
||||
# proxy gate + Traefik route). Its unit tests run as a stage of the image build.
|
||||
- name: build-authentik-gate-sync
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
|
||||
directory: ci/images/authentik-gate-sync
|
||||
when:
|
||||
- event: deployment
|
||||
@@ -428,7 +454,7 @@ steps:
|
||||
# kuma-sync: reconciles KumaMonitor resources into Uptime Kuma. Its unit
|
||||
# tests run as a stage of the image build.
|
||||
- name: build-kuma-sync
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
|
||||
directory: ci/images/kuma-sync
|
||||
when:
|
||||
- event: deployment
|
||||
@@ -454,7 +480,7 @@ steps:
|
||||
# cloudflare-lb-sync: reconciles CloudflareLoadBalancer resources into
|
||||
# Cloudflare. Its unit tests run as a stage of the image build.
|
||||
- name: build-cloudflare-lb-sync
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
|
||||
directory: ci/images/cloudflare-lb-sync
|
||||
when:
|
||||
- event: deployment
|
||||
@@ -480,7 +506,7 @@ steps:
|
||||
# bird: BGP daemon for the pod-network mesh over pod0 (#576). Runtime
|
||||
# artifact like heating-can; same registry so the audit tracks it.
|
||||
- name: build-bird
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
|
||||
directory: ci/images/bird
|
||||
when:
|
||||
- event: deployment
|
||||
@@ -504,7 +530,7 @@ steps:
|
||||
|
||||
# Orchestrator for the monthly backup-verify CronJob (velero ns).
|
||||
- name: build-backup-verify
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
|
||||
directory: ci/images/backup-verify
|
||||
when:
|
||||
- event: deployment
|
||||
|
||||
@@ -131,7 +131,7 @@ steps:
|
||||
# --build-arg to the resolved version (not "latest") guarantees the tag and
|
||||
# the image content agree even if a new release lands mid-run.
|
||||
- name: build
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
|
||||
directory: ci/images/claude
|
||||
depends_on: [decide]
|
||||
environment:
|
||||
|
||||
@@ -172,7 +172,7 @@ steps:
|
||||
# whenever it trails the deployed server. If you bump the server, bump here.
|
||||
- name: woodpecker-lint
|
||||
depends_on: [] # see note above `steps:`
|
||||
image: woodpeckerci/woodpecker-cli:v3.19.0-alpine@sha256:27a77117e38d7bbdc380f0e90ff3ee4871ab1eefa78b5d9136a2c969fbecd9f4
|
||||
image: woodpeckerci/woodpecker-cli:v3.20.1-alpine@sha256:74c576faf49cce699825d11c7d7bd38ab5f007dd4f57cdbdd55099d2c0f2621a
|
||||
commands:
|
||||
- |
|
||||
set -eu
|
||||
|
||||
@@ -35,25 +35,25 @@ when:
|
||||
steps:
|
||||
- name: model-selection-tests
|
||||
depends_on: []
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.318
|
||||
commands:
|
||||
- sh scripts/ci/test-model-selection.sh
|
||||
|
||||
- name: pr-review-mistral-tests
|
||||
depends_on: []
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.318
|
||||
commands:
|
||||
- sh scripts/ci/test-pr-review-mistral.sh
|
||||
|
||||
- name: promtool-rules-guard-tests
|
||||
depends_on: []
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.318
|
||||
commands:
|
||||
- sh scripts/ci/test-promtool-rules.sh
|
||||
|
||||
- name: yaml-citations-guard-tests
|
||||
depends_on: []
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.318
|
||||
commands:
|
||||
- sh scripts/ci/test-yaml-citations.sh
|
||||
|
||||
@@ -63,7 +63,7 @@ steps:
|
||||
# bridge, and the secret guard.
|
||||
- name: hermes-skill-tests
|
||||
depends_on: []
|
||||
image: python:3.15-alpine@sha256:d44b1254cb92329418d164b22b20d86bbd3869d48e80e09e476531a409a728bb
|
||||
image: python:3.15-alpine@sha256:20a82dbc2783112ddb91d1534603f802d222a3e63e593cd002eb2ac534e70211
|
||||
environment:
|
||||
PYTHONDONTWRITEBYTECODE: "1"
|
||||
commands:
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ steps:
|
||||
# fetch-models.sh response mapping. No cluster, no secrets, no network — the
|
||||
# ci-claude image just provides sh + jq. Keeps the flag-injection regression
|
||||
# test from rotting (it exercises the exact injection shapes).
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.318
|
||||
commands:
|
||||
- sh scripts/ci/test-model-selection.sh
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ when:
|
||||
|
||||
steps:
|
||||
- name: build
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
|
||||
directory: ci/images/hermes
|
||||
environment:
|
||||
# registry-bot write:package PAT (konairius org secret).
|
||||
|
||||
@@ -6,7 +6,7 @@ when:
|
||||
|
||||
steps:
|
||||
- name: go-test
|
||||
image: golang:1.27.3-bookworm@sha256:972b4110ba0f47539a975c31e52c64765e1ff19f2d74469ae4a595c6676e80a5
|
||||
image: golang:1.27.3-bookworm@sha256:d9b6a1beb9474adc2a1ce842573639b44b2a0dd49b6669f51d3821baf9ec37c9
|
||||
commands:
|
||||
- test -z "$(gofmt -l review-bot)"
|
||||
- go -C review-bot vet ./...
|
||||
|
||||
@@ -70,7 +70,7 @@ steps:
|
||||
# this step is skipped — otherwise we'd fork-bomb PRs.
|
||||
when:
|
||||
- branch: main
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.318
|
||||
environment:
|
||||
CLAUDE_CODE_OAUTH_TOKEN:
|
||||
from_secret: claude_code_oauth_token
|
||||
|
||||
@@ -30,7 +30,7 @@ when:
|
||||
|
||||
steps:
|
||||
- name: update-audit-agent
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.318
|
||||
environment:
|
||||
CLAUDE_CODE_OAUTH_TOKEN:
|
||||
from_secret: claude_code_oauth_token
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# Tests for the wlan-setup Go service (wlan-setup/). Offline: the tests run against a fake
|
||||
# authentik server, so the step needs no secret and no network beyond the module cache.
|
||||
when:
|
||||
- event: pull_request
|
||||
path: ["wlan-setup/**", ".woodpecker/wlan-setup-test.yml"]
|
||||
|
||||
steps:
|
||||
- name: go-test
|
||||
image: golang:1.27.3-bookworm@sha256:d9b6a1beb9474adc2a1ce842573639b44b2a0dd49b6669f51d3821baf9ec37c9
|
||||
commands:
|
||||
- test -z "$(gofmt -l wlan-setup)"
|
||||
- go -C wlan-setup vet ./...
|
||||
- go -C wlan-setup test -race ./...
|
||||
+25
-91
@@ -1,13 +1,17 @@
|
||||
# edge-host — bare-metal Arch thin client (replaces the k8s-edge Talos VM)
|
||||
|
||||
Runs the hardware-bound services that used to be pinned to the `k8s-edge`
|
||||
Talos node (opentofu `vm_153_k8s_edge.tf`), plus the new CAN bridge:
|
||||
Talos node (opentofu `vm_153_k8s_edge.tf`):
|
||||
|
||||
| Service | How it runs | Hardware | Talks to |
|
||||
|---|---|---|---|
|
||||
| zwave-js-ui | docker compose | ZWA-2 Z-Wave USB radio | HA `zwave_js` via ws://:3000, UI :8091 |
|
||||
| sml2mqtt | docker compose | FTDI FT230X meter reader | Mosquitto (`sml2mqtt` user) |
|
||||
| open3e | venv + systemd | Innomaker USB2CAN (gs_usb) | Mosquitto (`open3e` user) |
|
||||
|
||||
The Viessmann E3 CAN bus is not read by an edge host: the ESP32 gateway
|
||||
`vitocal-can` (open3e-esp32) at the Vitocal publishes `open3e/#` directly to
|
||||
the cluster broker. `plays/install-edge-host.yml` removes the former
|
||||
heating-can stack and the can0 configuration from edge hosts.
|
||||
|
||||
Everything else that once lived on k8s-edge (HA core, matter-server, OTBR,
|
||||
music-assistant) already moved to the general node pool and stays in the
|
||||
@@ -19,17 +23,13 @@ The deployed unit is a **Dell Wyse 3040** (Atom x5-Z8350, 2 GB RAM) on a
|
||||
flash-wear handling that hardware implies. The installer also supports an
|
||||
N100/NVMe box; it autodetects the target disk, so either works.
|
||||
|
||||
Rationale for leaving Kubernetes for the CAN part: the stock Talos kernel
|
||||
ships with `CONFIG_CAN` unset (siderolabs/talos#7686), so SocketCAN cannot
|
||||
work in any pod without a custom kernel image per Talos release.
|
||||
|
||||
## Base install (once, from the edge-host installer ISO)
|
||||
|
||||
Build and boot the dedicated installer image — see
|
||||
`images/edge-host-iso/README.md`. Short version:
|
||||
|
||||
1. `sudo pacman -S archiso && sudo images/edge-host-iso/build.sh`
|
||||
2. Write ISO to a USB stick, boot the target box from it, run `edge-install`
|
||||
2. Write ISO to a USB stick, boot the target box from it, run `edge-install <hostname>`
|
||||
(partitions the internal disk — NVMe if present, else the eMMC; the
|
||||
installer autodetects — installs the ansible-ready base system with SSH
|
||||
keys, DHCP, docker — asks for confirmation before wiping).
|
||||
@@ -58,24 +58,18 @@ inventory change.
|
||||
|
||||
| device | group | playbook |
|
||||
|---|---|---|
|
||||
| Innomaker USB2CAN (Vitocal/VX3 + E380) | `heating_can` | `plays/install-heating-can.yml` |
|
||||
| Nabu Casa ZWA-2 Z-Wave radio | `zwave_gateway` | `plays/install-zwave-gateway.yml` |
|
||||
|
||||
Add the host to the matching group, then run that playbook. They depend on
|
||||
the base play having run: all three need the local MQTT bridge, and two of
|
||||
them need docker.
|
||||
Add the host to the matching group, then run that playbook. It depends on
|
||||
the base play having run: it needs docker.
|
||||
|
||||
A host with nothing attached is simply absent from all three groups and needs
|
||||
A host with nothing attached is simply absent from these groups and needs
|
||||
none of these commands.
|
||||
|
||||
## Secrets (before first play run)
|
||||
|
||||
Create `inventory/group_vars/edge_host/vault.yml` with ansible-vault. The
|
||||
cluster broker has no `open3e` user: edge-1 logs in only as the register
|
||||
account `edge-1` (see "MQTT bridge" below), so
|
||||
`vault_edge_open3e_mqtt_password` reaches only the loopback broker, which
|
||||
allows anonymous clients; any random value works (`openssl rand -hex 24`).
|
||||
The zwave-js value comes out of the cluster:
|
||||
zwave-js value comes out of the cluster:
|
||||
|
||||
```
|
||||
# zwave-js session secret
|
||||
@@ -84,7 +78,6 @@ kubectl -n home-assistant get secret zwave-js-session -o jsonpath='{.data.sessio
|
||||
|
||||
```yaml
|
||||
# vault.yml
|
||||
vault_edge_open3e_mqtt_password: "..."
|
||||
vault_edge_zwavejs_session_secret: "..."
|
||||
vault_edge_loki_password: "..." # dedicated edge-1 Loki push cred (see "Logging & eMMC")
|
||||
```
|
||||
@@ -102,9 +95,6 @@ The Z-Wave network keys do NOT need separate handling — they live in
|
||||
|
||||
Preparation:
|
||||
- [ ] `vault.yml` populated (above), play runs green against edge-1
|
||||
- [ ] CAN: Innomaker adapter connected to the VX3 external CAN port,
|
||||
`ip -details link show can0` shows `state ERROR-ACTIVE bitrate 250000`,
|
||||
`candump can0 -n 5` shows frames
|
||||
|
||||
zwave-js (order matters — never two servers on one radio):
|
||||
- [ ] Copy the store out of the PVC while the pod still runs:
|
||||
@@ -122,11 +112,6 @@ sml2mqtt:
|
||||
port, Wh stays Wh) → remove sml2mqtt from the home-assistant
|
||||
kustomization in the follow-up PR
|
||||
|
||||
open3e:
|
||||
- [ ] Stop any workstation listener (ONE instance per bus, hard rule)
|
||||
- [ ] Move the USB2CAN adapter, `systemctl start open3e`
|
||||
- [ ] Topics under `open3e/#` update on the broker
|
||||
|
||||
Afterwards:
|
||||
- [ ] Retire VM 153: remove `vm_153_k8s_edge.tf`, `talos/patches/k8s-edge.yaml`,
|
||||
node from the cluster (`kubectl delete node k8s-edge`, tofu apply)
|
||||
@@ -134,22 +119,13 @@ Afterwards:
|
||||
|
||||
## Day-2 notes
|
||||
|
||||
- Image bumps: edit `edge_zwavejs_image` / `edge_sml2mqtt_image` in
|
||||
defaults and re-run the play (no flux image-automation out here —
|
||||
deliberate for sml2mqtt, acceptable for zwave-js). If a zwave-js bump
|
||||
changes its Alpine major, also update `edge_zwave_alpine_base` and refresh
|
||||
the preloaded `.so` (see "Logging & eMMC" → zwave-js).
|
||||
- open3e / E3onCAN updates: both are pinned to commits in
|
||||
`roles/heating-can/defaults/main.yml`, so re-running the play does NOT pick
|
||||
up upstream changes -- that is deliberate, these drive the heat pump. The
|
||||
weekly update-audit reports when either falls behind (see
|
||||
`ci/agents/update-audit/prompt.md`); bump the pin and re-run.
|
||||
Pinning to a tag is not an option for open3e: it publishes none.
|
||||
- Ad-hoc CAN scans: `systemctl stop open3e` first (single-instance rule),
|
||||
restart afterwards.
|
||||
- can-utils (`candump`) is AUR-only on Arch — install manually via
|
||||
`pamac build can-utils` if needed for debugging; the role deliberately
|
||||
doesn't manage AUR packages.
|
||||
- Image bumps: Renovate opens a PR for `edge_*_image` in
|
||||
`roles/*/defaults/main.yml` (tag + digest, `customManagers` in
|
||||
`renovate.json`). No pipeline deploys to edge-1, so after the merge run
|
||||
`ansible-playbook plays/install-zwave-gateway.yml` from `ansible/` on a
|
||||
mesh-connected workstation. The play reads the image's Alpine release
|
||||
itself and rebuilds the preloaded `.so` when it changes (see
|
||||
"Logging & eMMC" → zwave-js).
|
||||
|
||||
## Logging & eMMC
|
||||
|
||||
@@ -162,7 +138,9 @@ role keeps logs off local flash and monitors wear:
|
||||
- **Docker logs to journald** (`docker-daemon.json`, `log-driver: journald`)
|
||||
instead of unbounded json files under `/var/lib/docker/containers`. Both
|
||||
compose services inherit it; `docker logs` still works.
|
||||
- **Grafana Alloy ships the journal to Loki** (`edge-alloy.service`). Its
|
||||
- **Grafana Alloy ships the journal to Loki** (`edge-alloy.service`, which
|
||||
runs the upstream `grafana/alloy` image with `/run/log/journal` mounted
|
||||
read-only; Arch's `grafana-alloy` package has no `loki.source.journal`). Its
|
||||
WAL/positions sit on `/run/alloy` (tmpfs), so the agent writes nothing to
|
||||
the eMMC either. Net effect: no log writes hit local flash — the trade-off
|
||||
is that logs are RAM-only and lost on reboot unless already shipped.
|
||||
@@ -190,7 +168,7 @@ journal commit).
|
||||
|
||||
- **Fix:** the `zwave-js` container preloads **`libeatmydata`**
|
||||
(`LD_PRELOAD=/usr/lib/libeatmydata.so`, mounted read-only from
|
||||
`zwave-js/libeatmydata.so`). This makes `fsync`/`fdatasync` no-ops *for that
|
||||
`zwave-js/libeatmydata-alpine<X.Y>.so`). This makes `fsync`/`fdatasync` no-ops *for that
|
||||
one process*, so the kernel batches the writes via normal writeback (~30 s)
|
||||
instead of forcing each one. The WS/MQTT value stream to Home Assistant and
|
||||
the on-disk cache are **unchanged** — full data resolution, no re-interview
|
||||
@@ -199,9 +177,9 @@ journal commit).
|
||||
batch it); the only mount-level alternative is putting the cache on tmpfs,
|
||||
which makes the *whole* store volatile → re-interview on every restart.
|
||||
- **The `.so` is musl** (the image is Alpine-based), extracted from
|
||||
`edge_zwave_alpine_base` — keep that var in sync with the image's Alpine
|
||||
version; to refresh after a major bump, `rm zwave-js/libeatmydata.so` and
|
||||
re-run.
|
||||
`alpine:<X.Y>`, where `X.Y` is read from the image's `/etc/alpine-release`
|
||||
on every run. A new Alpine release gives a new file name, so the play
|
||||
extracts it, recreates the container and deletes the old file.
|
||||
- **Durability guard:** because `fsync` is neutralized, a hard power-loss
|
||||
during the (rare) settings write could truncate `settings.json`, which holds
|
||||
the **Z-Wave network keys**. `edge-zwave-settings-backup.timer` keeps a
|
||||
@@ -211,47 +189,3 @@ journal commit).
|
||||
- Verify the drop with the eMMC-write sampling described above; roll back by
|
||||
removing the `LD_PRELOAD` env + `.so` mount from the compose file.
|
||||
|
||||
## MQTT bridge (TLS to the cluster broker)
|
||||
|
||||
open3e, e3oncan and sml2mqtt do **not** publish to the cluster broker directly.
|
||||
They connect to a **local mosquitto on loopback** (`127.0.0.1:1883`,
|
||||
`edge_mqtt_host`), which **bridges to the cluster broker over TLS**
|
||||
(`mqtt.renner.uno:8883`). Two reasons:
|
||||
|
||||
- **Security:** open3e and e3oncan have no native MQTTS support (their MQTT
|
||||
code does a plain `connect()`), so they can't use the broker's 8883 TLS
|
||||
listener. Terminating TLS in the local broker keeps their plaintext MQTT on
|
||||
loopback only — off the untrusted IoT VLAN — while the actual network hop is
|
||||
encrypted. The public Let's Encrypt cert is validated against the system CA
|
||||
bundle (`edge_mqtt_bridge_cafile`); no cert distribution needed.
|
||||
- **Availability:** the local broker buffers telemetry (in RAM) if the cluster
|
||||
broker is briefly unreachable, and flushes on reconnect — edge-1 no longer
|
||||
hard-depends on cluster/network uptime for every publish.
|
||||
|
||||
The local broker is `allow_anonymous` on loopback — the trust boundary is the
|
||||
host; nothing off-host can reach it.
|
||||
|
||||
**Bridge credential (REN-1307).** One bridge `connection` logs in as the
|
||||
register account `edge-1` (`kubernetes/apps/home-assistant/mqtt-accounts.yaml`)
|
||||
and carries `open3e/#` out and `open3e/cmnd` back. The cluster broker does not
|
||||
accept the vault-password bridges in `bridge.d/legacy.conf`, which exists only
|
||||
until the first push. Its password is pushed
|
||||
by mqtt-account-controller to `edge-mqtt-cred` (`:8443`, the controller's
|
||||
esp32 API over HTTPS). That endpoint accepts only a client certificate from
|
||||
the MQTT CA with CN `mqtt-account-controller`; edge-1 holds the public CA and
|
||||
its own server key, nothing else. On a push it writes
|
||||
`/etc/mosquitto/bridge.d/edge-1.conf`, deletes `bridge.d/legacy.conf` and
|
||||
restarts the local mosquitto. The controller sets a new password whenever the
|
||||
bridge reports "not connected" (also after a reinstall), so no play run is
|
||||
needed for a rotation. Diagnosis: `journalctl -u
|
||||
edge-mqtt-cred`, and `mosquitto_sub -t '$SYS/broker/connection/edge-1/state'`
|
||||
(retained `1` = connected).
|
||||
|
||||
**eMMC:** the local broker writes **nothing** to flash — `persistence false`
|
||||
(retained/queued messages in RAM) and `log_dest stdout` (to the volatile
|
||||
journal → Loki). Do not add persistence or log-file settings to
|
||||
`edge-mqtt-bridge.conf.j2`.
|
||||
|
||||
Rollback: point `edge_mqtt_host` back to `mqtt.renner.uno` and re-run (clients
|
||||
reconnect directly on 1883). Config: `edge-mqtt-bridge.conf.j2` →
|
||||
`/etc/mosquitto/mosquitto.conf`.
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
# Secret indirection — real values live in vault.yml (ansible-vault) next
|
||||
# to this file; see docs/edge-host.md for how to populate it from
|
||||
# the cluster secrets.
|
||||
edge_open3e_mqtt_password: "{{ vault_edge_open3e_mqtt_password }}"
|
||||
edge_zwavejs_session_secret: "{{ vault_edge_zwavejs_session_secret }}"
|
||||
# Loki push credential (dedicated 'edge-1' user, Traefik basic-auth on the
|
||||
# loki.renner.uno ingress; the matching htpasswd line lives in the cluster
|
||||
@@ -36,25 +35,8 @@ keepalived_ntp_sysctl:
|
||||
# Realtek NIC on the Wyse 3040 has no hardware timestamping.
|
||||
chrony_hwtimestamp: false
|
||||
|
||||
# --- Local MQTT contract -------------------------------------------------
|
||||
# Shared between the broker and its clients, so it belongs to the inventory
|
||||
# rather than to either side's role. mqtt-bridge listens here; heating-can
|
||||
# publishes here; and the bridge authenticates to the cluster
|
||||
# broker AS the open3e user, so it needs that identity too.
|
||||
#
|
||||
# Keeping these in one role's defaults only worked while a single play loaded
|
||||
# every role at once. Splitting the plays by capability exposed it: the bridge
|
||||
# could not render without a heating-can default, and the CAN services could
|
||||
# not render without an mqtt-bridge default.
|
||||
edge_mqtt_host: 127.0.0.1
|
||||
edge_mqtt_port: 1883
|
||||
edge_open3e_mqtt_user: open3e
|
||||
edge_open3e_mqtt_topic: open3e
|
||||
|
||||
# Units claude-bot may restart (ssh-ca role); the containers go through docker.
|
||||
ssh_ca_claude_sudo_units:
|
||||
- mosquitto
|
||||
- edge-mqtt-cred
|
||||
- edge-alloy
|
||||
- keepalived
|
||||
- chronyd
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -18,10 +18,11 @@ proxy-1.renner.uno ansible_host=proxy-1.nb.renner.uno ansible_user=root
|
||||
proxy-2.renner.uno ansible_host=proxy-2.nb.renner.uno ansible_user=root
|
||||
|
||||
# Every bare-metal edge thin client. Gets the host-generic roles only:
|
||||
# flash-wear handling, the log shipper, the MQTT bridge, the VLAN
|
||||
# sub-interface. Nothing here assumes any peripheral is attached.
|
||||
# flash-wear handling, the log shipper, the VLAN sub-interface. Nothing here
|
||||
# assumes any peripheral is attached.
|
||||
[edge_host]
|
||||
edge-1.iot.renner.uno ansible_user=root
|
||||
edge-2.iot.renner.uno ansible_user=root
|
||||
|
||||
# Synology NAS (DSM): nas-1 RS1221+ on site, nas-2 DS1522+ off site.
|
||||
[synology_nas]
|
||||
@@ -36,32 +37,18 @@ nas-2.renner.uno ansible_user=root
|
||||
# These are children of [edge_host], declared below: a peripheral is added to an
|
||||
# edge client, it does not replace the base. Making that a :children relation
|
||||
# rather than a comment means a host added only to a capability group still
|
||||
# resolves group_vars/edge_host -- where the local MQTT contract lives -- so it
|
||||
# cannot silently come up without edge_mqtt_host and friends.
|
||||
# resolves group_vars/edge_host.
|
||||
[zwave_gateway]
|
||||
edge-1.iot.renner.uno
|
||||
|
||||
[heating_can]
|
||||
edge-1.iot.renner.uno
|
||||
|
||||
[edge_host:children]
|
||||
zwave_gateway
|
||||
heating_can
|
||||
|
||||
# NTP servers. edge-1 was added here so the role would survive the PVE hosts
|
||||
# being retired for bare-metal Talos, which cannot run chrony or keepalived
|
||||
# itself. That retirement has now happened, so edge-1 is the ONLY member.
|
||||
#
|
||||
# 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
|
||||
# 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.
|
||||
#
|
||||
# Restoring redundancy needs a second chrony host that is not a Talos node.
|
||||
# See plays/install-ntp-ha.yml for why keepalived is scoped more narrowly than
|
||||
# chrony within this group.
|
||||
# NTP servers: every edge host (edge-1 BACKUP prio 70, edge-2 BACKUP prio 60;
|
||||
# see the ntp_servers map in plays/install-ntp-ha.yml). Talos nodes cannot run
|
||||
# chrony or keepalived. The jupiter/saturn/neptune entries in that map
|
||||
# describe retired PVE hosts. See plays/install-ntp-ha.yml for why keepalived
|
||||
# is scoped more narrowly than chrony within this group.
|
||||
[ntp_cluster:children]
|
||||
edge_host
|
||||
|
||||
@@ -73,6 +60,7 @@ proxy-1.renner.uno
|
||||
proxy-2.renner.uno
|
||||
netbird.renner.uno
|
||||
edge-1.iot.renner.uno
|
||||
edge-2.iot.renner.uno
|
||||
nas-1.renner.uno
|
||||
nas-2.renner.uno
|
||||
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
---
|
||||
# Configure VMs to use host time sync via kvm-clock
|
||||
# Disables systemd-timesyncd to prevent NTP conflicts
|
||||
#
|
||||
# VMs get time from Proxmox host via kvm-clock paravirtualized clock
|
||||
# This is more accurate and reliable than network NTP for VMs
|
||||
|
||||
- name: Configure VM time synchronization from host
|
||||
hosts: all
|
||||
become: yes
|
||||
gather_facts: yes
|
||||
|
||||
tasks:
|
||||
- name: Check if running as VM (not LXC)
|
||||
stat:
|
||||
path: /sys/devices/system/clocksource/clocksource0/current_clocksource
|
||||
register: clocksource_file
|
||||
|
||||
- name: Get current clock source
|
||||
slurp:
|
||||
src: /sys/devices/system/clocksource/clocksource0/current_clocksource
|
||||
register: clocksource
|
||||
when: clocksource_file.stat.exists
|
||||
|
||||
- name: Display clock source
|
||||
debug:
|
||||
msg: "Clock source: {{ (clocksource.content | b64decode | trim) if clocksource_file.stat.exists else 'N/A (LXC)' }}"
|
||||
|
||||
- name: Check if systemd-timesyncd is installed
|
||||
command: systemctl list-unit-files systemd-timesyncd.service
|
||||
register: timesyncd_installed
|
||||
check_mode: false
|
||||
failed_when: false
|
||||
changed_when: false
|
||||
|
||||
- name: Stop and disable systemd-timesyncd on VMs using kvm-clock
|
||||
systemd:
|
||||
name: systemd-timesyncd
|
||||
state: stopped
|
||||
enabled: no
|
||||
when:
|
||||
- clocksource_file.stat.exists
|
||||
- clocksource.content is defined
|
||||
- "'kvm-clock' in (clocksource.content | b64decode)"
|
||||
- "'systemd-timesyncd.service' in timesyncd_installed.stdout"
|
||||
|
||||
- name: Mask systemd-timesyncd to prevent re-enabling
|
||||
systemd:
|
||||
name: systemd-timesyncd
|
||||
masked: yes
|
||||
when:
|
||||
- clocksource_file.stat.exists
|
||||
- clocksource.content is defined
|
||||
- "'kvm-clock' in (clocksource.content | b64decode)"
|
||||
- "'systemd-timesyncd.service' in timesyncd_installed.stdout"
|
||||
|
||||
- name: Verify time sync status
|
||||
command: timedatectl show --property=NTPSynchronized
|
||||
register: timesync_status
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: Display final status
|
||||
debug:
|
||||
msg: |
|
||||
Host: {{ inventory_hostname }}
|
||||
Type: {{ 'VM (kvm-clock)' if (clocksource_file.stat.exists and 'kvm-clock' in (clocksource.content | b64decode | default(''))) else 'LXC/Other' }}
|
||||
Time sync: {{ timesync_status.stdout | default('unknown') }}
|
||||
Note: VMs sync time from Proxmox host, LXCs inherit host time automatically
|
||||
@@ -8,7 +8,6 @@
|
||||
# their own inventory groups:
|
||||
#
|
||||
# plays/install-zwave-gateway.yml [zwave_gateway] ZWA-2 radio
|
||||
# plays/install-heating-can.yml [heating_can] Innomaker USB2CAN
|
||||
#
|
||||
# Splitting the roles without splitting the plays would have kept the coupling:
|
||||
# one play applying every role to one group is the monolith again, a layer up.
|
||||
@@ -31,11 +30,96 @@
|
||||
# do not silently depend on the connection user staying privileged.
|
||||
become: true
|
||||
|
||||
# The Viessmann E3 CAN bus is read by vitocal-can (open3e-esp32 at the
|
||||
# Vitocal), which publishes to the cluster broker itself, not by an edge
|
||||
# host. These remove the former heating-can stack, the can0 configuration
|
||||
# and the open3e MQTT bridge (loopback mosquitto + edge-mqtt-cred); each is
|
||||
# a no-op where they are absent.
|
||||
pre_tasks:
|
||||
- name: Read the service list
|
||||
ansible.builtin.service_facts:
|
||||
|
||||
- name: Stop and disable the retired MQTT bridge services
|
||||
ansible.builtin.systemd:
|
||||
name: "{{ item }}"
|
||||
state: stopped
|
||||
enabled: false
|
||||
loop:
|
||||
- edge-mqtt-cred
|
||||
- mosquitto
|
||||
when: (item ~ '.service') in ansible_facts.services
|
||||
|
||||
- name: Remove the retired MQTT bridge files
|
||||
ansible.builtin.file:
|
||||
path: "{{ item }}"
|
||||
state: absent
|
||||
loop:
|
||||
- /etc/systemd/system/edge-mqtt-cred.service
|
||||
- /usr/local/sbin/edge-mqtt-cred
|
||||
- /etc/edge-mqtt-cred
|
||||
- /etc/mosquitto
|
||||
register: edge_mqtt_bridge_files
|
||||
|
||||
- name: Reload systemd without the edge-mqtt-cred unit
|
||||
ansible.builtin.systemd:
|
||||
daemon_reload: true
|
||||
when: edge_mqtt_bridge_files.changed
|
||||
|
||||
- name: Uninstall mosquitto
|
||||
ansible.builtin.package:
|
||||
name: mosquitto
|
||||
state: absent
|
||||
|
||||
- name: Check for the retired heating-can compose project
|
||||
ansible.builtin.stat:
|
||||
path: /opt/edge-host/heating-can/docker-compose.yml
|
||||
register: edge_heating_can_compose
|
||||
|
||||
- name: Stop and remove the retired heating-can containers and image
|
||||
ansible.builtin.command:
|
||||
cmd: docker compose down --remove-orphans --rmi all
|
||||
chdir: /opt/edge-host/heating-can
|
||||
when: edge_heating_can_compose.stat.exists
|
||||
changed_when: true
|
||||
|
||||
- name: Remove the retired heating-can directory
|
||||
ansible.builtin.file:
|
||||
path: /opt/edge-host/heating-can
|
||||
state: absent
|
||||
|
||||
- name: Remove the can0 networkd configuration
|
||||
ansible.builtin.file:
|
||||
path: /etc/systemd/network/80-can0.network
|
||||
state: absent
|
||||
register: edge_can0_network
|
||||
|
||||
- name: Reload networkd without the can0 configuration
|
||||
ansible.builtin.command:
|
||||
cmd: networkctl reload
|
||||
when: edge_can0_network.changed
|
||||
changed_when: true
|
||||
|
||||
- name: Read the can0 link flags
|
||||
ansible.builtin.command:
|
||||
cmd: ip -o link show can0
|
||||
register: edge_can0_link
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: Take can0 down
|
||||
ansible.builtin.command:
|
||||
cmd: ip link set can0 down
|
||||
when:
|
||||
- edge_can0_link.rc == 0
|
||||
- edge_can0_link.stdout is search('[<,]UP[,>]')
|
||||
changed_when: true
|
||||
|
||||
roles:
|
||||
- certificates
|
||||
- common
|
||||
- pacman-cache
|
||||
- edge-flash-wear
|
||||
- docker-install
|
||||
- edge-log-shipper
|
||||
- mqtt-bridge
|
||||
- edge-vlan
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
---
|
||||
# Heating system over the Viessmann E3 CAN bus, for hosts with the Innomaker
|
||||
# USB2CAN adapter attached.
|
||||
#
|
||||
# Targets [heating_can]: membership means the adapter is physically present.
|
||||
# Two roles because they are two concerns -- the interface is host network
|
||||
# state, the services consume it -- but one group, because both need the same
|
||||
# hardware.
|
||||
#
|
||||
# Requires the base play (plays/install-edge-host.yml) to have run: these
|
||||
# services publish to the local MQTT bridge it sets up.
|
||||
- name: Install heating CAN services
|
||||
hosts: heating_can
|
||||
become: true
|
||||
|
||||
roles:
|
||||
- socketcan
|
||||
- heating-can
|
||||
@@ -22,6 +22,7 @@
|
||||
become: true
|
||||
roles:
|
||||
- common
|
||||
- timesyncd
|
||||
- docker-install
|
||||
- netbird-proxy
|
||||
- netbird-proxy-watchdog
|
||||
|
||||
@@ -16,5 +16,6 @@
|
||||
become: true
|
||||
roles:
|
||||
- common
|
||||
- timesyncd
|
||||
- docker-install
|
||||
- netbird-server
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -83,6 +83,9 @@
|
||||
edge-1.iot.renner.uno:
|
||||
priority: 70
|
||||
state: "BACKUP"
|
||||
edge-2.iot.renner.uno:
|
||||
priority: 60
|
||||
state: "BACKUP"
|
||||
|
||||
roles:
|
||||
- role: chrony-ntp-server
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -54,18 +54,9 @@
|
||||
# via plays/configure-ssh-ca.yml.
|
||||
|
||||
# -------------------------------------------------------
|
||||
# 5. Disable NTP on VMs/LXC (use host time)
|
||||
# 5. Time sync
|
||||
# -------------------------------------------------------
|
||||
- name: Stop and disable systemd-timesyncd on VMs/LXC
|
||||
systemd:
|
||||
name: systemd-timesyncd
|
||||
state: stopped
|
||||
enabled: no
|
||||
masked: yes
|
||||
when:
|
||||
- ansible_facts['virtualization_role'] == 'guest'
|
||||
- ansible_facts['virtualization_type'] in ['kvm', 'lxc']
|
||||
ignore_errors: yes
|
||||
# Moved to separate roles: timesyncd (internet VPS), chrony-ntp-server (edge).
|
||||
|
||||
# -------------------------------------------------------
|
||||
# 6. Certificates
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
|
||||
edge_alloy_config_file: /etc/alloy/edge.alloy
|
||||
edge_alloy_http_port: 12345
|
||||
edge_alloy_bin: /usr/bin/grafana-alloy # Arch `grafana-alloy` pkg names the binary this, not `alloy`
|
||||
# Upstream image: Arch's grafana-alloy 1.21 is built without
|
||||
# loki.source.journal. Renovate-tracked (customManagers in renovate.json).
|
||||
edge_alloy_image: grafana/alloy:v1.21.3@sha256:8ab871206bd2d8f89277f53abffee5f452a293980788d52f85fa183a87a505a4
|
||||
edge_loki_push_url: "https://loki.renner.uno/loki/api/v1/push"
|
||||
edge_loki_username: edge-1
|
||||
edge_alloy_labels:
|
||||
|
||||
@@ -6,15 +6,18 @@
|
||||
# directly, so one journal source covers everything. Alloy s storage/WAL sits
|
||||
# on /run (tmpfs) so it does not write to flash either.
|
||||
|
||||
- name: Install grafana-alloy
|
||||
# Alloy runs from the upstream image (see edge-alloy.service); the Arch package
|
||||
# lacks journal support.
|
||||
- name: Remove the grafana-alloy package
|
||||
ansible.builtin.package:
|
||||
name: grafana-alloy
|
||||
state: present
|
||||
state: absent
|
||||
|
||||
- name: Create alloy system group
|
||||
ansible.builtin.group:
|
||||
name: alloy
|
||||
system: true
|
||||
register: edge_alloy_group
|
||||
|
||||
- name: Create alloy system user
|
||||
ansible.builtin.user:
|
||||
@@ -24,6 +27,25 @@
|
||||
system: true
|
||||
create_home: false
|
||||
shell: /usr/bin/nologin
|
||||
register: edge_alloy_user
|
||||
|
||||
- name: Look up the systemd-journal group
|
||||
ansible.builtin.getent:
|
||||
database: group
|
||||
key: systemd-journal
|
||||
|
||||
- name: Set the container identity
|
||||
ansible.builtin.set_fact:
|
||||
edge_alloy_uid: "{{ edge_alloy_user.uid }}"
|
||||
edge_alloy_gid: "{{ edge_alloy_group.gid }}"
|
||||
edge_alloy_journal_gid: "{{ ansible_facts.getent_group['systemd-journal'][1] }}"
|
||||
|
||||
- name: Pull the alloy image
|
||||
ansible.builtin.command:
|
||||
argv: [docker, pull, "{{ edge_alloy_image }}"]
|
||||
register: edge_alloy_pull
|
||||
changed_when: "'Downloaded newer image' in edge_alloy_pull.stdout"
|
||||
notify: Restart alloy
|
||||
|
||||
- name: Create alloy config directory
|
||||
ansible.builtin.file:
|
||||
|
||||
@@ -1,32 +1,35 @@
|
||||
[Unit]
|
||||
Description=Grafana Alloy (edge-1 log shipper -> Loki)
|
||||
Description=Grafana Alloy (journal -> Loki)
|
||||
Documentation=https://grafana.com/docs/alloy/
|
||||
After=network-online.target
|
||||
After=docker.service network-online.target
|
||||
Requires=docker.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=alloy
|
||||
Group=alloy
|
||||
# Read the (volatile) systemd journal.
|
||||
SupplementaryGroups=systemd-journal
|
||||
# WAL + positions on tmpfs so the agent never writes to the eMMC. %t is the
|
||||
# runtime dir root (/run); RuntimeDirectory creates /run/alloy owned by us.
|
||||
RuntimeDirectory=alloy
|
||||
ExecStart={{ edge_alloy_bin }} run \
|
||||
# WAL + positions on tmpfs so the agent never writes to the eMMC.
|
||||
ExecStartPre=/usr/bin/install -d -o alloy -g alloy -m 0750 /run/alloy
|
||||
ExecStartPre=-/usr/bin/docker rm -f edge-alloy
|
||||
# Attached run with --log-driver none: the container's output reaches the
|
||||
# journal once, through this unit.
|
||||
ExecStart=/usr/bin/docker run --rm --name edge-alloy \
|
||||
--log-driver none \
|
||||
--network host \
|
||||
--user {{ edge_alloy_uid }}:{{ edge_alloy_gid }} \
|
||||
--group-add {{ edge_alloy_journal_gid }} \
|
||||
--read-only --tmpfs /tmp \
|
||||
--cap-drop ALL --security-opt no-new-privileges \
|
||||
-v /run/log/journal:/run/log/journal:ro \
|
||||
-v /etc/machine-id:/etc/machine-id:ro \
|
||||
-v {{ edge_alloy_config_file }}:/etc/alloy/config.alloy:ro \
|
||||
-v /run/alloy:/var/lib/alloy \
|
||||
{{ edge_alloy_image }} run \
|
||||
--server.http.listen-addr=127.0.0.1:{{ edge_alloy_http_port }} \
|
||||
--storage.path=%t/alloy \
|
||||
{{ edge_alloy_config_file }}
|
||||
ExecReload=/bin/kill -HUP $MAINPID
|
||||
Restart=on-failure
|
||||
--storage.path=/var/lib/alloy \
|
||||
/etc/alloy/config.alloy
|
||||
ExecStop=/usr/bin/docker stop edge-alloy
|
||||
Restart=always
|
||||
RestartSec=10s
|
||||
LimitNOFILE=65536
|
||||
|
||||
# Hardening (bare metal, no LXC namespace limits here).
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
---
|
||||
# Heating system over the Viessmann E3 CAN bus: open3e (heat pump) and
|
||||
# e3oncan (E380 energy meter). The can0 interface itself is the socketcan
|
||||
# role.
|
||||
|
||||
# Service root. Kept per-role so the role is usable on its own.
|
||||
edge_base_dir: /opt/edge-host
|
||||
|
||||
edge_open3e_mqtt_user: open3e
|
||||
edge_open3e_mqtt_topic: open3e
|
||||
edge_open3e_mqtt_format: "{device}_{didNumber:04d}_{didName}"
|
||||
edge_open3e_poll_seconds: 30
|
||||
# Read-only datapoint set (Phase 1 extended set, established 2026-07-02 on
|
||||
# the commissioning workstation; see vican repo CLAUDE.md for the rationale).
|
||||
# NO -w / write DIDs here — live heating + battery, writes are Phase 3.
|
||||
# vx3.266 = ErrorDtcHistory (O3EList) — the VX3 fault log; polled to catch
|
||||
# recurrences of FailureCommunicationEnergyMeter (the E380 energy-meter link
|
||||
# the internal PV-surplus EMS depends on). Decodes via the master O3EList
|
||||
# codec inherited through the vx3plus overlay.
|
||||
# vitocal.543/2350/2543/2560: SmartGrid-/EMS-Status-Beobachtung (2026-07-06)
|
||||
# — klärt, ob die Eigenstromoptimierung (EMS) bei PV-Überschuss von selbst
|
||||
# den WW-Boost auslöst (2543.2 steht laut Scan bereits auf +10 K), bevor
|
||||
# SG-Ready-Verdrahtung oder andere Trigger gebaut werden. Alle vier ro.
|
||||
# vitocal.259/260 = InfoDtcList/InfoDtcHistory (O3EList) — the HPMU event log,
|
||||
# the Vitocal-side counterpart to vx3.266. Both are capped lists (2 and 9
|
||||
# entries when added), so the multi-frame read is cheap at a 30 s poll.
|
||||
# Records faults/info events only (NormalOperation, LagDeviceDtcReported,
|
||||
# LegionellaProtectionTemperatureReached) — NOT setpoint or program changes.
|
||||
# onebase.*: the 29 paired ZigBee slots on the One Base gateway (ECU 0x6c3) --
|
||||
# 8 radiator valves, 9 climate sensors, 9 underfloor actuators, 3 floor
|
||||
# thermostats. Per-device room temperature, active setpoint, humidity, valve
|
||||
# opening/demand, battery and RSSI; the local replacement for the ViCare cloud
|
||||
# room sensors. Needs the onebase overlay (files/open3e-onebase.py) -- the stock
|
||||
# codec declares 57 bytes and a paired slot answers with 68.
|
||||
# Only CurrentValues are polled. The room and device registries (1884.., 2084..)
|
||||
# are static configuration and are read on demand, not every 30 s.
|
||||
edge_open3e_datapoints: >-
|
||||
vitocal.259,vitocal.260,vitocal.268,vitocal.269,vitocal.271,vitocal.274,vitocal.286,vitocal.288,vitocal.318,vitocal.321,vitocal.324,vitocal.335,vitocal.336,vitocal.396,vitocal.402,vitocal.403,vitocal.476,vitocal.477,vitocal.543,vitocal.548,vitocal.565,vitocal.1007,vitocal.1043,vitocal.1190,vitocal.1211,vitocal.1391,vitocal.1416,vitocal.1417,vitocal.2320,vitocal.2346,vitocal.2350,vitocal.2351,vitocal.2352,vitocal.2369,vitocal.2370,vitocal.2486,vitocal.2487,vitocal.2488,vitocal.2543,vitocal.2560,vitocal.2622,vitocal.2624,vitocal.2625,vitocal.2735,vitocal.3016,vx3.266,vx3.378,vx3.379,vx3.380,vx3.535,vx3.1603,vx3.1664,vx3.1690,vx3.1801,vx3.1802,vx3.1810,vx3.1828,vx3.1830,vx3.1577,vx3.1578,vx3.1579,vx3.1580,vx3.1581,vx3.1582,vx3.1834,vx3.1836,vx3.2214,vx3.2240,vx3.3085,vx3.3086,vx3.3087,vx3.3088,vx3.3089,vx3.3090,vcmu.320,vcmu.322,vcmu.325,vcmu.355,vcmu.389,vcmu.391,vcmu.1772,vcmu.1773,vcmu.1775,vcmu.1776,vcmu.2256,vcmu.2259,vcmu.2333,vcmu.2334,vcmu.2569,vcmu.2571,vitocal.2791,onebase.2086,onebase.2089,onebase.2092,onebase.2095,onebase.2098,onebase.2101,onebase.2104,onebase.2107,onebase.2110,onebase.2113,onebase.2116,onebase.2119,onebase.2122,onebase.2125,onebase.2128,onebase.2131,onebase.2134,onebase.2137,onebase.2140,onebase.2143,onebase.2262,onebase.2265,onebase.2268,onebase.2271,onebase.2274,onebase.2277,onebase.2280,onebase.2283,onebase.2286
|
||||
# ECU addresses; -dev selects codec set, tx selects the UDS address.
|
||||
# vcmu (refrigerant-circuit controller) has no dedicated codec set —
|
||||
# omitting dpList makes open3e fall back to the generic datapoint list,
|
||||
# which decodes all polled VCMU DIDs.
|
||||
edge_open3e_devices:
|
||||
vitocal: { tx: "0x680", dpList: "vcal" }
|
||||
# dpList als Pfad: Overlay-Datei mit den Batteriemodul-DIDs (siehe
|
||||
# files/open3e-vx3plus.py) — die Stock-vx3-Liste filtert sie sonst weg.
|
||||
vx3: { tx: "0x6a1", dpList: "/config/vx3plus.py" }
|
||||
vcmu: { tx: "0x68c" }
|
||||
# One Base ZigBee gateway. Overlay re-declares the paired ZigBee slots at
|
||||
# their real 68-byte length (see files/open3e-onebase.py).
|
||||
onebase: { tx: "0x6c3", dpList: "/config/onebase.py" }
|
||||
|
||||
edge_e3oncan_gap_seconds: 10
|
||||
edge_e3oncan_mqtt_format: "{device}_{didNumber:04d}_{didName}"
|
||||
|
||||
# Secrets come from inventory/group_vars/edge_host/vault.yml:
|
||||
# edge_open3e_mqtt_password
|
||||
|
||||
# The image carrying both readers, built and pinned in CI
|
||||
# (ci/images/heating-can/Containerfile). The source pins live in the
|
||||
# Containerfile, in the artifact -- not here -- so what is deployed is a tag,
|
||||
# and `docker compose up -d` on an unchanged tag is a genuine no-op.
|
||||
#
|
||||
# Tracked by the weekly update-audit as an ordinary image, which is why this is
|
||||
# simpler to keep current than the two git refs it replaced: one publishes no
|
||||
# tags at all, the other was pinned to an untagged commit between releases.
|
||||
heating_can_image: gitea.renner.uno/gitea_admin/heating-can:latest
|
||||
|
||||
# Compose project directory. Separate from the old native layout under
|
||||
# {{ edge_base_dir }}/open3e, which this role removes.
|
||||
heating_can_base_dir: "{{ edge_base_dir }}/heating-can"
|
||||
@@ -1,54 +0,0 @@
|
||||
# Managed by ansible (roles/heating-can). Datapoint overlay for the One Base
|
||||
# ZigBee gateway on ECU 0x6c3 (BACKENDGATEWAY).
|
||||
#
|
||||
# Upstream declares ZigBee<N>DeviceCurrentValues as 57 bytes. A PAIRED device
|
||||
# slot on this gateway answers with 68: the 57 known bytes plus the parent
|
||||
# device serial and three trailing bytes. udsoncan reads only the declared
|
||||
# length and then treats the next two bytes -- the first half of that serial --
|
||||
# as a foreign data identifier, so every paired slot fails to decode with
|
||||
# "Server returned values for data identifier 0x.... that was not requested".
|
||||
# The bogus identifier differs per device because the serial does.
|
||||
#
|
||||
# This overlay re-declares the 29 paired slots at 68 bytes, reusing upstream's
|
||||
# sub-type list so the per-device-type O3ESwitch (climate sensor / TRV / floor
|
||||
# thermostat / actuator, incl. DeviceTemperatureSetpoint) is inherited rather
|
||||
# than copied.
|
||||
#
|
||||
# EMPTY slots answer with 57 and decode against the stock codec, so they are
|
||||
# left alone and are not polled. A slot that is later unpaired therefore starts
|
||||
# reporting a decode error on its topic instead of a value.
|
||||
#
|
||||
# Verified on ECU 0x6c3, 2026-09-17: 29 paired slots, all 68 bytes.
|
||||
from open3e.Open3Ecodecs import O3EComplexType, RawCodec
|
||||
from open3e.Open3Edatapoints import dataIdentifiers as _master
|
||||
|
||||
# ZigBee<N>DeviceCurrentValues of the paired slots: devices 1-20 run 2086..2143
|
||||
# in steps of 3, devices 21-29 continue at 2262..2286.
|
||||
_PAIRED_CURRENT_VALUES = [2086 + 3 * n for n in range(20)] + [
|
||||
2262 + 3 * n for n in range(9)
|
||||
]
|
||||
|
||||
|
||||
def _with_parent_serial(did):
|
||||
stock = _master["dids"][did]
|
||||
return O3EComplexType(
|
||||
68,
|
||||
stock.id,
|
||||
list(stock.subTypes)
|
||||
+ [
|
||||
RawCodec(8, "ParentDeviceSerialnumber"),
|
||||
RawCodec(2, "Unknown_65_66"),
|
||||
RawCodec(1, "Unknown_67"),
|
||||
],
|
||||
desc=stock.desc,
|
||||
info=stock.info,
|
||||
acc=stock.acc,
|
||||
)
|
||||
|
||||
|
||||
dataIdentifiers = {
|
||||
"dids": {
|
||||
**_master["dids"],
|
||||
**{did: _with_parent_serial(did) for did in _PAIRED_CURRENT_VALUES},
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
# Managed by ansible (roles/heating-can). Datapoint overlay for the VX3:
|
||||
# the stock vx3 list acts as a FILTER (DIDs missing from it are popped
|
||||
# from the master table, Open3Eclass.py), so the per-battery-module DIDs
|
||||
# were never pollable with dpList "vx3". This overlay re-adds them with
|
||||
# codec None (= keep the master/general codec, RawCodec hex strings).
|
||||
#
|
||||
# Module DIDs (verified answering on ECU 0x6a1, 2026-07-03):
|
||||
# 1577-1582 ElectricalEnergyStorageModule{One..Six}OperatingData
|
||||
# (139 B: 8x temperature 0.1 °C + 15x cell voltage mV)
|
||||
# 3085-3090 ElectricalEnergyStorageModule{One..Six}Information
|
||||
# (18 B: module voltage 0.01 V, SoH?, counters)
|
||||
from open3e.Open3EdatapointsVx3 import dataIdentifiers as _vx3
|
||||
|
||||
dataIdentifiers = {
|
||||
"dids": {
|
||||
**_vx3["dids"],
|
||||
1577: None, 1578: None, 1579: None, 1580: None, 1581: None, 1582: None,
|
||||
3085: None, 3086: None, 3087: None, 3088: None, 3089: None, 3090: None,
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
- name: Reload systemd
|
||||
ansible.builtin.systemd:
|
||||
daemon_reload: true
|
||||
|
||||
- name: Restart heating-can
|
||||
ansible.builtin.command:
|
||||
cmd: docker compose up -d --force-recreate
|
||||
chdir: "{{ heating_can_base_dir }}"
|
||||
changed_when: true
|
||||
@@ -1,172 +0,0 @@
|
||||
---
|
||||
# Heating system over the Viessmann E3 CAN bus.
|
||||
#
|
||||
# Two readers of the same heating installation, supervised inside one container
|
||||
# from one image (ci/images/heating-can):
|
||||
# open3e UDS request/response to the Vitocal heat pump and Vitocharge VX3
|
||||
# e3oncan passive CANopen listener for the E380 grid energy meter, which
|
||||
# feeds the heat pump's own energy management
|
||||
#
|
||||
# They coexist because e3oncan issues no bus writes.
|
||||
#
|
||||
# Nothing is installed here. The image is built in CI with both sources pinned,
|
||||
# published to the registry, and this host only pulls and runs it. That is what
|
||||
# removes the churn: these were pip-installed into venvs on the node, and pip
|
||||
# re-cloned and rebuilt a VCS requirement on every converge -- restarting the
|
||||
# heating bridge each time -- which pinning the SHA did not fix.
|
||||
#
|
||||
# network_mode: host is required, not incidental: SocketCAN interfaces live in
|
||||
# the host network namespace, so can0 is only reachable that way. It is also how
|
||||
# the local MQTT broker on 127.0.0.1 is reached. CAN_RAW needs no capabilities,
|
||||
# so both run unprivileged.
|
||||
#
|
||||
# The can0 interface itself belongs to the socketcan role; this role consumes it.
|
||||
|
||||
- name: Create heating-can directory
|
||||
ansible.builtin.file:
|
||||
path: "{{ heating_can_base_dir }}"
|
||||
state: directory
|
||||
mode: "0755"
|
||||
|
||||
# --- Migration off the native install ---------------------------------------
|
||||
# Must happen before the containers start. Two open3e instances issuing UDS
|
||||
# requests on one CAN bus is the failure mode to avoid, so the old units are
|
||||
# stopped and masked rather than merely disabled -- masked so a stray
|
||||
# `systemctl start` or a leftover dependency cannot bring them back alongside
|
||||
# the containers.
|
||||
- name: Find which native CAN units still exist
|
||||
ansible.builtin.stat:
|
||||
path: "/etc/systemd/system/{{ item }}.service"
|
||||
loop:
|
||||
- open3e
|
||||
- e3oncan
|
||||
register: heating_can_native_units
|
||||
|
||||
# Stop and disable BEFORE anything else, and do NOT swallow the result.
|
||||
#
|
||||
# The first version did stop + disable + mask in one call with
|
||||
# failed_when: false, then deleted the unit files. Masking a unit whose file
|
||||
# still exists conflicts, so the call failed, failed_when hid it, nothing was
|
||||
# stopped -- and the next task deleted the unit files out from under two
|
||||
# running processes. systemctl is-enabled then reported "not-found" and looked
|
||||
# clean while is-active still said "active".
|
||||
#
|
||||
# The result on 2026-08-10 was two open3e instances issuing UDS requests on the
|
||||
# same CAN bus for nine minutes, fighting over the open3e-edge client ID and
|
||||
# kicking each other off the broker.
|
||||
#
|
||||
# Only units that actually exist are touched, so a fresh host does not need an
|
||||
# error swallowed to get through this.
|
||||
- name: Stop and disable the native CAN services
|
||||
ansible.builtin.systemd:
|
||||
name: "{{ item.item }}"
|
||||
state: stopped
|
||||
enabled: false
|
||||
loop: "{{ heating_can_native_units.results }}"
|
||||
loop_control:
|
||||
label: "{{ item.item }}"
|
||||
when: item.stat.exists
|
||||
|
||||
# Masking is deliberately NOT used. It writes a symlink at the same path the
|
||||
# next task removes, so the two cancel out -- which is half of how the original
|
||||
# failed. With the unit files gone there is nothing left to start.
|
||||
- name: Remove the native unit files
|
||||
ansible.builtin.file:
|
||||
path: "/etc/systemd/system/{{ item }}.service"
|
||||
state: absent
|
||||
loop:
|
||||
- open3e
|
||||
- e3oncan
|
||||
notify: Reload systemd
|
||||
|
||||
# The invariant that actually matters is "no native process is on the CAN bus",
|
||||
# and systemd reporting success is not the same claim. Two open3e instances
|
||||
# polling one bus is the failure this whole block exists to prevent, so it is
|
||||
# asserted directly rather than inferred from the calls above.
|
||||
- name: Check for surviving native CAN processes
|
||||
ansible.builtin.command:
|
||||
argv: [pgrep, -af, "edge-host/(open3e|e3oncan)"]
|
||||
register: heating_can_survivors
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: Assert no native CAN process survived
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- heating_can_survivors.stdout | default('') | length == 0
|
||||
fail_msg: >-
|
||||
A native open3e/e3oncan process is still running after the units were
|
||||
stopped and removed:
|
||||
{{ heating_can_survivors.stdout_lines | default([]) | join('; ') }}.
|
||||
Starting the container now would put two readers on the same CAN bus --
|
||||
two open3e instances issue competing UDS requests and take over each
|
||||
other's MQTT session. Kill the survivors, then re-run.
|
||||
success_msg: No native CAN processes remain; safe to start the container.
|
||||
|
||||
- name: Remove the native venvs
|
||||
ansible.builtin.file:
|
||||
path: "{{ item }}"
|
||||
state: absent
|
||||
loop:
|
||||
- "{{ edge_base_dir }}/open3e/venv"
|
||||
- "{{ edge_base_dir }}/e3oncan/venv"
|
||||
- "{{ edge_base_dir }}/e3oncan/src"
|
||||
- "{{ edge_base_dir }}/open3e"
|
||||
- "{{ edge_base_dir }}/e3oncan"
|
||||
|
||||
# --- Configuration (bind-mounted into the containers) ------------------------
|
||||
- name: Deploy open3e devices.json
|
||||
ansible.builtin.copy:
|
||||
content: "{{ edge_open3e_devices | to_nice_json }}\n"
|
||||
dest: "{{ heating_can_base_dir }}/devices.json"
|
||||
mode: "0644"
|
||||
notify: Restart heating-can
|
||||
|
||||
- name: Deploy open3e vx3 datapoint overlay (battery-module DIDs)
|
||||
ansible.builtin.copy:
|
||||
src: open3e-vx3plus.py
|
||||
dest: "{{ heating_can_base_dir }}/vx3plus.py"
|
||||
mode: "0644"
|
||||
notify: Restart heating-can
|
||||
|
||||
- name: Deploy open3e One Base datapoint overlay (68-byte ZigBee slots)
|
||||
ansible.builtin.copy:
|
||||
src: open3e-onebase.py
|
||||
dest: "{{ heating_can_base_dir }}/onebase.py"
|
||||
mode: "0644"
|
||||
notify: Restart heating-can
|
||||
|
||||
# --- Compose project ---------------------------------------------------------
|
||||
- name: Deploy the broker credential env file
|
||||
ansible.builtin.template:
|
||||
src: heating-can.env.j2
|
||||
dest: "{{ heating_can_base_dir }}/heating-can.env"
|
||||
mode: "0600"
|
||||
notify: Restart heating-can
|
||||
|
||||
- name: Deploy heating-can compose project
|
||||
ansible.builtin.template:
|
||||
src: docker-compose.yml.j2
|
||||
dest: "{{ heating_can_base_dir }}/docker-compose.yml"
|
||||
mode: "0644"
|
||||
notify: Restart heating-can
|
||||
|
||||
- name: Pull the heating-can image
|
||||
ansible.builtin.command:
|
||||
cmd: docker compose pull
|
||||
chdir: "{{ heating_can_base_dir }}"
|
||||
changed_when: false
|
||||
|
||||
# Unlike the zwave and meter services there is no missing-device tolerance here:
|
||||
# can0 is created by the socketcan role from a USB adapter that is part of this
|
||||
# host's purpose. If it is absent the heating telemetry is down and the run
|
||||
# should say so rather than pass quietly.
|
||||
- name: Start heating-can services
|
||||
ansible.builtin.command:
|
||||
cmd: docker compose up -d
|
||||
chdir: "{{ heating_can_base_dir }}"
|
||||
register: heating_can_up
|
||||
changed_when: >-
|
||||
'Started' in heating_can_up.stderr or 'Created' in heating_can_up.stderr
|
||||
or 'Started' in heating_can_up.stdout or 'Creating' in heating_can_up.stdout
|
||||
@@ -1,36 +0,0 @@
|
||||
# Managed by ansible (roles/heating-can).
|
||||
#
|
||||
# One container. Both CAN readers live in the image and are supervised inside
|
||||
# it -- this file supplies configuration and nothing else. It does not know
|
||||
# open3e's or E3onCAN's command line.
|
||||
#
|
||||
# network_mode: host is required: SocketCAN interfaces live in the host network
|
||||
# namespace, so {{ edge_can_interface }} is only reachable this way, and it is
|
||||
# also how the local MQTT broker is reached. CAN_RAW needs no capabilities, so
|
||||
# the image runs unprivileged.
|
||||
services:
|
||||
heating-can:
|
||||
image: {{ heating_can_image }}
|
||||
container_name: heating-can
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
volumes:
|
||||
- ./devices.json:/config/devices.json:ro
|
||||
- ./vx3plus.py:/config/vx3plus.py:ro
|
||||
- ./onebase.py:/config/onebase.py:ro
|
||||
env_file:
|
||||
# Broker credential, 0600 -- see heating-can.env.j2.
|
||||
- heating-can.env
|
||||
environment:
|
||||
CAN_INTERFACE: "{{ edge_can_interface }}"
|
||||
MQTT_HOST: "{{ edge_mqtt_host }}"
|
||||
MQTT_PORT: "{{ edge_mqtt_port }}"
|
||||
MQTT_TOPIC: "{{ edge_open3e_mqtt_topic }}"
|
||||
MQTT_FORMAT: "{{ edge_open3e_mqtt_format }}"
|
||||
E3ONCAN_MQTT_FORMAT: "{{ edge_e3oncan_mqtt_format }}"
|
||||
OPEN3E_CONFIG: /config/devices.json
|
||||
OPEN3E_CLIENT_ID: open3e-edge
|
||||
OPEN3E_POLL_SECONDS: "{{ edge_open3e_poll_seconds }}"
|
||||
OPEN3E_DATAPOINTS: "{{ edge_open3e_datapoints }}"
|
||||
E3ONCAN_DEVICE: e380
|
||||
E3ONCAN_GAP_SECONDS: "{{ edge_e3oncan_gap_seconds }}"
|
||||
@@ -1,8 +0,0 @@
|
||||
# Managed by ansible (roles/heating-can). 0600 -- carries the broker credential.
|
||||
#
|
||||
# Kept out of docker-compose.yml, which is 0644: the sibling roles on this host
|
||||
# do the same (power-meter-sml renders its config at 0600 while its compose file
|
||||
# stays world-readable), and the native unit this replaced used a 0600 env file
|
||||
# for exactly this value.
|
||||
MQTT_USER={{ edge_open3e_mqtt_user }}
|
||||
MQTT_PASSWORD={{ edge_open3e_mqtt_password }}
|
||||
@@ -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
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
---
|
||||
# --- MQTT: edge clients (open3e, e3oncan, sml2mqtt) publish to a LOCAL
|
||||
# mosquitto on loopback, which bridges to the central cluster broker over TLS
|
||||
# (open3e/e3oncan have no native MQTTS). This keeps plaintext on loopback only
|
||||
# — off the untrusted IoT VLAN — and buffers telemetry in RAM during cluster
|
||||
# blips. The local broker writes nothing to the eMMC (persistence off, logs to
|
||||
# the volatile journal). See edge-mqtt-bridge.conf.j2 and README "MQTT bridge".
|
||||
# Upstream cluster broker the local bridge connects to (TLS; the public
|
||||
# Let's Encrypt cert is validated against the system CA bundle).
|
||||
edge_mqtt_cluster_host: mqtt.renner.uno
|
||||
edge_mqtt_cluster_tls_port: 8883
|
||||
edge_mqtt_bridge_cafile: /etc/ssl/certs/ca-certificates.crt
|
||||
|
||||
# --- Bridge credential from mqtt-account-controller (REN-1307) ------------
|
||||
# The cluster account the bridge logs in as (register entry in
|
||||
# kubernetes/apps/home-assistant/mqtt-accounts.yaml). The controller pushes
|
||||
# its password to edge-mqtt-cred and rotates it when the bridge reports
|
||||
# "not connected"; there is no copy in ansible-vault.
|
||||
edge_mqtt_bridge_account: edge-1
|
||||
# HTTPS port of edge-mqtt-cred (esp32 device API, mTLS). Only a client
|
||||
# certificate from the MQTT CA with this CN is accepted.
|
||||
edge_mqtt_cred_port: 8443
|
||||
edge_mqtt_cred_allowed_cn: mqtt-account-controller
|
||||
@@ -1,141 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# Managed by ansible (roles/mqtt-bridge). Receives the cluster bridge password
|
||||
# from mqtt-account-controller (REN-1307) so no plaintext copy lives in
|
||||
# ansible-vault and no play run is needed to rotate it.
|
||||
#
|
||||
# Speaks the controller's esp32 device API over HTTPS:
|
||||
# GET /api/status -> {"mqtt": {"connected": bool}}
|
||||
# PUT /api/settings <- {"mqtt": {"user": "...", "pass": "..."}}
|
||||
# A client certificate from the MQTT CA with CN mqtt-account-controller is
|
||||
# required; nothing else may call it. The server key is generated on this
|
||||
# host and never leaves it (the controller pins it on first contact).
|
||||
#
|
||||
# PUT writes the bridge file (template + credentials), removes the legacy
|
||||
# vault-password bridges and restarts the local mosquitto. The password is
|
||||
# kept only in that 0640 root:mosquitto file.
|
||||
import grp
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
CONF = json.load(open(sys.argv[1]))
|
||||
WRITE = threading.Lock()
|
||||
|
||||
|
||||
def connected():
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["mosquitto_sub", "-h", "127.0.0.1", "-p", str(CONF["local_port"]),
|
||||
"-t", CONF["state_topic"], "-C", "1", "-W", "3"],
|
||||
capture_output=True, text=True, timeout=10).stdout
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
return out.strip() == "1"
|
||||
|
||||
|
||||
def write_bridge(user, password):
|
||||
with open(CONF["template"]) as f:
|
||||
body = f.read()
|
||||
body += f"remote_username {user}\nremote_password {password}\n"
|
||||
d = os.path.dirname(CONF["bridge_file"])
|
||||
fd, tmp = tempfile.mkstemp(dir=d, prefix=".edge-mqtt-cred.")
|
||||
with os.fdopen(fd, "w") as f:
|
||||
f.write(body)
|
||||
os.chown(tmp, 0, grp.getgrnam("mosquitto").gr_gid)
|
||||
os.chmod(tmp, 0o640)
|
||||
os.replace(tmp, CONF["bridge_file"])
|
||||
for legacy in CONF["legacy_files"]:
|
||||
if os.path.exists(legacy):
|
||||
os.remove(legacy)
|
||||
subprocess.run(["systemctl", "restart", "mosquitto"], check=True, timeout=60)
|
||||
|
||||
|
||||
def valid(s):
|
||||
# Controller passwords are random URL-safe tokens; anything with
|
||||
# whitespace or a control character would break the config line.
|
||||
return isinstance(s, str) and 0 < len(s) <= 256 and s.isprintable() and not any(c.isspace() for c in s)
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def authorized(self):
|
||||
cert = self.connection.getpeercert() or {}
|
||||
cns = [v for rdn in cert.get("subject", ()) for k, v in rdn if k == "commonName"]
|
||||
if cns != [CONF["allowed_cn"]]:
|
||||
self.send_error(403)
|
||||
return False
|
||||
return True
|
||||
|
||||
def reply(self, code, obj=None):
|
||||
data = b"" if obj is None else json.dumps(obj).encode()
|
||||
self.send_response(code)
|
||||
if data:
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def do_GET(self):
|
||||
if not self.authorized():
|
||||
return
|
||||
if self.path != "/api/status":
|
||||
return self.send_error(404)
|
||||
self.reply(200, {"mqtt": {"connected": connected()}})
|
||||
|
||||
def do_PUT(self):
|
||||
if not self.authorized():
|
||||
return
|
||||
if self.path != "/api/settings":
|
||||
return self.send_error(404)
|
||||
try:
|
||||
n = int(self.headers.get("Content-Length", "0"))
|
||||
if not 0 < n <= 4096:
|
||||
raise ValueError("body size")
|
||||
m = json.loads(self.rfile.read(n))["mqtt"]
|
||||
user, password = m["user"], m["pass"]
|
||||
except (ValueError, KeyError, TypeError):
|
||||
return self.send_error(400)
|
||||
if user != CONF["user"] or not valid(password):
|
||||
return self.send_error(400)
|
||||
with WRITE:
|
||||
write_bridge(user, password)
|
||||
self.log_message("bridge credentials for %s replaced, mosquitto restarted", user)
|
||||
self.reply(204)
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
sys.stderr.write("%s %s\n" % (self.address_string(), fmt % args))
|
||||
|
||||
|
||||
class Server(ThreadingHTTPServer):
|
||||
address_family = socket.AF_INET6
|
||||
|
||||
def server_bind(self):
|
||||
self.socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0)
|
||||
super().server_bind()
|
||||
|
||||
def get_request(self):
|
||||
# The TLS handshake runs in the request thread, with a timeout, so a
|
||||
# stalled peer cannot block the accept loop.
|
||||
sock, addr = self.socket.accept()
|
||||
sock.settimeout(10)
|
||||
return self.ctx.wrap_socket(sock, server_side=True, do_handshake_on_connect=False), addr
|
||||
|
||||
|
||||
def main():
|
||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
|
||||
ctx.load_cert_chain(CONF["cert"], CONF["key"])
|
||||
ctx.load_verify_locations(CONF["ca"])
|
||||
ctx.verify_mode = ssl.CERT_REQUIRED
|
||||
srv = Server(("::", CONF["port"]), Handler)
|
||||
srv.ctx = ctx
|
||||
srv.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,10 +0,0 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIBdTCCARygAwIBAgIUN8ne4k4Wif9O83GDyKiBP0Ma7EIwCgYIKoZIzj0EAwIw
|
||||
GTEXMBUGA1UEAxMOUmVubmVyIE1RVFQgQ0EwHhcNMjYxMDA2MTUzMDE1WhcNMzYx
|
||||
MDAzMTUzMDE1WjAZMRcwFQYDVQQDEw5SZW5uZXIgTVFUVCBDQTBZMBMGByqGSM49
|
||||
AgEGCCqGSM49AwEHA0IABOZhZF2JDoyvVg2oIXSbJAuAWDcoG22RxPA4oMKRMA/b
|
||||
XzIY566D2k6cVnWqbil9s/q4O2mj+Epo7plBw8NwuOOjQjBAMA4GA1UdDwEB/wQE
|
||||
AwICpDAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBQcqSDDiLZzhrvqq9f16Y08
|
||||
CO7QfzAKBggqhkjOPQQDAgNHADBEAiBFkxd9pXnqIcjReEcOTvCBZ3HxKu2jNQGL
|
||||
/le7gunTGgIgZapWAcu4awa+wmZcR/z2luOjD2GhIT15um14Z+sstJk=
|
||||
-----END CERTIFICATE-----
|
||||
@@ -1,13 +0,0 @@
|
||||
---
|
||||
# The MQTT clients (zwave-js, sml2mqtt, open3e, e3oncan) reconnect on their
|
||||
# own once the local broker is back.
|
||||
- name: Restart mosquitto
|
||||
ansible.builtin.service:
|
||||
name: mosquitto
|
||||
state: restarted
|
||||
|
||||
- name: Restart edge-mqtt-cred
|
||||
ansible.builtin.systemd:
|
||||
name: edge-mqtt-cred
|
||||
state: restarted
|
||||
daemon_reload: true
|
||||
@@ -1,109 +0,0 @@
|
||||
---
|
||||
# Local MQTT bridge broker.
|
||||
#
|
||||
# Edge clients (open3e, e3oncan, sml2mqtt) publish here over loopback; this
|
||||
# broker bridges to the cluster broker over TLS, because those clients have no
|
||||
# native MQTTS. Persistence is off and logging goes to stdout so it writes
|
||||
# nothing to local flash.
|
||||
|
||||
- name: Install mosquitto
|
||||
ansible.builtin.package:
|
||||
name: mosquitto
|
||||
state: present
|
||||
|
||||
- name: Create the bridge directory
|
||||
ansible.builtin.file:
|
||||
path: /etc/mosquitto/bridge.d
|
||||
state: directory
|
||||
owner: root
|
||||
group: mosquitto
|
||||
mode: "0750"
|
||||
|
||||
- name: Check for the controller-managed bridge file
|
||||
ansible.builtin.stat:
|
||||
path: "/etc/mosquitto/bridge.d/{{ edge_mqtt_bridge_account }}.conf"
|
||||
register: edge_mqtt_bridge_managed
|
||||
|
||||
# Until mqtt-account-controller has pushed the first password, keep the
|
||||
# vault-password bridges so the cluster never loses edge-1's data. Once the
|
||||
# managed file exists, never render them again (edge-mqtt-cred removed them).
|
||||
- name: Deploy legacy vault-password bridges
|
||||
ansible.builtin.template:
|
||||
src: edge-mqtt-bridge-legacy.conf.j2
|
||||
dest: /etc/mosquitto/bridge.d/legacy.conf
|
||||
owner: root
|
||||
group: mosquitto
|
||||
mode: "0640" # carries the legacy cluster bridge credentials
|
||||
when: not edge_mqtt_bridge_managed.stat.exists
|
||||
notify: Restart mosquitto
|
||||
|
||||
- name: Deploy local mosquitto config
|
||||
ansible.builtin.template:
|
||||
src: edge-mqtt-bridge.conf.j2
|
||||
dest: /etc/mosquitto/mosquitto.conf
|
||||
owner: root
|
||||
group: mosquitto
|
||||
mode: "0640"
|
||||
notify: Restart mosquitto
|
||||
|
||||
- name: Enable local mosquitto bridge
|
||||
ansible.builtin.systemd:
|
||||
name: mosquitto
|
||||
enabled: true
|
||||
state: started
|
||||
|
||||
# --- edge-mqtt-cred (REN-1307): receives the bridge password from
|
||||
# mqtt-account-controller. edge-1 holds only the public MQTT CA and its own
|
||||
# server key, generated here and never copied off the host.
|
||||
- name: Create edge-mqtt-cred config directory
|
||||
ansible.builtin.file:
|
||||
path: /etc/edge-mqtt-cred
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0700"
|
||||
|
||||
- name: Generate edge-mqtt-cred server key and certificate
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1
|
||||
-nodes -days 36500 -subj "/CN={{ inventory_hostname }}"
|
||||
-keyout /etc/edge-mqtt-cred/tls.key -out /etc/edge-mqtt-cred/tls.crt
|
||||
creates: /etc/edge-mqtt-cred/tls.key
|
||||
notify: Restart edge-mqtt-cred
|
||||
|
||||
- name: Deploy edge-mqtt-cred files
|
||||
ansible.builtin.copy:
|
||||
src: "{{ item.src }}"
|
||||
dest: "{{ item.dest }}"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "{{ item.mode }}"
|
||||
loop:
|
||||
- {src: edge-mqtt-cred.py, dest: /usr/local/sbin/edge-mqtt-cred, mode: "0755"}
|
||||
# Public CA of the cluster's MQTT client certificates
|
||||
# (kubernetes/infrastructure/post-crds/mqtt-ca.yaml), valid until 2036-10-03.
|
||||
- {src: mqtt-ca.crt, dest: /etc/edge-mqtt-cred/mqtt-ca.crt, mode: "0644"}
|
||||
notify: Restart edge-mqtt-cred
|
||||
|
||||
- name: Deploy edge-mqtt-cred config and bridge template
|
||||
ansible.builtin.template:
|
||||
src: "{{ item.src }}"
|
||||
dest: "{{ item.dest }}"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "{{ item.mode }}"
|
||||
loop:
|
||||
- {src: edge-mqtt-cred.json.j2, dest: /etc/edge-mqtt-cred/config.json, mode: "0600"}
|
||||
# Read only on the controller's next push: a change here reaches the
|
||||
# running bridge when the password is next set.
|
||||
- {src: edge-1-bridge.conf.in.j2, dest: /etc/edge-mqtt-cred/bridge.conf.in, mode: "0600"}
|
||||
- {src: edge-mqtt-cred.service.j2, dest: /etc/systemd/system/edge-mqtt-cred.service, mode: "0644"}
|
||||
notify: Restart edge-mqtt-cred
|
||||
|
||||
- name: Enable edge-mqtt-cred
|
||||
ansible.builtin.systemd:
|
||||
name: edge-mqtt-cred
|
||||
enabled: true
|
||||
state: started
|
||||
daemon_reload: true
|
||||
@@ -1,21 +0,0 @@
|
||||
# Managed by roles/mqtt-bridge (REN-1307). edge-mqtt-cred appends
|
||||
# remote_username/remote_password to this and writes the result to
|
||||
# /etc/mosquitto/bridge.d/{{ edge_mqtt_bridge_account }}.conf. One bridge for
|
||||
# open3e, as the register account `{{ edge_mqtt_bridge_account }}`
|
||||
# (ACL readwrite open3e/#). sml2mqtt is retired (SML reader, 2026-09-19).
|
||||
connection {{ edge_mqtt_bridge_account }}
|
||||
address {{ edge_mqtt_cluster_host }}:{{ edge_mqtt_cluster_tls_port }}
|
||||
bridge_cafile {{ edge_mqtt_bridge_cafile }}
|
||||
bridge_insecure false
|
||||
remote_clientid edge1-bridge
|
||||
cleansession false
|
||||
try_private true
|
||||
restart_timeout 5 30
|
||||
queue_qos0_messages true
|
||||
# The bridge state goes to the LOCAL broker only (retained 1/0); edge-mqtt-cred
|
||||
# reads it to answer the controller's "connected?".
|
||||
notifications true
|
||||
notifications_local_only true
|
||||
notification_topic $SYS/broker/connection/{{ edge_mqtt_bridge_account }}/state
|
||||
topic open3e/# out 0
|
||||
topic open3e/cmnd in 0
|
||||
@@ -1,21 +0,0 @@
|
||||
# Managed by roles/mqtt-bridge. Legacy vault-password bridges, rendered only
|
||||
# while /etc/mosquitto/bridge.d/{{ edge_mqtt_bridge_account }}.conf does not
|
||||
# exist; edge-mqtt-cred deletes this file on the controller's first push
|
||||
# (REN-1307). Remove with the vault keys once edge-1 is on the register account.
|
||||
# --- Bridge 1: open3e datapoints + command topic, as the cluster `open3e`
|
||||
# user (its ACL is `readwrite open3e/#`, which covers publishing datapoints
|
||||
# and subscribing to open3e/cmnd). e3oncan also publishes under open3e/#.
|
||||
connection edge1-open3e
|
||||
address {{ edge_mqtt_cluster_host }}:{{ edge_mqtt_cluster_tls_port }}
|
||||
bridge_cafile {{ edge_mqtt_bridge_cafile }}
|
||||
bridge_insecure false
|
||||
remote_username {{ edge_open3e_mqtt_user }}
|
||||
remote_password {{ edge_open3e_mqtt_password }}
|
||||
remote_clientid edge1-bridge-open3e
|
||||
cleansession false
|
||||
try_private true
|
||||
notifications false
|
||||
restart_timeout 5 30
|
||||
queue_qos0_messages true
|
||||
topic open3e/# out 0
|
||||
topic open3e/cmnd in 0
|
||||
@@ -1,30 +0,0 @@
|
||||
# Managed by roles/mqtt-bridge. Local mosquitto that the edge clients (open3e,
|
||||
# e3oncan, sml2mqtt) publish to over loopback; it bridges to the central
|
||||
# cluster broker over TLS. open3e/e3oncan have no native MQTTS, so this is how
|
||||
# edge-1's MQTT gets off plaintext on the untrusted IoT VLAN, and it buffers
|
||||
# telemetry in RAM if the cluster broker is briefly unreachable.
|
||||
#
|
||||
# eMMC: this host runs on a small internal eMMC, so the local broker writes
|
||||
# NOTHING to flash — `persistence false` keeps retained/queued messages in RAM
|
||||
# only, and `log_dest stdout` goes to the (volatile, RAM) journal that Alloy
|
||||
# ships to Loki. Do not add persistence/log-file settings here.
|
||||
persistence false
|
||||
log_dest stdout
|
||||
log_type error
|
||||
log_type warning
|
||||
log_type notice
|
||||
|
||||
# Loopback only — the trust boundary is the host itself. Any process on edge-1
|
||||
# may publish here (same trust as the bridged services); nothing off-host can
|
||||
# reach it. Real authentication to the cluster happens on the bridge below.
|
||||
# Single v4-loopback listener: all edge clients dial 127.0.0.1 (edge_mqtt_host),
|
||||
# and a second same-port listener can make some builds refuse to start.
|
||||
listener 1883 127.0.0.1
|
||||
allow_anonymous true
|
||||
|
||||
# --- Bridges to the cluster broker (REN-1307). bridge.d holds either
|
||||
# {{ edge_mqtt_bridge_account }}.conf, written by edge-mqtt-cred with the password
|
||||
# mqtt-account-controller pushed, or, until that first push, legacy.conf with
|
||||
# the two vault-password bridges. edge-mqtt-cred deletes legacy.conf when it
|
||||
# writes the new file.
|
||||
include_dir /etc/mosquitto/bridge.d
|
||||
@@ -1,13 +0,0 @@
|
||||
{{ {
|
||||
"port": edge_mqtt_cred_port,
|
||||
"allowed_cn": edge_mqtt_cred_allowed_cn,
|
||||
"user": edge_mqtt_bridge_account,
|
||||
"cert": "/etc/edge-mqtt-cred/tls.crt",
|
||||
"key": "/etc/edge-mqtt-cred/tls.key",
|
||||
"ca": "/etc/edge-mqtt-cred/mqtt-ca.crt",
|
||||
"template": "/etc/edge-mqtt-cred/bridge.conf.in",
|
||||
"bridge_file": "/etc/mosquitto/bridge.d/" ~ edge_mqtt_bridge_account ~ ".conf",
|
||||
"legacy_files": ["/etc/mosquitto/bridge.d/legacy.conf"],
|
||||
"local_port": edge_mqtt_port,
|
||||
"state_topic": "$SYS/broker/connection/" ~ edge_mqtt_bridge_account ~ "/state",
|
||||
} | to_nice_json }}
|
||||
@@ -1,19 +0,0 @@
|
||||
# Managed by roles/mqtt-bridge (REN-1307). See /usr/local/sbin/edge-mqtt-cred.
|
||||
[Unit]
|
||||
Description=Receives the MQTT bridge credential from mqtt-account-controller
|
||||
After=network-online.target mosquitto.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/bin/python3 /usr/local/sbin/edge-mqtt-cred /etc/edge-mqtt-cred/config.json
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
# Root only to write the bridge file and restart mosquitto.
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/etc/mosquitto/bridge.d
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
NoNewPrivileges=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,14 @@
|
||||
# Managed by ansible (roles/pacman-cache).
|
||||
# Downloaded packages are not kept; older versions come from the Arch Linux
|
||||
# Archive.
|
||||
[Trigger]
|
||||
Operation = Install
|
||||
Operation = Upgrade
|
||||
Operation = Remove
|
||||
Type = Package
|
||||
Target = *
|
||||
|
||||
[Action]
|
||||
Description = Removing downloaded packages from the cache...
|
||||
When = PostTransaction
|
||||
Exec = /usr/bin/find /var/cache/pacman/pkg -mindepth 1 -delete
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
# Keep the pacman package cache empty. pacman keeps every downloaded package;
|
||||
# on a 7 GB eMMC root the cache grows to gigabytes. Arch hosts only.
|
||||
|
||||
- name: Ensure the pacman hook directory exists
|
||||
ansible.builtin.file:
|
||||
path: /etc/pacman.d/hooks
|
||||
state: directory
|
||||
mode: "0755"
|
||||
|
||||
- name: Empty the package cache after every transaction
|
||||
ansible.builtin.copy:
|
||||
src: pacman-cache.hook
|
||||
dest: /etc/pacman.d/hooks/pacman-cache.hook
|
||||
mode: "0644"
|
||||
|
||||
- name: Empty the package cache
|
||||
ansible.builtin.command:
|
||||
argv: [find, /var/cache/pacman/pkg, -mindepth, "1", -delete, -print]
|
||||
register: pacman_cache_clean
|
||||
changed_when: pacman_cache_clean.stdout | length > 0
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
# SocketCAN interface. Consumed by heating-can.
|
||||
# --- CAN / open3e (Viessmann E3 external bus) ---
|
||||
edge_can_interface: can0
|
||||
edge_can_bitrate: 250000
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
# reload alone only re-reads config files; existing links keep their old state
|
||||
# until reconfigured.
|
||||
- name: Reload networkd
|
||||
ansible.builtin.shell:
|
||||
cmd: networkctl reload && networkctl reconfigure {{ edge_can_interface }}
|
||||
changed_when: true
|
||||
@@ -1,20 +0,0 @@
|
||||
---
|
||||
# SocketCAN interface, owned by systemd-networkd.
|
||||
#
|
||||
# gs_usb is an in-tree module and autoloads when the adapter enumerates;
|
||||
# networkd then matches the interface and applies bitrate + up. Separate from
|
||||
# the services that consume the bus (heating-can, e380-can-meter) because the
|
||||
# interface is host network state shared by both.
|
||||
|
||||
- name: Configure can0 via systemd-networkd
|
||||
ansible.builtin.template:
|
||||
src: 80-can0.network.j2
|
||||
dest: /etc/systemd/network/80-can0.network
|
||||
mode: "0644"
|
||||
notify: Reload networkd
|
||||
|
||||
- name: Enable systemd-networkd
|
||||
ansible.builtin.service:
|
||||
name: systemd-networkd
|
||||
state: started
|
||||
enabled: true
|
||||
@@ -1,17 +0,0 @@
|
||||
# Managed by ansible (roles/socketcan).
|
||||
# Viessmann E3 external CAN bus via Innomaker USB2CAN (gs_usb).
|
||||
# The adapter is a stub tap on an already-terminated bus (120R jumper OFF).
|
||||
[Match]
|
||||
Name={{ edge_can_interface }}
|
||||
|
||||
[Link]
|
||||
# networkd applies [CAN] settings but does not raise a CAN link on its
|
||||
# own (observed on edge-1); force it up, and back up after any down.
|
||||
ActivationPolicy=always-up
|
||||
|
||||
[CAN]
|
||||
BitRate={{ edge_can_bitrate }}
|
||||
# NO RestartSec here: gs_usb does not support hardware bus-off restart,
|
||||
# and networkd fails the ENTIRE link configuration on the unsupported
|
||||
# option ("off (failed)", link never raised — found by reboot test).
|
||||
# Bus-off recovery falls to the open3e service restart loop.
|
||||
@@ -143,6 +143,12 @@
|
||||
group: root
|
||||
mode: "0644"
|
||||
|
||||
- name: Install sudo
|
||||
ansible.builtin.package:
|
||||
name: sudo
|
||||
state: present
|
||||
when: not ssh_ca_synouser.stat.exists
|
||||
|
||||
- name: Install the Claude sudo rules
|
||||
ansible.builtin.template:
|
||||
src: sudoers-claude.j2
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
# Keep the system clock on NTP with systemd-timesyncd, on hosts that run no
|
||||
# chrony. A KVM guest's clocksource does not steer wall-clock time; without an
|
||||
# NTP client the clock drifts until JWT iat/exp checks fail.
|
||||
|
||||
- name: Install systemd-timesyncd
|
||||
ansible.builtin.apt:
|
||||
name: systemd-timesyncd
|
||||
state: present
|
||||
when: ansible_facts['os_family'] == 'Debian'
|
||||
|
||||
- name: Run systemd-timesyncd
|
||||
ansible.builtin.systemd_service:
|
||||
name: systemd-timesyncd
|
||||
masked: false
|
||||
enabled: true
|
||||
state: started
|
||||
|
||||
- name: Wait for the clock to synchronize
|
||||
ansible.builtin.command:
|
||||
argv: [timedatectl, show, --property=NTPSynchronized, --value]
|
||||
register: timesyncd_synchronized
|
||||
changed_when: false
|
||||
until: timesyncd_synchronized.stdout == "yes"
|
||||
retries: 12
|
||||
delay: 5
|
||||
@@ -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
|
||||
|
||||
@@ -13,12 +13,8 @@ zwave_base_dir: "{{ edge_base_dir }}/zwave-js"
|
||||
# survives moving the stick between machines.
|
||||
edge_zwave_device: /dev/serial/by-id/usb-Nabu_Casa_ZWA-2_80B54EE15720-if00
|
||||
|
||||
edge_zwavejs_image: ghcr.io/zwave-js/zwave-js-ui:11.21.1
|
||||
|
||||
# Alpine version the zwave-js-ui image is built on -- the source for the musl
|
||||
# libeatmydata.so preloaded into that container. Keep in sync if the image
|
||||
# bumps its Alpine major (check: docker exec zwave-js cat /etc/os-release).
|
||||
edge_zwave_alpine_base: alpine:3.22
|
||||
# Renovate-tracked (customManagers in renovate.json).
|
||||
edge_zwavejs_image: ghcr.io/zwave-js/zwave-js-ui:11.25.2@sha256:ac4874a3cbea61c0c60002a8fd8ae05104cc8eb2eae91aa05bd184972aa06853
|
||||
|
||||
# Secret from inventory/group_vars/edge_host/vault.yml:
|
||||
# edge_zwavejs_session_secret
|
||||
|
||||
@@ -15,17 +15,31 @@
|
||||
- "{{ zwave_base_dir }}"
|
||||
- "{{ zwave_base_dir }}/store"
|
||||
|
||||
# The preloaded .so is musl and must come from the image's own Alpine release.
|
||||
- name: Read the Alpine release of the zwave-js-ui image
|
||||
ansible.builtin.command:
|
||||
cmd: docker run --rm --entrypoint cat {{ edge_zwavejs_image }} /etc/alpine-release
|
||||
register: zwave_alpine_release
|
||||
changed_when: false
|
||||
check_mode: false
|
||||
|
||||
- name: Name libeatmydata.so after that Alpine release
|
||||
ansible.builtin.set_fact:
|
||||
zwave_alpine_minor: "{{ zwave_alpine_release.stdout.split('.')[:2] | join('.') }}"
|
||||
zwave_libeatmydata: "libeatmydata-alpine{{ zwave_alpine_release.stdout.split('.')[:2] | join('.') }}.so"
|
||||
|
||||
- name: Extract musl libeatmydata.so (matches the zwave-js-ui Alpine base)
|
||||
ansible.builtin.shell:
|
||||
cmd: |
|
||||
set -euo pipefail
|
||||
tmp="$(mktemp)"
|
||||
trap 'rm -f "$tmp"' EXIT
|
||||
docker run --rm --network=host {{ edge_zwave_alpine_base }} \
|
||||
docker run --rm --network=host alpine:{{ zwave_alpine_minor }} \
|
||||
sh -c 'apk add --no-cache libeatmydata >/dev/null 2>&1 && cat /usr/lib/libeatmydata.so' > "$tmp"
|
||||
test "$(stat -c%s "$tmp")" -ge 8000 # sanity: real .so is ~14 KB, guards against a failed/empty pull
|
||||
install -m 0644 "$tmp" {{ zwave_base_dir }}/libeatmydata.so
|
||||
creates: "{{ zwave_base_dir }}/libeatmydata.so"
|
||||
install -m 0644 "$tmp" {{ zwave_base_dir }}/{{ zwave_libeatmydata }}
|
||||
creates: "{{ zwave_base_dir }}/{{ zwave_libeatmydata }}"
|
||||
executable: /bin/bash
|
||||
notify: Restart zwave-js
|
||||
|
||||
# Because fsync is neutralized, a hard power-loss during the (rare) settings
|
||||
@@ -93,3 +107,18 @@
|
||||
zwave-js could not start -- the ZWA-2 radio is not present:
|
||||
{{ zwave_compose_up.stderr_lines | select('search', 'device') | list }}
|
||||
when: zwave_compose_up.rc != 0
|
||||
|
||||
- name: Find libeatmydata.so builds of other Alpine releases
|
||||
ansible.builtin.find:
|
||||
paths: "{{ zwave_base_dir }}"
|
||||
patterns: "libeatmydata*.so"
|
||||
excludes: "{{ zwave_libeatmydata }}"
|
||||
register: zwave_stale_libeatmydata
|
||||
|
||||
- name: Remove libeatmydata.so builds of other Alpine releases
|
||||
ansible.builtin.file:
|
||||
path: "{{ item.path }}"
|
||||
state: absent
|
||||
loop: "{{ zwave_stale_libeatmydata.files }}"
|
||||
loop_control:
|
||||
label: "{{ item.path }}"
|
||||
|
||||
@@ -31,7 +31,7 @@ services:
|
||||
- "{{ edge_zwave_device }}:/dev/zwave"
|
||||
volumes:
|
||||
# musl libeatmydata.so extracted from the image's Alpine base (ro)
|
||||
- ./libeatmydata.so:/usr/lib/libeatmydata.so:ro
|
||||
- ./{{ zwave_libeatmydata }}:/usr/lib/libeatmydata.so:ro
|
||||
# Persistent store: settings.json, device DB, network keys. Path is
|
||||
# unchanged from the previous shared project, so nothing is migrated.
|
||||
- ./store:/usr/src/app/store
|
||||
|
||||
@@ -12,8 +12,6 @@
|
||||
ansible.builtin.import_playbook: plays/configure-synology-python.yml
|
||||
- name: Base system
|
||||
ansible.builtin.import_playbook: plays/common.yml
|
||||
- name: VM time sync
|
||||
ansible.builtin.import_playbook: plays/configure-vm-time-sync.yml
|
||||
|
||||
- name: SSH user CA
|
||||
ansible.builtin.import_playbook: plays/configure-ssh-ca.yml
|
||||
@@ -33,8 +31,6 @@
|
||||
ansible.builtin.import_playbook: plays/install-edge-host.yml
|
||||
- name: Z-Wave gateway
|
||||
ansible.builtin.import_playbook: plays/install-zwave-gateway.yml
|
||||
- name: Heating CAN
|
||||
ansible.builtin.import_playbook: plays/install-heating-can.yml
|
||||
- name: NTP service
|
||||
ansible.builtin.import_playbook: plays/install-ntp-ha.yml
|
||||
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
|
||||
# The unit tests gate the image: the final stage copies the controller out of
|
||||
# this stage, so a failing test fails the build.
|
||||
FROM python:3.15-slim@sha256:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51 AS test
|
||||
FROM python:3.15-slim@sha256:94d3426f1def7db4a106a232ac121bfd975393e5dfd40a619b955fae45fe7913 AS test
|
||||
WORKDIR /src
|
||||
COPY controller.py test_controller.py ./
|
||||
RUN python3 -B -m unittest -v
|
||||
|
||||
FROM python:3.15-slim@sha256:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51
|
||||
FROM python:3.15-slim@sha256:94d3426f1def7db4a106a232ac121bfd975393e5dfd40a619b955fae45fe7913
|
||||
|
||||
RUN useradd --system --no-create-home --shell /usr/sbin/nologin controller
|
||||
COPY --from=test /src/controller.py /opt/controller.py
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:24-alpine@sha256:06d7adf3e0ef61a13f1d28682726366f97a15aa1888c934d3ecec86c33918b53
|
||||
FROM node:26-alpine@sha256:c6ad9e9c1d80f6eb607c26b4b0a132ff7a2344fad1c78ea3baa2a7d4c8de6d94
|
||||
|
||||
# Default to the latest Claude Code. Pinning a fixed version is the wrong
|
||||
# default here: old releases eventually stop working against the API, so a
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# Cloudflare. The unit tests (no tags, so no envtest) gate the image: the
|
||||
# final stage copies the binary out of this stage, so a failing test fails the
|
||||
# build.
|
||||
FROM golang:1.27@sha256:f20d83a83e18dbd2a4714b8ffd4b30b287b6243962dd75608e86abcc63ba4efc AS build
|
||||
FROM golang:1.27@sha256:2920bb124f1a2e12a1d39f0cab756ae0068441ee1da7655dec7f5e374944095b AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
@@ -10,7 +10,7 @@ COPY . .
|
||||
RUN go test ./...
|
||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/cloudflare-lb-sync ./cmd/cloudflare-lb-sync
|
||||
|
||||
FROM gcr.io/distroless/static:nonroot@sha256:18f4cc26c9ffa7f20a359298be7deadac5c607c8e03a02b8119f1a5fc2f82c99
|
||||
FROM gcr.io/distroless/static:nonroot@sha256:25ac8f0c3b2334a9eb05452266bd4697864a8c8ef0ef71e5e6480d7f92868329
|
||||
COPY --from=build /out/cloudflare-lb-sync /cloudflare-lb-sync
|
||||
USER 65532
|
||||
ENTRYPOINT ["/cloudflare-lb-sync"]
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
# namespace, so can0 is only reachable with network_mode: host. That is also
|
||||
# how the local MQTT broker on 127.0.0.1:1883 is reached. CAN_RAW sockets need
|
||||
# no capabilities, so both run unprivileged.
|
||||
FROM python:3.15-slim@sha256:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51 AS build
|
||||
FROM python:3.15-slim@sha256:94d3426f1def7db4a106a232ac121bfd975393e5dfd40a619b955fae45fe7913 AS build
|
||||
|
||||
# Pins live here, in the artifact, rather than in Ansible variables.
|
||||
#
|
||||
@@ -56,7 +56,7 @@ RUN git clone --quiet https://github.com/MyHomeMyData/E3onCAN.git /opt/e3oncan \
|
||||
&& rm -rf /opt/e3oncan/.git
|
||||
|
||||
|
||||
FROM python:3.15-slim@sha256:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51
|
||||
FROM python:3.15-slim@sha256:94d3426f1def7db4a106a232ac121bfd975393e5dfd40a619b955fae45fe7913
|
||||
|
||||
ARG OPEN3E_REF=6553c10453a6bae1210908f38a5af2481a46a3e5
|
||||
ARG E3ONCAN_REF=461dcbdf97e8d72d2edb9929c63b39382af6c3a1
|
||||
|
||||
@@ -6,10 +6,10 @@ RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends jq \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=ghcr.io/siderolabs/talosctl:v1.14.4@sha256:b3233234327350f20ed194f3fad83d90a64344c1940f839c7b0c0abef50c8c33 /talosctl /usr/local/bin/talosctl
|
||||
COPY --from=alpine/kubectl:1.37.1@sha256:a5d0fbecab3f7b6c2ab7398f1daa73a3e0ed6ece18556d21698233e9e3759cc7 /usr/local/bin/kubectl /usr/local/bin/kubectl
|
||||
COPY --from=ghcr.io/fluxcd/flux-cli:v2.9.6@sha256:b1ac18156f227af9a524b842a96f2c20986c7a779ef721df3ba4e4540f449d76 /usr/local/bin/flux /usr/local/bin/flux
|
||||
COPY --from=quay.io/openbao/openbao:2.7.1@sha256:6d2b93856e3fcf7b18ad855a0b51eaba474dc8b79cf554379ea32034797d2acf /usr/bin/bao /usr/local/bin/bao
|
||||
COPY --from=ghcr.io/siderolabs/talosctl:v1.14.5@sha256:0b852e35c1f513ee5eacc22183d6193486012420569fde39fcd5260c51938443 /talosctl /usr/local/bin/talosctl
|
||||
COPY --from=alpine/kubectl:1.37.2@sha256:5e2de75d37e811ba6eb92e06890b1b3263eae4f0a1c17b49269edec589b1352a /usr/local/bin/kubectl /usr/local/bin/kubectl
|
||||
COPY --from=ghcr.io/fluxcd/flux-cli:v2.10.0@sha256:1a5178b1770052d6e88ab1c7c8434c5ac9bca8a8ab984dcaf6b9df31814ce9f2 /usr/local/bin/flux /usr/local/bin/flux
|
||||
COPY --from=quay.io/openbao/openbao:2.7.2@sha256:5046911075e137e154ce49ab051502f4ec7cf367d484ae4a4dce665d9b90561f /usr/bin/bao /usr/local/bin/bao
|
||||
COPY --from=gitea/tea:0.16.0@sha256:af50a2060ce4d8fd76b7ebe75a3d6db4bb0c615c5bc8f2bdfd8ea83207c4752d /usr/bin/tea /usr/local/bin/tea
|
||||
|
||||
RUN talosctl version --client \
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# kuma-sync: reconciles KumaMonitor resources into Uptime Kuma. The unit tests
|
||||
# (no tags, so no Docker and no envtest) gate the image: the final stage copies
|
||||
# the binary out of this stage, so a failing test fails the build.
|
||||
FROM golang:1.27@sha256:f20d83a83e18dbd2a4714b8ffd4b30b287b6243962dd75608e86abcc63ba4efc AS build
|
||||
FROM golang:1.27@sha256:2920bb124f1a2e12a1d39f0cab756ae0068441ee1da7655dec7f5e374944095b AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
@@ -9,7 +9,7 @@ COPY . .
|
||||
RUN go test ./...
|
||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/kuma-sync ./cmd/kuma-sync
|
||||
|
||||
FROM gcr.io/distroless/static:nonroot@sha256:18f4cc26c9ffa7f20a359298be7deadac5c607c8e03a02b8119f1a5fc2f82c99
|
||||
FROM gcr.io/distroless/static:nonroot@sha256:25ac8f0c3b2334a9eb05452266bd4697864a8c8ef0ef71e5e6480d7f92868329
|
||||
COPY --from=build /out/kuma-sync /kuma-sync
|
||||
USER 65532
|
||||
ENTRYPOINT ["/kuma-sync"]
|
||||
|
||||
@@ -97,6 +97,10 @@ type KumaMonitorSpec struct {
|
||||
// +kubebuilder:validation:Minimum=0
|
||||
// +kubebuilder:default=0
|
||||
MaxRetries int32 `json:"maxRetries,omitempty"`
|
||||
// Consecutive DOWN heartbeats after which Kuma sends the DOWN notification
|
||||
// again, not minutes; 0 sends it once per outage.
|
||||
// +kubebuilder:validation:Minimum=0
|
||||
ResendInterval int32 `json:"resendInterval,omitempty"`
|
||||
// Seconds.
|
||||
// +kubebuilder:validation:Minimum=1
|
||||
Timeout *int32 `json:"timeout,omitempty"`
|
||||
@@ -108,6 +112,17 @@ type KumaMonitorSpec struct {
|
||||
JSONQuery *JSONQuerySpec `json:"jsonQuery,omitempty"`
|
||||
DNS *DNSSpec `json:"dns,omitempty"`
|
||||
Port *PortSpec `json:"port,omitempty"`
|
||||
|
||||
// KumaNotifications attached to the monitor; none attaches no notification.
|
||||
// +listType=map
|
||||
// +listMapKey=name
|
||||
NotificationRefs []NotificationRef `json:"notificationRefs,omitempty"`
|
||||
}
|
||||
|
||||
// NotificationRef references a KumaNotification.
|
||||
type NotificationRef struct {
|
||||
// +kubebuilder:validation:MinLength=1
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type KumaMonitorStatus struct {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package v1alpha1
|
||||
|
||||
import (
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
// SecretRef references a Secret in any namespace.
|
||||
type SecretRef struct {
|
||||
// +kubebuilder:validation:MinLength=1
|
||||
Namespace string `json:"namespace"`
|
||||
// +kubebuilder:validation:MinLength=1
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// SMTPSpec configures an smtp notification. The server and its credentials
|
||||
// come from the Secret: keys host, port, username and password, all required.
|
||||
// Port 465 uses implicit TLS, any other port STARTTLS.
|
||||
type SMTPSpec struct {
|
||||
// +kubebuilder:validation:MinLength=1
|
||||
From string `json:"from"`
|
||||
// +kubebuilder:validation:MinLength=1
|
||||
To string `json:"to"`
|
||||
SecretRef SecretRef `json:"secretRef"`
|
||||
}
|
||||
|
||||
// +kubebuilder:validation:XValidation:rule="(self.type == 'smtp') == has(self.smtp)",message="smtp is required for type smtp and forbidden otherwise"
|
||||
type KumaNotificationSpec struct {
|
||||
// +kubebuilder:validation:Enum=smtp
|
||||
Type string `json:"type"`
|
||||
SMTP *SMTPSpec `json:"smtp,omitempty"`
|
||||
}
|
||||
|
||||
type KumaNotificationStatus struct {
|
||||
NotificationID int64 `json:"notificationID,omitempty"`
|
||||
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
|
||||
// +listType=map
|
||||
// +listMapKey=type
|
||||
Conditions []metav1.Condition `json:"conditions,omitempty"`
|
||||
}
|
||||
|
||||
// KumaNotification is the Kuma notification named like the resource. It is
|
||||
// never a default notification and never applied to existing monitors;
|
||||
// KumaMonitors attach it through notificationRefs.
|
||||
// +kubebuilder:object:root=true
|
||||
// +kubebuilder:subresource:status
|
||||
// +kubebuilder:resource:path=kumanotifications,scope=Cluster
|
||||
// +kubebuilder:printcolumn:name="Type",type=string,JSONPath=`.spec.type`
|
||||
// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status`
|
||||
type KumaNotification struct {
|
||||
metav1.TypeMeta `json:",inline"`
|
||||
metav1.ObjectMeta `json:"metadata,omitempty"`
|
||||
|
||||
Spec KumaNotificationSpec `json:"spec"`
|
||||
Status KumaNotificationStatus `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
// +kubebuilder:object:root=true
|
||||
type KumaNotificationList struct {
|
||||
metav1.TypeMeta `json:",inline"`
|
||||
metav1.ListMeta `json:"metadata,omitempty"`
|
||||
Items []KumaNotification `json:"items"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
SchemeBuilder.Register(&KumaNotification{}, &KumaNotificationList{})
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
//go:build envtest
|
||||
|
||||
package v1alpha1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
func smtpNotification(name string) *KumaNotification {
|
||||
return &KumaNotification{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: name},
|
||||
Spec: KumaNotificationSpec{Type: "smtp", SMTP: &SMTPSpec{
|
||||
From: "kuma@renner.uno", To: "ops@renner.uno",
|
||||
SecretRef: SecretRef{Namespace: "kuma-sync", Name: "smtp"},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func expectNotificationInvalid(t *testing.T, n *KumaNotification, wants ...string) {
|
||||
t.Helper()
|
||||
err := k8s.Create(context.Background(), n)
|
||||
if err == nil || !errors.IsInvalid(err) {
|
||||
t.Fatalf("expected invalid, got %v", err)
|
||||
}
|
||||
for _, w := range wants {
|
||||
if !strings.Contains(err.Error(), w) {
|
||||
t.Fatalf("error %q does not contain %q", err.Error(), w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidNotification(t *testing.T) {
|
||||
if err := k8s.Create(context.Background(), smtpNotification("valid-smtp")); err != nil {
|
||||
t.Fatalf("expected accept, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationInvalidSpecs(t *testing.T) {
|
||||
noSMTP := smtpNotification("no-smtp")
|
||||
noSMTP.Spec.SMTP = nil
|
||||
expectNotificationInvalid(t, noSMTP, "smtp is required for type smtp")
|
||||
|
||||
webhook := smtpNotification("webhook")
|
||||
webhook.Spec.Type = "webhook"
|
||||
expectNotificationInvalid(t, webhook, "spec.type")
|
||||
|
||||
noTo := smtpNotification("no-to")
|
||||
noTo.Spec.SMTP.To = ""
|
||||
expectNotificationInvalid(t, noTo, "spec.smtp.to")
|
||||
|
||||
noSecret := smtpNotification("no-secret")
|
||||
noSecret.Spec.SMTP.SecretRef.Namespace = ""
|
||||
expectNotificationInvalid(t, noSecret, "spec.smtp.secretRef.namespace")
|
||||
}
|
||||
|
||||
func TestMonitorNotificationRefs(t *testing.T) {
|
||||
m := monitor("with-refs", KumaMonitorSpec{
|
||||
Type: "group", DisplayName: "g",
|
||||
NotificationRefs: []NotificationRef{{Name: "mail"}, {Name: "ops"}},
|
||||
})
|
||||
if err := k8s.Create(context.Background(), m); err != nil {
|
||||
t.Fatalf("expected accept, got %v", err)
|
||||
}
|
||||
dup := monitor("dup-refs", KumaMonitorSpec{
|
||||
Type: "group", DisplayName: "g",
|
||||
NotificationRefs: []NotificationRef{{Name: "mail"}, {Name: "mail"}},
|
||||
})
|
||||
expectInvalid(t, dup, "Duplicate value")
|
||||
empty := monitor("empty-ref", KumaMonitorSpec{
|
||||
Type: "group", DisplayName: "g", NotificationRefs: []NotificationRef{{Name: ""}},
|
||||
})
|
||||
expectInvalid(t, empty, "spec.notificationRefs[0].name")
|
||||
}
|
||||
@@ -131,6 +131,15 @@ func TestNegativeMaxRetriesRejected(t *testing.T) {
|
||||
expectInvalid(t, m, "spec.maxRetries")
|
||||
}
|
||||
|
||||
func TestNegativeResendIntervalRejected(t *testing.T) {
|
||||
m := monitor("neg-resend", KumaMonitorSpec{
|
||||
Type: "group",
|
||||
DisplayName: "g",
|
||||
ResendInterval: -1,
|
||||
})
|
||||
expectInvalid(t, m, "spec.resendInterval")
|
||||
}
|
||||
|
||||
func TestJSONQueryBadOperator(t *testing.T) {
|
||||
m := monitor("jq-bad-op", KumaMonitorSpec{
|
||||
Type: "json-query",
|
||||
|
||||
@@ -203,6 +203,11 @@ func (in *KumaMonitorSpec) DeepCopyInto(out *KumaMonitorSpec) {
|
||||
*out = new(PortSpec)
|
||||
**out = **in
|
||||
}
|
||||
if in.NotificationRefs != nil {
|
||||
in, out := &in.NotificationRefs, &out.NotificationRefs
|
||||
*out = make([]NotificationRef, len(*in))
|
||||
copy(*out, *in)
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KumaMonitorSpec.
|
||||
@@ -237,6 +242,107 @@ func (in *KumaMonitorStatus) DeepCopy() *KumaMonitorStatus {
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *KumaNotification) DeepCopyInto(out *KumaNotification) {
|
||||
*out = *in
|
||||
out.TypeMeta = in.TypeMeta
|
||||
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
|
||||
in.Spec.DeepCopyInto(&out.Spec)
|
||||
in.Status.DeepCopyInto(&out.Status)
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KumaNotification.
|
||||
func (in *KumaNotification) DeepCopy() *KumaNotification {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(KumaNotification)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
|
||||
func (in *KumaNotification) DeepCopyObject() runtime.Object {
|
||||
if c := in.DeepCopy(); c != nil {
|
||||
return c
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *KumaNotificationList) DeepCopyInto(out *KumaNotificationList) {
|
||||
*out = *in
|
||||
out.TypeMeta = in.TypeMeta
|
||||
in.ListMeta.DeepCopyInto(&out.ListMeta)
|
||||
if in.Items != nil {
|
||||
in, out := &in.Items, &out.Items
|
||||
*out = make([]KumaNotification, len(*in))
|
||||
for i := range *in {
|
||||
(*in)[i].DeepCopyInto(&(*out)[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KumaNotificationList.
|
||||
func (in *KumaNotificationList) DeepCopy() *KumaNotificationList {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(KumaNotificationList)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
|
||||
func (in *KumaNotificationList) DeepCopyObject() runtime.Object {
|
||||
if c := in.DeepCopy(); c != nil {
|
||||
return c
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *KumaNotificationSpec) DeepCopyInto(out *KumaNotificationSpec) {
|
||||
*out = *in
|
||||
if in.SMTP != nil {
|
||||
in, out := &in.SMTP, &out.SMTP
|
||||
*out = new(SMTPSpec)
|
||||
**out = **in
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KumaNotificationSpec.
|
||||
func (in *KumaNotificationSpec) DeepCopy() *KumaNotificationSpec {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(KumaNotificationSpec)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *KumaNotificationStatus) DeepCopyInto(out *KumaNotificationStatus) {
|
||||
*out = *in
|
||||
if in.Conditions != nil {
|
||||
in, out := &in.Conditions, &out.Conditions
|
||||
*out = make([]v1.Condition, len(*in))
|
||||
for i := range *in {
|
||||
(*in)[i].DeepCopyInto(&(*out)[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KumaNotificationStatus.
|
||||
func (in *KumaNotificationStatus) DeepCopy() *KumaNotificationStatus {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(KumaNotificationStatus)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *KumaStatusPage) DeepCopyInto(out *KumaStatusPage) {
|
||||
*out = *in
|
||||
@@ -365,6 +471,21 @@ func (in *LocalRef) DeepCopy() *LocalRef {
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *NotificationRef) DeepCopyInto(out *NotificationRef) {
|
||||
*out = *in
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NotificationRef.
|
||||
func (in *NotificationRef) DeepCopy() *NotificationRef {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(NotificationRef)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *PortSpec) DeepCopyInto(out *PortSpec) {
|
||||
*out = *in
|
||||
@@ -380,6 +501,37 @@ func (in *PortSpec) DeepCopy() *PortSpec {
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *SMTPSpec) DeepCopyInto(out *SMTPSpec) {
|
||||
*out = *in
|
||||
out.SecretRef = in.SecretRef
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SMTPSpec.
|
||||
func (in *SMTPSpec) DeepCopy() *SMTPSpec {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(SMTPSpec)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *SecretRef) DeepCopyInto(out *SecretRef) {
|
||||
*out = *in
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecretRef.
|
||||
func (in *SecretRef) DeepCopy() *SecretRef {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(SecretRef)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *SectionStatus) DeepCopyInto(out *SectionStatus) {
|
||||
*out = *in
|
||||
|
||||
@@ -3,19 +3,25 @@ package main
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"k8s.io/apimachinery/pkg/util/validation"
|
||||
)
|
||||
|
||||
type config struct {
|
||||
URL string
|
||||
Username string
|
||||
Password string
|
||||
// RootGroup is the zero value when KUMA_ROOT_GROUP is unset.
|
||||
RootGroup types.NamespacedName
|
||||
}
|
||||
|
||||
// loadConfig reads the Kuma connection settings from the environment. It
|
||||
// names every missing variable.
|
||||
// names every missing or invalid variable.
|
||||
func loadConfig(getenv func(string) string) (config, error) {
|
||||
var cfg config
|
||||
var missing []error
|
||||
var errs []error
|
||||
for _, v := range []struct {
|
||||
name string
|
||||
dst *string
|
||||
@@ -25,8 +31,30 @@ func loadConfig(getenv func(string) string) (config, error) {
|
||||
{"KUMA_PASSWORD", &cfg.Password},
|
||||
} {
|
||||
if *v.dst = getenv(v.name); *v.dst == "" {
|
||||
missing = append(missing, fmt.Errorf("missing required environment variable %s", v.name))
|
||||
errs = append(errs, fmt.Errorf("missing required environment variable %s", v.name))
|
||||
}
|
||||
}
|
||||
return cfg, errors.Join(missing...)
|
||||
if raw := getenv("KUMA_ROOT_GROUP"); raw != "" {
|
||||
root, err := parseRootGroup(raw)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("KUMA_ROOT_GROUP: %w", err))
|
||||
}
|
||||
cfg.RootGroup = root
|
||||
}
|
||||
return cfg, errors.Join(errs...)
|
||||
}
|
||||
|
||||
// parseRootGroup parses namespace/name of a KumaMonitor.
|
||||
func parseRootGroup(raw string) (types.NamespacedName, error) {
|
||||
ns, name, ok := strings.Cut(raw, "/")
|
||||
if !ok || strings.Contains(name, "/") {
|
||||
return types.NamespacedName{}, fmt.Errorf("%q is not namespace/name", raw)
|
||||
}
|
||||
if msgs := validation.IsDNS1123Label(ns); len(msgs) > 0 {
|
||||
return types.NamespacedName{}, fmt.Errorf("namespace %q: %s", ns, strings.Join(msgs, "; "))
|
||||
}
|
||||
if msgs := validation.IsDNS1123Subdomain(name); len(msgs) > 0 {
|
||||
return types.NamespacedName{}, fmt.Errorf("name %q: %s", name, strings.Join(msgs, "; "))
|
||||
}
|
||||
return types.NamespacedName{Namespace: ns, Name: name}, nil
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package main
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
)
|
||||
|
||||
func TestLoadConfig(t *testing.T) {
|
||||
@@ -36,3 +38,21 @@ func TestLoadConfig(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigRootGroup(t *testing.T) {
|
||||
env := func(root string) func(string) string {
|
||||
m := map[string]string{"KUMA_URL": "http://kuma", "KUMA_USERNAME": "u", "KUMA_PASSWORD": "p", "KUMA_ROOT_GROUP": root}
|
||||
return func(k string) string { return m[k] }
|
||||
}
|
||||
|
||||
cfg, err := loadConfig(env("kuma-sync/renner-uno"))
|
||||
if err != nil || cfg.RootGroup != (types.NamespacedName{Namespace: "kuma-sync", Name: "renner-uno"}) {
|
||||
t.Fatalf("cfg=%+v err=%v", cfg, err)
|
||||
}
|
||||
|
||||
for _, bad := range []string{"renner-uno", "/renner-uno", "kuma-sync/", "a/b/c", "Kuma/x", "ns/UPPER", " ns/x"} {
|
||||
if _, err := loadConfig(env(bad)); err == nil || !strings.Contains(err.Error(), "KUMA_ROOT_GROUP") {
|
||||
t.Errorf("%q: err = %v, want KUMA_ROOT_GROUP rejected", bad, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ func run() error {
|
||||
GCInterval: 5 * time.Minute,
|
||||
OrphanGrace: time.Hour,
|
||||
Resync: 5 * time.Minute,
|
||||
RootGroup: cfg.RootGroup,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -182,6 +182,22 @@ spec:
|
||||
format: int32
|
||||
minimum: 0
|
||||
type: integer
|
||||
notificationRefs:
|
||||
description: KumaNotifications attached to the monitor; none attaches
|
||||
no notification.
|
||||
items:
|
||||
description: NotificationRef references a KumaNotification.
|
||||
properties:
|
||||
name:
|
||||
minLength: 1
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
type: object
|
||||
type: array
|
||||
x-kubernetes-list-map-keys:
|
||||
- name
|
||||
x-kubernetes-list-type: map
|
||||
parentRef:
|
||||
description: LocalRef references a KumaMonitor in the same namespace.
|
||||
properties:
|
||||
@@ -206,6 +222,13 @@ spec:
|
||||
- hostname
|
||||
- port
|
||||
type: object
|
||||
resendInterval:
|
||||
description: |-
|
||||
Consecutive DOWN heartbeats after which Kuma sends the DOWN notification
|
||||
again, not minutes; 0 sends it once per outage.
|
||||
format: int32
|
||||
minimum: 0
|
||||
type: integer
|
||||
retryInterval:
|
||||
default: 60
|
||||
description: Seconds.
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
---
|
||||
apiVersion: apiextensions.k8s.io/v1
|
||||
kind: CustomResourceDefinition
|
||||
metadata:
|
||||
annotations:
|
||||
controller-gen.kubebuilder.io/version: v0.23.0
|
||||
name: kumanotifications.uptime.renner.uno
|
||||
spec:
|
||||
group: uptime.renner.uno
|
||||
names:
|
||||
kind: KumaNotification
|
||||
listKind: KumaNotificationList
|
||||
plural: kumanotifications
|
||||
singular: kumanotification
|
||||
scope: Cluster
|
||||
versions:
|
||||
- additionalPrinterColumns:
|
||||
- jsonPath: .spec.type
|
||||
name: Type
|
||||
type: string
|
||||
- jsonPath: .status.conditions[?(@.type=="Ready")].status
|
||||
name: Ready
|
||||
type: string
|
||||
name: v1alpha1
|
||||
schema:
|
||||
openAPIV3Schema:
|
||||
description: |-
|
||||
KumaNotification is the Kuma notification named like the resource. It is
|
||||
never a default notification and never applied to existing monitors;
|
||||
KumaMonitors attach it through notificationRefs.
|
||||
properties:
|
||||
apiVersion:
|
||||
description: |-
|
||||
APIVersion defines the versioned schema of this representation of an object.
|
||||
Servers should convert recognized schemas to the latest internal value, and
|
||||
may reject unrecognized values.
|
||||
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
|
||||
type: string
|
||||
kind:
|
||||
description: |-
|
||||
Kind is a string value representing the REST resource this object represents.
|
||||
Servers may infer this from the endpoint the client submits requests to.
|
||||
Cannot be updated.
|
||||
In CamelCase.
|
||||
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
|
||||
type: string
|
||||
metadata:
|
||||
type: object
|
||||
spec:
|
||||
properties:
|
||||
smtp:
|
||||
description: |-
|
||||
SMTPSpec configures an smtp notification. The server and its credentials
|
||||
come from the Secret: keys host, port, username and password, all required.
|
||||
Port 465 uses implicit TLS, any other port STARTTLS.
|
||||
properties:
|
||||
from:
|
||||
minLength: 1
|
||||
type: string
|
||||
secretRef:
|
||||
description: SecretRef references a Secret in any namespace.
|
||||
properties:
|
||||
name:
|
||||
minLength: 1
|
||||
type: string
|
||||
namespace:
|
||||
minLength: 1
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
- namespace
|
||||
type: object
|
||||
to:
|
||||
minLength: 1
|
||||
type: string
|
||||
required:
|
||||
- from
|
||||
- secretRef
|
||||
- to
|
||||
type: object
|
||||
type:
|
||||
enum:
|
||||
- smtp
|
||||
type: string
|
||||
required:
|
||||
- type
|
||||
type: object
|
||||
x-kubernetes-validations:
|
||||
- message: smtp is required for type smtp and forbidden otherwise
|
||||
rule: (self.type == 'smtp') == has(self.smtp)
|
||||
status:
|
||||
properties:
|
||||
conditions:
|
||||
items:
|
||||
description: Condition contains details for one aspect of the current
|
||||
state of this API Resource.
|
||||
properties:
|
||||
lastTransitionTime:
|
||||
description: |-
|
||||
lastTransitionTime is the last time the condition transitioned from one status to another.
|
||||
This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
|
||||
format: date-time
|
||||
type: string
|
||||
message:
|
||||
description: |-
|
||||
message is a human readable message indicating details about the transition.
|
||||
This may be an empty string.
|
||||
maxLength: 32768
|
||||
type: string
|
||||
observedGeneration:
|
||||
description: |-
|
||||
observedGeneration represents the .metadata.generation that the condition was set based upon.
|
||||
For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
|
||||
with respect to the current state of the instance.
|
||||
format: int64
|
||||
minimum: 0
|
||||
type: integer
|
||||
reason:
|
||||
description: |-
|
||||
reason contains a programmatic identifier indicating the reason for the condition's last transition.
|
||||
Producers of specific condition types may define expected values and meanings for this field,
|
||||
and whether the values are considered a guaranteed API.
|
||||
The value should be a CamelCase string.
|
||||
This field may not be empty.
|
||||
maxLength: 1024
|
||||
minLength: 1
|
||||
pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
|
||||
type: string
|
||||
status:
|
||||
description: status of the condition, one of True, False, Unknown.
|
||||
enum:
|
||||
- "True"
|
||||
- "False"
|
||||
- Unknown
|
||||
type: string
|
||||
type:
|
||||
description: type of condition in CamelCase or in foo.example.com/CamelCase.
|
||||
maxLength: 316
|
||||
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
|
||||
type: string
|
||||
required:
|
||||
- lastTransitionTime
|
||||
- message
|
||||
- reason
|
||||
- status
|
||||
- type
|
||||
type: object
|
||||
type: array
|
||||
x-kubernetes-list-map-keys:
|
||||
- type
|
||||
x-kubernetes-list-type: map
|
||||
notificationID:
|
||||
format: int64
|
||||
type: integer
|
||||
observedGeneration:
|
||||
format: int64
|
||||
type: integer
|
||||
type: object
|
||||
required:
|
||||
- spec
|
||||
type: object
|
||||
served: true
|
||||
storage: true
|
||||
subresources:
|
||||
status: {}
|
||||
@@ -21,6 +21,7 @@ rules:
|
||||
- uptime.renner.uno
|
||||
resources:
|
||||
- kumamonitors
|
||||
- kumanotifications
|
||||
- kumastatuspages
|
||||
verbs:
|
||||
- get
|
||||
@@ -32,6 +33,7 @@ rules:
|
||||
- uptime.renner.uno
|
||||
resources:
|
||||
- kumamonitors/finalizers
|
||||
- kumanotifications/finalizers
|
||||
- kumastatuspages/finalizers
|
||||
verbs:
|
||||
- update
|
||||
@@ -39,6 +41,7 @@ rules:
|
||||
- uptime.renner.uno
|
||||
resources:
|
||||
- kumamonitors/status
|
||||
- kumanotifications/status
|
||||
- kumastatuspages/status
|
||||
verbs:
|
||||
- get
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
|
||||
"k8s.io/client-go/rest"
|
||||
"k8s.io/utils/ptr"
|
||||
@@ -41,6 +42,9 @@ type Options struct {
|
||||
// Resync is how often a healthy resource is reconciled again, which is
|
||||
// what repairs drift made in Kuma; 0 means 5 minutes.
|
||||
Resync time.Duration
|
||||
// RootGroup is the group KumaMonitor that monitors without a parentRef are
|
||||
// placed under; the zero value leaves them at the top level.
|
||||
RootGroup types.NamespacedName
|
||||
|
||||
// SkipNameValidation allows several managers in one process, as in tests
|
||||
// that restart the manager.
|
||||
@@ -72,12 +76,19 @@ func NewManager(o Options) (manager.Manager, error) {
|
||||
return nil, fmt.Errorf("create manager: %w", err)
|
||||
}
|
||||
|
||||
if err := (&controller.Reconciler{Client: mgr.GetClient(), Kuma: o.Kuma, Resync: o.Resync}).SetupWithManager(mgr); err != nil {
|
||||
if err := (&controller.Reconciler{
|
||||
Client: mgr.GetClient(), Kuma: o.Kuma, Resync: o.Resync, RootGroup: o.RootGroup,
|
||||
}).SetupWithManager(mgr); err != nil {
|
||||
return nil, fmt.Errorf("set up reconciler: %w", err)
|
||||
}
|
||||
if err := (&controller.StatusPageReconciler{Client: mgr.GetClient(), Kuma: o.Kuma, Resync: o.Resync}).SetupWithManager(mgr); err != nil {
|
||||
return nil, fmt.Errorf("set up status page reconciler: %w", err)
|
||||
}
|
||||
if err := (&controller.NotificationReconciler{
|
||||
Client: mgr.GetClient(), APIReader: mgr.GetAPIReader(), Kuma: o.Kuma, Resync: o.Resync,
|
||||
}).SetupWithManager(mgr); err != nil {
|
||||
return nil, fmt.Errorf("set up notification reconciler: %w", err)
|
||||
}
|
||||
if err := mgr.Add(&controller.GC{
|
||||
Client: mgr.GetClient(),
|
||||
Kuma: o.Kuma,
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -21,6 +20,7 @@ import (
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
|
||||
"k8s.io/client-go/util/retry"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/envtest"
|
||||
@@ -85,6 +85,9 @@ func newE2E(t *testing.T) (*e2e, *envtest.Environment) {
|
||||
t.Cleanup(func() { _ = env.Stop() })
|
||||
|
||||
scheme := runtime.NewScheme()
|
||||
if err := clientgoscheme.AddToScheme(scheme); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := v1.AddToScheme(scheme); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -411,46 +414,6 @@ func (f *e2e) webhook(name string, isDefault bool) int64 {
|
||||
return id
|
||||
}
|
||||
|
||||
func TestE2EDefaultNotifications(t *testing.T) {
|
||||
f, env := newE2E(t)
|
||||
first := f.webhook("first", true)
|
||||
f.webhook("opt-in", false)
|
||||
f.start(env)
|
||||
f.apply(group("g", ""), dns("probe", "g"))
|
||||
|
||||
carry := func(want ...int64) func() (bool, string) {
|
||||
return func() (bool, string) {
|
||||
m := byKey(f.owned())
|
||||
for _, k := range []string{"default/g", "default/probe"} {
|
||||
got, ok := m[k]
|
||||
if !ok {
|
||||
return false, k + " missing"
|
||||
}
|
||||
if !slices.Equal(got.NotificationIDs, want) {
|
||||
return false, fmt.Sprintf("%s carries %v, want %v", k, got.NotificationIDs, want)
|
||||
}
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
}
|
||||
eventually(t, 30*time.Second, "monitors created with the default notification", carry(first))
|
||||
|
||||
second := f.webhook("second", true)
|
||||
want := []int64{first, second}
|
||||
slices.Sort(want)
|
||||
eventually(t, 30*time.Second, "new default notification attached by a resync", carry(want...))
|
||||
|
||||
var live monitor.DNS
|
||||
if err := f.raw.GetMonitorAs(t.Context(), byKey(f.owned())["default/probe"].ID, &live); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
live.NotificationIDs = nil
|
||||
if err := f.raw.UpdateMonitor(t.Context(), &live); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
eventually(t, 30*time.Second, "notifications removed by hand restored by a resync", carry(want...))
|
||||
}
|
||||
|
||||
// A monitor that lost its ownership tag in the UI must not get a twin when the
|
||||
// controller restarts: the resource reports NotOwned and nothing is created.
|
||||
func TestE2ETagRemovedNeverDuplicates(t *testing.T) {
|
||||
|
||||
@@ -17,8 +17,11 @@ import (
|
||||
"sigs.k8s.io/controller-runtime/pkg/builder"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
|
||||
"sigs.k8s.io/controller-runtime/pkg/event"
|
||||
"sigs.k8s.io/controller-runtime/pkg/handler"
|
||||
"sigs.k8s.io/controller-runtime/pkg/log"
|
||||
"sigs.k8s.io/controller-runtime/pkg/predicate"
|
||||
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
||||
|
||||
v1 "gitea.renner.uno/konairius/homelab/kuma-sync/api/v1alpha1"
|
||||
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/kumaclient"
|
||||
@@ -27,15 +30,16 @@ import (
|
||||
const (
|
||||
Finalizer = "uptime.renner.uno/kuma-sync"
|
||||
|
||||
ReasonSynced = "Synced"
|
||||
ReasonParentPending = "ParentPending"
|
||||
ReasonInvalidParent = "InvalidParent"
|
||||
ReasonOwnerTagMissing = "OwnerTagMissing"
|
||||
ReasonOwnerConflict = "OwnerConflict"
|
||||
ReasonDuplicateOwner = "DuplicateOwner"
|
||||
ReasonKumaUnavailable = "KumaUnavailable"
|
||||
ReasonPossibleOrphan = "PossibleOrphan"
|
||||
ReasonNotOwned = "NotOwned"
|
||||
ReasonSynced = "Synced"
|
||||
ReasonParentPending = "ParentPending"
|
||||
ReasonInvalidParent = "InvalidParent"
|
||||
ReasonOwnerTagMissing = "OwnerTagMissing"
|
||||
ReasonOwnerConflict = "OwnerConflict"
|
||||
ReasonDuplicateOwner = "DuplicateOwner"
|
||||
ReasonKumaUnavailable = "KumaUnavailable"
|
||||
ReasonPossibleOrphan = "PossibleOrphan"
|
||||
ReasonNotOwned = "NotOwned"
|
||||
ReasonNotificationPending = "NotificationPending"
|
||||
|
||||
parentRetry = 10 * time.Second
|
||||
resyncPeriod = 5 * time.Minute
|
||||
@@ -54,6 +58,15 @@ type Reconciler struct {
|
||||
// Resync is how often a healthy resource is reconciled again, which repairs
|
||||
// drift made in Kuma. 0 means 5 minutes.
|
||||
Resync time.Duration
|
||||
// RootGroup is the group KumaMonitor every monitor without a parentRef is
|
||||
// placed under in Kuma; the zero value places them at the top level.
|
||||
RootGroup types.NamespacedName
|
||||
}
|
||||
|
||||
func (r *Reconciler) hasRoot() bool { return r.RootGroup.Name != "" }
|
||||
|
||||
func (r *Reconciler) isRoot(o client.Object) bool {
|
||||
return r.hasRoot() && o.GetNamespace() == r.RootGroup.Namespace && o.GetName() == r.RootGroup.Name
|
||||
}
|
||||
|
||||
func (r *Reconciler) resync() time.Duration {
|
||||
@@ -65,12 +78,19 @@ func (r *Reconciler) resync() time.Duration {
|
||||
|
||||
// SetupWithManager ignores status-only updates, which keeps failure backoff
|
||||
// intact. Deletion still reconciles: setting deletionTimestamp bumps
|
||||
// metadata.generation.
|
||||
// metadata.generation. A KumaNotification whose ID changes enqueues the
|
||||
// monitors referencing it; a change to the root group enqueues the monitors
|
||||
// without a parentRef.
|
||||
func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error {
|
||||
return ctrl.NewControllerManagedBy(mgr).
|
||||
b := ctrl.NewControllerManagedBy(mgr).
|
||||
For(&v1.KumaMonitor{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})).
|
||||
Named("kumamonitor").
|
||||
Complete(r)
|
||||
Watches(&v1.KumaNotification{}, handler.EnqueueRequestsFromMapFunc(r.referencingMonitors),
|
||||
builder.WithPredicates(predicate.Funcs{UpdateFunc: notificationIDChanged}))
|
||||
if r.hasRoot() {
|
||||
b = b.Watches(&v1.KumaMonitor{}, handler.EnqueueRequestsFromMapFunc(r.rootChildren),
|
||||
builder.WithPredicates(r.rootPredicate()))
|
||||
}
|
||||
return b.Named("kumamonitor").Complete(r)
|
||||
}
|
||||
|
||||
// failure is a Ready=False outcome. The message is fixed per reason; the cause
|
||||
@@ -82,18 +102,22 @@ type failure struct {
|
||||
}
|
||||
|
||||
const (
|
||||
msgKumaUnavailable = "Kuma request failed"
|
||||
msgListIncomplete = "monitor list incomplete: owner conflict on another monitor"
|
||||
msgOwnerTagMissing = "kuma-sync tag missing in Kuma"
|
||||
msgOwnerConflict = "monitor carries conflicting kuma-sync tag values"
|
||||
msgPossibleOrphan = "create outcome unknown, an untagged monitor may exist in Kuma"
|
||||
msgDuplicateOwner = "several monitors carry this owner key; syncing the lowest ID"
|
||||
msgParentPending = "parent has no monitor ID yet"
|
||||
msgParentNotGroup = "parent must be of type group"
|
||||
msgParentCycle = "parentRef forms a cycle"
|
||||
msgParentTooDeep = "parentRef chain is too deep"
|
||||
msgSynced = "monitor in sync"
|
||||
msgNotOwned = "previous monitor exists without our ownership tag; restore the tag or delete it in Kuma"
|
||||
msgKumaUnavailable = "Kuma request failed"
|
||||
msgListIncomplete = "monitor list incomplete: owner conflict on another monitor"
|
||||
msgOwnerTagMissing = "kuma-sync tag missing in Kuma"
|
||||
msgOwnerConflict = "monitor carries conflicting kuma-sync tag values"
|
||||
msgPossibleOrphan = "create outcome unknown, an untagged monitor may exist in Kuma"
|
||||
msgDuplicateOwner = "several monitors carry this owner key; syncing the lowest ID"
|
||||
msgParentPending = "parent has no monitor ID yet"
|
||||
msgParentNotGroup = "parent must be of type group"
|
||||
msgParentCycle = "parentRef forms a cycle"
|
||||
msgParentTooDeep = "parentRef chain is too deep"
|
||||
msgRootPending = "root group has no monitor ID yet"
|
||||
msgRootNotGroup = "root group must be of type group"
|
||||
msgRootHasParent = "root group must not have a parentRef"
|
||||
msgNotificationPending = "a referenced notification has no notification ID yet"
|
||||
msgSynced = "monitor in sync"
|
||||
msgNotOwned = "previous monitor exists without our ownership tag; restore the tag or delete it in Kuma"
|
||||
)
|
||||
|
||||
func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
|
||||
@@ -111,29 +135,43 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
|
||||
}
|
||||
|
||||
desired := Desired(km)
|
||||
if km.Spec.ParentRef != nil {
|
||||
p, err := r.resolveParent(ctx, km)
|
||||
if err != nil {
|
||||
var p parentOutcome
|
||||
var err error
|
||||
switch {
|
||||
case km.Spec.ParentRef != nil:
|
||||
p, err = r.resolveParent(ctx, km)
|
||||
case r.hasRoot() && !r.isRoot(km):
|
||||
p, err = r.resolveRoot(ctx)
|
||||
}
|
||||
if err != nil {
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
if p.reason != "" {
|
||||
if err := r.setReady(ctx, km, metav1.ConditionFalse, p.reason, p.message, nil); err != nil {
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
if p.reason != "" {
|
||||
if err := r.setReady(ctx, km, metav1.ConditionFalse, p.reason, p.message, nil); err != nil {
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
return ctrl.Result{RequeueAfter: p.requeue}, nil
|
||||
}
|
||||
return ctrl.Result{RequeueAfter: p.requeue}, nil
|
||||
}
|
||||
if p.id != 0 {
|
||||
desired.Parent = &p.id
|
||||
}
|
||||
|
||||
notifications, pending, err := r.resolveNotifications(ctx, km)
|
||||
if err != nil {
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
if pending {
|
||||
if err := r.setReady(ctx, km, metav1.ConditionFalse, ReasonNotificationPending, msgNotificationPending, nil); err != nil {
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
return ctrl.Result{RequeueAfter: parentRetry}, nil
|
||||
}
|
||||
desired.NotificationIDs = notifications
|
||||
|
||||
res, fail := r.lookup(ctx, desired.OwnerKey)
|
||||
if fail != nil {
|
||||
return r.fail(ctx, km, fail)
|
||||
}
|
||||
notifications, err := r.Kuma.DefaultNotifications(ctx)
|
||||
if err != nil {
|
||||
return r.fail(ctx, km, &failure{ReasonKumaUnavailable, msgKumaUnavailable, err})
|
||||
}
|
||||
desired.NotificationIDs = notifications
|
||||
|
||||
var id int64
|
||||
if res.mon == nil {
|
||||
@@ -250,6 +288,57 @@ func (r *Reconciler) lookup(ctx context.Context, key string) (lookupResult, *fai
|
||||
return res, &failure{ReasonKumaUnavailable, msgListIncomplete, err}
|
||||
}
|
||||
|
||||
// resolveNotifications returns the Kuma IDs of the referenced
|
||||
// KumaNotifications. pending reports a reference that does not exist, has no
|
||||
// notification ID yet or is being deleted. No references yield nil, which
|
||||
// detaches every notification in Kuma.
|
||||
func (r *Reconciler) resolveNotifications(ctx context.Context, km *v1.KumaMonitor) (ids []int64, pending bool, err error) {
|
||||
for _, ref := range km.Spec.NotificationRefs {
|
||||
kn := &v1.KumaNotification{}
|
||||
err := r.Client.Get(ctx, types.NamespacedName{Name: ref.Name}, kn)
|
||||
if apierrors.IsNotFound(err) {
|
||||
return nil, true, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if kn.Status.NotificationID == 0 || !kn.DeletionTimestamp.IsZero() {
|
||||
return nil, true, nil
|
||||
}
|
||||
ids = append(ids, kn.Status.NotificationID)
|
||||
}
|
||||
return ids, false, nil
|
||||
}
|
||||
|
||||
// notificationIDChanged reports changes that alter what a referencing
|
||||
// monitor attaches.
|
||||
func notificationIDChanged(e event.UpdateEvent) bool {
|
||||
o, ok1 := e.ObjectOld.(*v1.KumaNotification)
|
||||
n, ok2 := e.ObjectNew.(*v1.KumaNotification)
|
||||
if !ok1 || !ok2 {
|
||||
return true
|
||||
}
|
||||
return o.Status.NotificationID != n.Status.NotificationID ||
|
||||
o.DeletionTimestamp.IsZero() != n.DeletionTimestamp.IsZero()
|
||||
}
|
||||
|
||||
// referencingMonitors enqueues the KumaMonitors of all namespaces that
|
||||
// reference the notification.
|
||||
func (r *Reconciler) referencingMonitors(ctx context.Context, o client.Object) []reconcile.Request {
|
||||
var list v1.KumaMonitorList
|
||||
if err := r.Client.List(ctx, &list); err != nil {
|
||||
log.FromContext(ctx).Error(err, "list KumaMonitors to enqueue")
|
||||
return nil
|
||||
}
|
||||
var reqs []reconcile.Request
|
||||
for _, km := range list.Items {
|
||||
if slices.ContainsFunc(km.Spec.NotificationRefs, func(ref v1.NotificationRef) bool { return ref.Name == o.GetName() }) {
|
||||
reqs = append(reqs, reconcile.Request{NamespacedName: types.NamespacedName{Namespace: km.Namespace, Name: km.Name}})
|
||||
}
|
||||
}
|
||||
return reqs
|
||||
}
|
||||
|
||||
type parentOutcome struct {
|
||||
id int64
|
||||
reason string // empty: resolved
|
||||
@@ -257,8 +346,75 @@ type parentOutcome struct {
|
||||
requeue time.Duration
|
||||
}
|
||||
|
||||
// rootChanged reports changes to the root group that alter what the monitors
|
||||
// without a parentRef resolve to.
|
||||
func rootChanged(e event.UpdateEvent) bool {
|
||||
o, ok1 := e.ObjectOld.(*v1.KumaMonitor)
|
||||
n, ok2 := e.ObjectNew.(*v1.KumaMonitor)
|
||||
if !ok1 || !ok2 {
|
||||
return true
|
||||
}
|
||||
return o.Status.MonitorID != n.Status.MonitorID ||
|
||||
o.Spec.Type != n.Spec.Type ||
|
||||
(o.Spec.ParentRef == nil) != (n.Spec.ParentRef == nil) ||
|
||||
o.DeletionTimestamp.IsZero() != n.DeletionTimestamp.IsZero()
|
||||
}
|
||||
|
||||
// rootPredicate passes events of the root group only.
|
||||
func (r *Reconciler) rootPredicate() predicate.Funcs {
|
||||
return predicate.Funcs{
|
||||
CreateFunc: func(e event.CreateEvent) bool { return r.isRoot(e.Object) },
|
||||
DeleteFunc: func(e event.DeleteEvent) bool { return r.isRoot(e.Object) },
|
||||
GenericFunc: func(e event.GenericEvent) bool { return r.isRoot(e.Object) },
|
||||
UpdateFunc: func(e event.UpdateEvent) bool { return r.isRoot(e.ObjectNew) && rootChanged(e) },
|
||||
}
|
||||
}
|
||||
|
||||
// rootChildren enqueues the KumaMonitors of all namespaces that have no
|
||||
// parentRef, except the root group itself.
|
||||
func (r *Reconciler) rootChildren(ctx context.Context, _ client.Object) []reconcile.Request {
|
||||
var list v1.KumaMonitorList
|
||||
if err := r.Client.List(ctx, &list); err != nil {
|
||||
log.FromContext(ctx).Error(err, "list KumaMonitors to enqueue")
|
||||
return nil
|
||||
}
|
||||
var reqs []reconcile.Request
|
||||
for i := range list.Items {
|
||||
km := &list.Items[i]
|
||||
if km.Spec.ParentRef == nil && !r.isRoot(km) {
|
||||
reqs = append(reqs, reconcile.Request{NamespacedName: types.NamespacedName{Namespace: km.Namespace, Name: km.Name}})
|
||||
}
|
||||
}
|
||||
return reqs
|
||||
}
|
||||
|
||||
// resolveRoot returns the Kuma ID of the root group for a monitor without a
|
||||
// parentRef. The root must be a group without a parentRef: under one of its
|
||||
// own ancestors it would close a cycle in Kuma.
|
||||
func (r *Reconciler) resolveRoot(ctx context.Context) (parentOutcome, error) {
|
||||
pending := parentOutcome{reason: ReasonParentPending, message: msgRootPending, requeue: parentRetry}
|
||||
root := &v1.KumaMonitor{}
|
||||
err := r.Client.Get(ctx, r.RootGroup, root)
|
||||
if apierrors.IsNotFound(err) {
|
||||
return pending, nil
|
||||
}
|
||||
if err != nil {
|
||||
return parentOutcome{}, err
|
||||
}
|
||||
switch {
|
||||
case root.Spec.Type != "group":
|
||||
return parentOutcome{reason: ReasonInvalidParent, message: msgRootNotGroup, requeue: r.resync()}, nil
|
||||
case root.Spec.ParentRef != nil:
|
||||
return parentOutcome{reason: ReasonInvalidParent, message: msgRootHasParent, requeue: r.resync()}, nil
|
||||
case root.Status.MonitorID == 0 || !root.DeletionTimestamp.IsZero():
|
||||
return pending, nil
|
||||
}
|
||||
return parentOutcome{id: root.Status.MonitorID}, nil
|
||||
}
|
||||
|
||||
// resolveParent checks the whole parentRef chain: every ancestor must exist
|
||||
// and be a group, without cycles or more than maxParentDepth levels.
|
||||
// and be a group, without cycles or more than maxParentDepth levels. The root
|
||||
// group, which the topmost ancestor is placed under, counts as a level.
|
||||
func (r *Reconciler) resolveParent(ctx context.Context, km *v1.KumaMonitor) (parentOutcome, error) {
|
||||
invalid := func(msg string) (parentOutcome, error) {
|
||||
return parentOutcome{reason: ReasonInvalidParent, message: msg, requeue: r.resync()}, nil
|
||||
@@ -268,7 +424,8 @@ func (r *Reconciler) resolveParent(ctx context.Context, km *v1.KumaMonitor) (par
|
||||
visited := map[string]bool{km.Name: true}
|
||||
cur := km
|
||||
var directID int64
|
||||
for depth := 0; cur.Spec.ParentRef != nil; depth++ {
|
||||
depth := 0
|
||||
for ; cur.Spec.ParentRef != nil; depth++ {
|
||||
if depth >= maxParentDepth {
|
||||
return invalid(msgParentTooDeep)
|
||||
}
|
||||
@@ -293,6 +450,9 @@ func (r *Reconciler) resolveParent(ctx context.Context, km *v1.KumaMonitor) (par
|
||||
}
|
||||
cur = parent
|
||||
}
|
||||
if r.hasRoot() && !r.isRoot(cur) && depth >= maxParentDepth {
|
||||
return invalid(msgParentTooDeep)
|
||||
}
|
||||
if directID == 0 {
|
||||
return pending, nil
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -311,79 +310,6 @@ func TestReconcileListErrorNoCreate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileCreateGetsDefaultNotifications(t *testing.T) {
|
||||
group := &v1.KumaMonitor{
|
||||
ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "grp"},
|
||||
Spec: v1.KumaMonitorSpec{Type: "group", DisplayName: "grp", Interval: 60, RetryInterval: 60},
|
||||
}
|
||||
e := newEnv(t, httpMonitor("a"), group)
|
||||
e.kuma.SetDefaultNotifications(7, 3)
|
||||
for _, name := range []string{"a", "grp"} {
|
||||
if _, err := e.reconcile(t, name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := e.kuma.Get(e.get(t, name).Status.MonitorID)
|
||||
if got == nil || !slices.Equal(got.NotificationIDs, []int64{3, 7}) {
|
||||
t.Errorf("%s: kuma monitor = %+v, want notifications [3 7]", name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileRevertsNotificationDrift(t *testing.T) {
|
||||
km := httpMonitor("a")
|
||||
e := newEnv(t, km)
|
||||
e.kuma.SetDefaultNotifications(1, 2)
|
||||
drifted := Desired(km)
|
||||
drifted.NotificationIDs = []int64{2, 9}
|
||||
id, _ := e.kuma.Create(t.Context(), drifted)
|
||||
if _, err := e.reconcile(t, "a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e.cnt.updates != 1 || e.cnt.creates != 0 {
|
||||
t.Fatalf("updates=%d creates=%d", e.cnt.updates, e.cnt.creates)
|
||||
}
|
||||
if got := e.kuma.Get(id).NotificationIDs; !slices.Equal(got, []int64{1, 2}) {
|
||||
t.Errorf("notifications = %v, want [1 2]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileUnchangedNotificationsNoUpdate(t *testing.T) {
|
||||
km := httpMonitor("a")
|
||||
e := newEnv(t, km)
|
||||
e.kuma.SetDefaultNotifications(4, 2)
|
||||
existing := Desired(km)
|
||||
existing.NotificationIDs = []int64{4, 2}
|
||||
e.kuma.Create(t.Context(), existing)
|
||||
for range 2 {
|
||||
if _, err := e.reconcile(t, "a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if e.cnt.updates != 0 || e.cnt.creates != 0 {
|
||||
t.Fatalf("updates=%d creates=%d, want 0 and 0", e.cnt.updates, e.cnt.creates)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileNotificationListErrorNoWrite(t *testing.T) {
|
||||
adopt := httpMonitor("b")
|
||||
e := newEnv(t, httpMonitor("a"), adopt)
|
||||
drifted := Desired(adopt)
|
||||
drifted.Interval = 30
|
||||
e.kuma.Create(t.Context(), drifted)
|
||||
e.kuma.SetNotificationsErr(errors.New("boom"))
|
||||
for _, name := range []string{"a", "b"} {
|
||||
if _, err := e.reconcile(t, name); err == nil {
|
||||
t.Fatalf("%s: expected error", name)
|
||||
}
|
||||
if c := ready(t, e.get(t, name)); c.Status != metav1.ConditionFalse || c.Reason != ReasonKumaUnavailable {
|
||||
t.Errorf("%s: Ready = %+v", name, c)
|
||||
}
|
||||
}
|
||||
if e.cnt.creates != 0 || e.cnt.updates != 0 {
|
||||
t.Errorf("creates=%d updates=%d", e.cnt.creates, e.cnt.updates)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileOwnerTagMissing(t *testing.T) {
|
||||
e := newEnv(t, httpMonitor("a"))
|
||||
e.kuma.SetErrs(kumaclient.ErrOwnerTagMissing, nil, nil, nil)
|
||||
|
||||
@@ -24,14 +24,15 @@ func OwnerKey(km *v1.KumaMonitor) string {
|
||||
func Desired(km *v1.KumaMonitor) kumaclient.Monitor {
|
||||
s := km.Spec
|
||||
m := kumaclient.Monitor{
|
||||
Type: s.Type,
|
||||
Name: s.DisplayName,
|
||||
Description: s.Description,
|
||||
Active: !s.Paused,
|
||||
Interval: s.Interval,
|
||||
RetryInterval: s.RetryInterval,
|
||||
MaxRetries: s.MaxRetries,
|
||||
OwnerKey: OwnerKey(km),
|
||||
Type: s.Type,
|
||||
Name: s.DisplayName,
|
||||
Description: s.Description,
|
||||
Active: !s.Paused,
|
||||
Interval: s.Interval,
|
||||
RetryInterval: s.RetryInterval,
|
||||
MaxRetries: s.MaxRetries,
|
||||
ResendInterval: s.ResendInterval,
|
||||
OwnerKey: OwnerKey(km),
|
||||
}
|
||||
if s.Timeout != nil {
|
||||
m.Timeout = *s.Timeout
|
||||
|
||||
@@ -57,6 +57,8 @@ func TestMappingAllTypes(t *testing.T) {
|
||||
port.Port = &v1.PortSpec{Hostname: "p.com", Port: 443}
|
||||
paused := base("group")
|
||||
paused.Paused = true
|
||||
resend := base("group")
|
||||
resend.ResendInterval = 60
|
||||
|
||||
wantKW := withHTTP(common("keyword"))
|
||||
wantKW.Keyword, wantKW.InvertKeyword = "ok", true
|
||||
@@ -74,6 +76,8 @@ func TestMappingAllTypes(t *testing.T) {
|
||||
wantPort.Hostname, wantPort.Port = "p.com", 443
|
||||
wantPaused := common("group")
|
||||
wantPaused.Active = false
|
||||
wantResend := common("group")
|
||||
wantResend.ResendInterval = 60
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -88,6 +92,7 @@ func TestMappingAllTypes(t *testing.T) {
|
||||
{"dns with conditions", dnsCond, wantDNSCond},
|
||||
{"port", port, wantPort},
|
||||
{"paused maps to inactive", paused, wantPaused},
|
||||
{"resend interval", resend, wantResend},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := Desired(mk(c.spec))
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
"k8s.io/apimachinery/pkg/api/meta"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
ctrl "sigs.k8s.io/controller-runtime"
|
||||
"sigs.k8s.io/controller-runtime/pkg/builder"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
|
||||
"sigs.k8s.io/controller-runtime/pkg/log"
|
||||
"sigs.k8s.io/controller-runtime/pkg/predicate"
|
||||
|
||||
v1 "gitea.renner.uno/konairius/homelab/kuma-sync/api/v1alpha1"
|
||||
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/kumaclient"
|
||||
)
|
||||
|
||||
const (
|
||||
ReasonSecretMissing = "SecretMissing"
|
||||
ReasonInvalidSecret = "InvalidSecret"
|
||||
ReasonInvalidSpec = "InvalidSpec"
|
||||
|
||||
msgNotificationSynced = "notification in sync"
|
||||
msgDuplicateNotification = "several notifications carry this name; syncing the lowest ID"
|
||||
msgSecretMissing = "secret not found or not readable"
|
||||
msgSecretKeyMissing = "secret lacks host, port, username or password"
|
||||
msgInvalidSecretPort = "secret port is not a number from 1 to 65535"
|
||||
msgInvalidType = "notification type is not supported"
|
||||
|
||||
// smtpsPort is the port that uses implicit TLS; any other uses STARTTLS.
|
||||
smtpsPort = 465
|
||||
)
|
||||
|
||||
// smtpSecretKeys are the keys the SMTP Secret must hold, none empty.
|
||||
var smtpSecretKeys = []string{"host", "port", "username", "password"}
|
||||
|
||||
// +kubebuilder:rbac:groups=uptime.renner.uno,resources=kumanotifications,verbs=get;list;watch;update;patch
|
||||
// +kubebuilder:rbac:groups=uptime.renner.uno,resources=kumanotifications/status,verbs=get;update;patch
|
||||
// +kubebuilder:rbac:groups=uptime.renner.uno,resources=kumanotifications/finalizers,verbs=update
|
||||
|
||||
// NotificationReconciler syncs KumaNotification resources to Kuma. The Kuma
|
||||
// notification is owned by name: the lowest-ID notification with the
|
||||
// resource's name is adopted, whoever made it.
|
||||
type NotificationReconciler struct {
|
||||
Client client.Client
|
||||
// APIReader reads the SMTP Secret uncached, so no Secret watch is needed:
|
||||
// only get on the referenced Secret.
|
||||
APIReader client.Reader
|
||||
Kuma kumaclient.Notifications
|
||||
// Resync is how often a resource is reconciled again, which reverts edits
|
||||
// made in Kuma and picks up Secret changes. 0 means 5 minutes.
|
||||
Resync time.Duration
|
||||
}
|
||||
|
||||
func (r *NotificationReconciler) resync() time.Duration {
|
||||
if r.Resync > 0 {
|
||||
return r.Resync
|
||||
}
|
||||
return resyncPeriod
|
||||
}
|
||||
|
||||
// SetupWithManager ignores status-only updates. Secrets are not watched.
|
||||
func (r *NotificationReconciler) SetupWithManager(mgr ctrl.Manager) error {
|
||||
return ctrl.NewControllerManagedBy(mgr).
|
||||
For(&v1.KumaNotification{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})).
|
||||
Named("kumanotification").
|
||||
Complete(r)
|
||||
}
|
||||
|
||||
// specProblem is a Ready=False outcome that waits for a resync.
|
||||
type specProblem struct {
|
||||
reason string
|
||||
message string
|
||||
}
|
||||
|
||||
func (r *NotificationReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
|
||||
kn := &v1.KumaNotification{}
|
||||
if err := r.Client.Get(ctx, req.NamespacedName, kn); err != nil {
|
||||
return ctrl.Result{}, client.IgnoreNotFound(err)
|
||||
}
|
||||
if !kn.DeletionTimestamp.IsZero() {
|
||||
return r.reconcileDelete(ctx, kn)
|
||||
}
|
||||
if controllerutil.AddFinalizer(kn, Finalizer) {
|
||||
if err := r.Client.Update(ctx, kn); err != nil {
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
}
|
||||
|
||||
desired, problem, err := r.desired(ctx, kn)
|
||||
if err != nil {
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
if problem != nil {
|
||||
err := r.setReady(ctx, kn, metav1.ConditionFalse, problem.reason, problem.message, nil)
|
||||
return ctrl.Result{RequeueAfter: r.resync()}, err
|
||||
}
|
||||
|
||||
list, err := r.Kuma.ListNotifications(ctx, kn.Name)
|
||||
if err != nil {
|
||||
return r.fail(ctx, kn, &failure{ReasonKumaUnavailable, msgKumaUnavailable, err})
|
||||
}
|
||||
var id int64
|
||||
if len(list) == 0 {
|
||||
id, err = r.Kuma.CreateNotification(ctx, desired)
|
||||
if err != nil {
|
||||
return r.fail(ctx, kn, &failure{ReasonKumaUnavailable, msgKumaUnavailable, err})
|
||||
}
|
||||
} else {
|
||||
id = list[0].ID
|
||||
desired.ID = id
|
||||
if !desired.SameContent(list[0]) {
|
||||
log.FromContext(ctx).Info("notification differs from spec, updating", "notificationID", id)
|
||||
if err := r.Kuma.UpdateNotification(ctx, desired); err != nil {
|
||||
return r.fail(ctx, kn, &failure{ReasonKumaUnavailable, msgKumaUnavailable, err})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(list) > 1 {
|
||||
ids := make([]int64, 0, len(list))
|
||||
for _, n := range list {
|
||||
ids = append(ids, n.ID)
|
||||
}
|
||||
log.FromContext(ctx).Error(nil, "several Kuma notifications carry the same name, syncing the lowest ID",
|
||||
"name", kn.Name, "notificationIDs", ids)
|
||||
err := r.setReady(ctx, kn, metav1.ConditionFalse, ReasonDuplicateOwner, msgDuplicateNotification, &id)
|
||||
return ctrl.Result{RequeueAfter: r.resync()}, err
|
||||
}
|
||||
if err := r.setReady(ctx, kn, metav1.ConditionTrue, ReasonSynced, msgNotificationSynced, &id); err != nil {
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
return ctrl.Result{RequeueAfter: r.resync()}, nil
|
||||
}
|
||||
|
||||
// reconcileDelete removes every notification carrying the name, duplicates
|
||||
// included. Kuma detaches them from the monitors.
|
||||
func (r *NotificationReconciler) reconcileDelete(ctx context.Context, kn *v1.KumaNotification) (ctrl.Result, error) {
|
||||
if !controllerutil.ContainsFinalizer(kn, Finalizer) {
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
list, err := r.Kuma.ListNotifications(ctx, kn.Name)
|
||||
if err != nil {
|
||||
return r.fail(ctx, kn, &failure{ReasonKumaUnavailable, msgKumaUnavailable, err})
|
||||
}
|
||||
for _, n := range list {
|
||||
err := r.Kuma.DeleteNotification(ctx, n.ID)
|
||||
if err != nil && !errors.Is(err, kumaclient.ErrNotificationNotFound) {
|
||||
return r.fail(ctx, kn, &failure{ReasonKumaUnavailable, msgKumaUnavailable, err})
|
||||
}
|
||||
}
|
||||
controllerutil.RemoveFinalizer(kn, Finalizer)
|
||||
return ctrl.Result{}, r.Client.Update(ctx, kn)
|
||||
}
|
||||
|
||||
// desired builds the notification from the spec and the SMTP Secret. A
|
||||
// missing or invalid Secret is a problem, not an error; the cause is never
|
||||
// logged, as it may carry Secret data.
|
||||
func (r *NotificationReconciler) desired(
|
||||
ctx context.Context, kn *v1.KumaNotification,
|
||||
) (kumaclient.Notification, *specProblem, error) {
|
||||
s := kn.Spec.SMTP
|
||||
if kn.Spec.Type != "smtp" || s == nil {
|
||||
return kumaclient.Notification{}, &specProblem{ReasonInvalidSpec, msgInvalidType}, nil
|
||||
}
|
||||
secret := &corev1.Secret{}
|
||||
err := r.APIReader.Get(ctx, types.NamespacedName{Namespace: s.SecretRef.Namespace, Name: s.SecretRef.Name}, secret)
|
||||
if apierrors.IsNotFound(err) || apierrors.IsForbidden(err) {
|
||||
return kumaclient.Notification{}, &specProblem{ReasonSecretMissing, msgSecretMissing}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return kumaclient.Notification{}, nil, err
|
||||
}
|
||||
for _, k := range smtpSecretKeys {
|
||||
if len(secret.Data[k]) == 0 {
|
||||
return kumaclient.Notification{}, &specProblem{ReasonSecretMissing, msgSecretKeyMissing}, nil
|
||||
}
|
||||
}
|
||||
port, err := strconv.Atoi(strings.TrimSpace(string(secret.Data["port"])))
|
||||
if err != nil || port < 1 || port > 65535 {
|
||||
return kumaclient.Notification{}, &specProblem{ReasonInvalidSecret, msgInvalidSecretPort}, nil
|
||||
}
|
||||
return kumaclient.Notification{
|
||||
Name: kn.Name,
|
||||
Type: "smtp",
|
||||
Active: true,
|
||||
SMTP: kumaclient.SMTP{
|
||||
Host: strings.TrimSpace(string(secret.Data["host"])),
|
||||
Port: int32(port),
|
||||
Secure: port == smtpsPort,
|
||||
Username: string(secret.Data["username"]),
|
||||
Password: string(secret.Data["password"]),
|
||||
From: s.From,
|
||||
To: s.To,
|
||||
},
|
||||
}, nil, nil
|
||||
}
|
||||
|
||||
// fail records a Ready=False outcome and returns its cause so the request is
|
||||
// requeued with backoff.
|
||||
func (r *NotificationReconciler) fail(ctx context.Context, kn *v1.KumaNotification, f *failure) (ctrl.Result, error) {
|
||||
log.FromContext(ctx).Info("reconcile failed", "reason", f.reason, "error", f.err.Error())
|
||||
if serr := r.setReady(ctx, kn, metav1.ConditionFalse, f.reason, f.message, nil); serr != nil {
|
||||
return ctrl.Result{}, errors.Join(f.err, serr)
|
||||
}
|
||||
return ctrl.Result{}, f.err
|
||||
}
|
||||
|
||||
// setReady writes the Ready condition, and the notification ID when given,
|
||||
// through the status subresource. It writes nothing if the status is
|
||||
// unchanged.
|
||||
func (r *NotificationReconciler) setReady(
|
||||
ctx context.Context, kn *v1.KumaNotification, status metav1.ConditionStatus, reason, msg string, id *int64,
|
||||
) error {
|
||||
orig := kn.Status.DeepCopy()
|
||||
if id != nil {
|
||||
kn.Status.NotificationID = *id
|
||||
kn.Status.ObservedGeneration = kn.Generation
|
||||
}
|
||||
meta.SetStatusCondition(&kn.Status.Conditions, metav1.Condition{
|
||||
Type: v1.ConditionReady,
|
||||
Status: status,
|
||||
Reason: reason,
|
||||
Message: msg,
|
||||
ObservedGeneration: kn.Generation,
|
||||
})
|
||||
if reflect.DeepEqual(*orig, kn.Status) {
|
||||
return nil
|
||||
}
|
||||
return r.Client.Status().Update(ctx, kn)
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
|
||||
ctrl "sigs.k8s.io/controller-runtime"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
||||
|
||||
v1 "gitea.renner.uno/konairius/homelab/kuma-sync/api/v1alpha1"
|
||||
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/kumaclient"
|
||||
)
|
||||
|
||||
// countingNotifs records notification writes on top of the Kuma fake.
|
||||
type countingNotifs struct {
|
||||
kumaclient.Notifications
|
||||
creates, updates, deletes int
|
||||
}
|
||||
|
||||
func (c *countingNotifs) CreateNotification(ctx context.Context, n kumaclient.Notification) (int64, error) {
|
||||
c.creates++
|
||||
return c.Notifications.CreateNotification(ctx, n)
|
||||
}
|
||||
|
||||
func (c *countingNotifs) UpdateNotification(ctx context.Context, n kumaclient.Notification) error {
|
||||
c.updates++
|
||||
return c.Notifications.UpdateNotification(ctx, n)
|
||||
}
|
||||
|
||||
func (c *countingNotifs) DeleteNotification(ctx context.Context, id int64) error {
|
||||
c.deletes++
|
||||
return c.Notifications.DeleteNotification(ctx, id)
|
||||
}
|
||||
|
||||
type notifEnv struct {
|
||||
r *NotificationReconciler
|
||||
k8s client.Client
|
||||
kuma *kumaclient.Fake
|
||||
cnt *countingNotifs
|
||||
}
|
||||
|
||||
func newNotifEnv(t *testing.T, objs ...client.Object) *notifEnv {
|
||||
t.Helper()
|
||||
s := runtime.NewScheme()
|
||||
if err := clientgoscheme.AddToScheme(s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := v1.AddToScheme(s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
k8s := fake.NewClientBuilder().WithScheme(s).WithObjects(objs...).
|
||||
WithStatusSubresource(&v1.KumaNotification{}).Build()
|
||||
kf := kumaclient.NewFake()
|
||||
cnt := &countingNotifs{Notifications: kf}
|
||||
return ¬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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/event"
|
||||
|
||||
v1 "gitea.renner.uno/konairius/homelab/kuma-sync/api/v1alpha1"
|
||||
)
|
||||
|
||||
const rootID = 7
|
||||
|
||||
var rootName = types.NamespacedName{Namespace: "default", Name: "root"}
|
||||
|
||||
func rootGroup() *v1.KumaMonitor {
|
||||
g := group("root", "")
|
||||
g.Status.MonitorID = rootID
|
||||
return g
|
||||
}
|
||||
|
||||
func newRootEnv(t *testing.T, objs ...client.Object) *env {
|
||||
t.Helper()
|
||||
e := newEnv(t, objs...)
|
||||
e.r.RootGroup = rootName
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *env) parentOf(t *testing.T, name string) *int64 {
|
||||
t.Helper()
|
||||
got := e.kuma.Get(e.get(t, name).Status.MonitorID)
|
||||
if got == nil {
|
||||
t.Fatalf("%s: no Kuma monitor", name)
|
||||
}
|
||||
return got.Parent
|
||||
}
|
||||
|
||||
func TestRootGroupParentsParentless(t *testing.T) {
|
||||
e := newRootEnv(t, rootGroup(), httpMonitor("a"))
|
||||
if _, err := e.reconcile(t, "a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p := e.parentOf(t, "a"); p == nil || *p != rootID {
|
||||
t.Errorf("parent = %v, want %d", p, rootID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootGroupKeepsParentRef(t *testing.T) {
|
||||
grp := group("grp", "")
|
||||
grp.Status.MonitorID = 42
|
||||
child := httpMonitor("child")
|
||||
child.Spec.ParentRef = &v1.LocalRef{Name: "grp"}
|
||||
e := newRootEnv(t, rootGroup(), grp, child)
|
||||
if _, err := e.reconcile(t, "child"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p := e.parentOf(t, "child"); p == nil || *p != 42 {
|
||||
t.Errorf("parent = %v, want 42", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootGroupItselfHasNoParent(t *testing.T) {
|
||||
root := rootGroup()
|
||||
root.Status.MonitorID = 0
|
||||
e := newRootEnv(t, root)
|
||||
if _, err := e.reconcile(t, "root"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p := e.parentOf(t, "root"); p != nil {
|
||||
t.Errorf("parent = %d, want none", *p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootGroupUnsetLeavesTopLevel(t *testing.T) {
|
||||
e := newEnv(t, rootGroup(), httpMonitor("a"))
|
||||
if _, err := e.reconcile(t, "a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p := e.parentOf(t, "a"); p != nil {
|
||||
t.Errorf("parent = %d, want none", *p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootGroupPending(t *testing.T) {
|
||||
noID := rootGroup()
|
||||
noID.Status.MonitorID = 0
|
||||
deletingRoot := deleting(rootGroup())
|
||||
cases := map[string][]client.Object{
|
||||
"missing": nil,
|
||||
"no ID": {noID},
|
||||
"deleting": {deletingRoot},
|
||||
"not group": {func() *v1.KumaMonitor { m := httpMonitor("root"); m.Status.MonitorID = rootID; return m }()},
|
||||
"has parent": {func() *v1.KumaMonitor {
|
||||
m := rootGroup()
|
||||
m.Spec.ParentRef = &v1.LocalRef{Name: "x"}
|
||||
return m
|
||||
}()},
|
||||
}
|
||||
want := map[string]struct {
|
||||
reason string
|
||||
requeue time.Duration
|
||||
}{
|
||||
"missing": {ReasonParentPending, 10 * time.Second},
|
||||
"no ID": {ReasonParentPending, 10 * time.Second},
|
||||
"deleting": {ReasonParentPending, 10 * time.Second},
|
||||
"not group": {ReasonInvalidParent, 5 * time.Minute},
|
||||
"has parent": {ReasonInvalidParent, 5 * time.Minute},
|
||||
}
|
||||
for name, objs := range cases {
|
||||
e := newRootEnv(t, append(objs, httpMonitor("a"))...)
|
||||
res, err := e.reconcile(t, "a")
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", name, err)
|
||||
}
|
||||
if res.RequeueAfter != want[name].requeue {
|
||||
t.Errorf("%s: RequeueAfter = %v, want %v", name, res.RequeueAfter, want[name].requeue)
|
||||
}
|
||||
if c := ready(t, e.get(t, "a")); c.Status != metav1.ConditionFalse || c.Reason != want[name].reason {
|
||||
t.Errorf("%s: Ready = %+v", name, c)
|
||||
}
|
||||
if e.cnt.creates != 0 {
|
||||
t.Errorf("%s: creates = %d", name, e.cnt.creates)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A chain of maxParentDepth groups is allowed on its own, but the root group
|
||||
// adds one more level above it.
|
||||
func TestRootGroupCountsTowardsDepth(t *testing.T) {
|
||||
var objs []client.Object
|
||||
for i := range maxParentDepth {
|
||||
parent := ""
|
||||
if i < maxParentDepth-1 {
|
||||
parent = fmt.Sprintf("c%d", i+1)
|
||||
}
|
||||
objs = append(objs, group(fmt.Sprintf("c%d", i), parent))
|
||||
}
|
||||
deep := httpMonitor("deep")
|
||||
deep.Spec.ParentRef = &v1.LocalRef{Name: "c0"}
|
||||
objs = append(objs, deep)
|
||||
|
||||
if _, err := newEnv(t, objs...).reconcile(t, "deep"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
e := newRootEnv(t, append(objs, rootGroup())...)
|
||||
if _, err := e.reconcile(t, "deep"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c := ready(t, e.get(t, "deep")); c.Reason != ReasonInvalidParent || c.Message != msgParentTooDeep {
|
||||
t.Errorf("Ready = %+v", c)
|
||||
}
|
||||
|
||||
// A chain ending in the root itself gets no extra level.
|
||||
objs[maxParentDepth-1] = func() *v1.KumaMonitor { g := rootGroup(); g.Name = "c9"; return g }()
|
||||
e = newEnv(t, objs...)
|
||||
e.r.RootGroup = types.NamespacedName{Namespace: "default", Name: "c9"}
|
||||
if _, err := e.reconcile(t, "deep"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c := ready(t, e.get(t, "deep")); c.Status != metav1.ConditionTrue {
|
||||
t.Errorf("chain ending in root: Ready = %+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootChildrenEnqueuesParentless(t *testing.T) {
|
||||
child := httpMonitor("child")
|
||||
child.Spec.ParentRef = &v1.LocalRef{Name: "grp"}
|
||||
other := httpMonitor("other")
|
||||
other.Namespace = "elsewhere"
|
||||
e := newRootEnv(t, rootGroup(), group("grp", ""), child, httpMonitor("a"), other)
|
||||
|
||||
var got []string
|
||||
for _, req := range e.r.rootChildren(t.Context(), rootGroup()) {
|
||||
got = append(got, req.String())
|
||||
}
|
||||
slices.Sort(got)
|
||||
want := []string{"default/a", "default/grp", "elsewhere/other"}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Errorf("enqueued %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootPredicate(t *testing.T) {
|
||||
r := &Reconciler{RootGroup: rootName}
|
||||
p := r.rootPredicate()
|
||||
old := rootGroup()
|
||||
old.Status.MonitorID = 0
|
||||
withID := rootGroup()
|
||||
other := group("other", "")
|
||||
|
||||
if !p.Update(event.UpdateEvent{ObjectOld: old, ObjectNew: withID}) {
|
||||
t.Error("root ID change must pass")
|
||||
}
|
||||
if p.Update(event.UpdateEvent{ObjectOld: withID, ObjectNew: withID.DeepCopy()}) {
|
||||
t.Error("unchanged root must not pass")
|
||||
}
|
||||
if p.Update(event.UpdateEvent{ObjectOld: other, ObjectNew: other.DeepCopy()}) || p.Create(event.CreateEvent{Object: other}) {
|
||||
t.Error("other monitors must not pass")
|
||||
}
|
||||
if !p.Create(event.CreateEvent{Object: withID}) || !p.Delete(event.DeleteEvent{Object: withID}) {
|
||||
t.Error("root create and delete must pass")
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
|
||||
kuma "github.com/breml/go-uptime-kuma-client"
|
||||
"github.com/breml/go-uptime-kuma-client/monitor"
|
||||
"github.com/breml/go-uptime-kuma-client/notification"
|
||||
"github.com/breml/go-uptime-kuma-client/tag"
|
||||
)
|
||||
|
||||
@@ -47,6 +46,9 @@ type Breml struct {
|
||||
opts []kuma.Option
|
||||
token string
|
||||
backoff0 time.Duration
|
||||
// notifStale means a notification write may be missing from the
|
||||
// session's cached list.
|
||||
notifStale bool
|
||||
}
|
||||
|
||||
var _ Client = (*Breml)(nil)
|
||||
@@ -158,7 +160,7 @@ func (b *Breml) checkSession(ctx context.Context, err error) (rebuilt, dead bool
|
||||
if ctx.Err() != nil {
|
||||
return false, false
|
||||
}
|
||||
for _, app := range []error{ErrNotFound, ErrNotOwned, ErrOwnerTagMissing, ErrOwnerConflict, ErrStatusPageNotFound} {
|
||||
for _, app := range []error{ErrNotFound, ErrNotOwned, ErrOwnerTagMissing, ErrOwnerConflict, ErrStatusPageNotFound, ErrNotificationNotFound} {
|
||||
if errors.Is(err, app) {
|
||||
return false, false
|
||||
}
|
||||
@@ -196,6 +198,8 @@ func (b *Breml) reconnect(ctx context.Context) error {
|
||||
}
|
||||
_ = b.c.Disconnect()
|
||||
b.c = c
|
||||
// A new session starts with a fresh notification list.
|
||||
b.notifStale = false
|
||||
if t := c.SessionToken(); t != "" {
|
||||
b.token = t
|
||||
}
|
||||
@@ -374,7 +378,7 @@ func (b *Breml) update(ctx context.Context, m Monitor) error {
|
||||
return fmt.Errorf("update %d: monitor carries owner key %q, not %q: %w", m.ID, key, m.OwnerKey, ErrNotOwned)
|
||||
}
|
||||
// Start from the full current object, also across a type change, so
|
||||
// fields we do not manage (proxy, resend interval, upside down, auth)
|
||||
// fields we do not manage (proxy, upside down, auth)
|
||||
// survive the overwrite.
|
||||
wire, err := toWire(m, &cur)
|
||||
if err != nil {
|
||||
@@ -405,29 +409,6 @@ func (b *Breml) Delete(ctx context.Context, id int64) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// DefaultNotifications reads the notification list cached by the session.
|
||||
// Kuma has no command to request it: it pushes the list at login and to every
|
||||
// session of the user after each change.
|
||||
func (b *Breml) DefaultNotifications(ctx context.Context) ([]int64, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return defaultIDs(b.c.GetNotifications(ctx)), nil
|
||||
}
|
||||
|
||||
func defaultIDs(list []notification.Base) []int64 {
|
||||
var ids []int64
|
||||
for _, n := range list {
|
||||
if n.IsDefault {
|
||||
ids = append(ids, n.ID)
|
||||
}
|
||||
}
|
||||
slices.Sort(ids)
|
||||
return ids
|
||||
}
|
||||
|
||||
// toDomain converts a monitor from the Kuma monitor list.
|
||||
func toDomain(base monitor.Base, ownerKey string) (Monitor, error) {
|
||||
m := Monitor{
|
||||
@@ -439,6 +420,7 @@ func toDomain(base monitor.Base, ownerKey string) (Monitor, error) {
|
||||
Interval: int32(base.Interval),
|
||||
RetryInterval: int32(base.RetryInterval),
|
||||
MaxRetries: int32(base.MaxRetries),
|
||||
ResendInterval: int32(base.ResendInterval),
|
||||
NotificationIDs: slices.Clone(base.NotificationIDs),
|
||||
OwnerKey: ownerKey,
|
||||
}
|
||||
@@ -600,6 +582,7 @@ func writeBase(b *monitor.Base, m Monitor) {
|
||||
b.Interval = int64(m.Interval)
|
||||
b.RetryInterval = int64(m.RetryInterval)
|
||||
b.MaxRetries = int64(m.MaxRetries)
|
||||
b.ResendInterval = int64(m.ResendInterval)
|
||||
b.IsActive = m.Active
|
||||
b.NotificationIDs = slices.Clone(m.NotificationIDs)
|
||||
}
|
||||
|
||||
@@ -127,7 +127,7 @@ func TestCreateFieldsIntact(t *testing.T) {
|
||||
|
||||
group := Monitor{
|
||||
Type: "group", Name: "grp", Description: "a group", Active: true,
|
||||
Interval: 60, RetryInterval: 60, MaxRetries: 0, OwnerKey: "default/grp",
|
||||
Interval: 60, RetryInterval: 60, MaxRetries: 0, ResendInterval: 60, OwnerKey: "default/grp",
|
||||
}
|
||||
gid, err := b.Create(ctx, group)
|
||||
if err != nil {
|
||||
@@ -313,7 +313,6 @@ func setUnmanaged(t *testing.T, b *Breml, id int64) (notificationID int64) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
kw.UpsideDown = true
|
||||
kw.ResendInterval = 7
|
||||
kw.NotificationIDs = []int64{nid}
|
||||
if err := b.c.UpdateMonitor(ctx, &kw); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -328,8 +327,8 @@ func assertUnmanaged(t *testing.T, c *Breml, id int64, when string) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !base.UpsideDown || base.ResendInterval != 7 {
|
||||
t.Errorf("%s: unmanaged state lost: upsideDown=%v resend=%d", when, base.UpsideDown, base.ResendInterval)
|
||||
if !base.UpsideDown {
|
||||
t.Errorf("%s: unmanaged state lost: upsideDown=%v", when, base.UpsideDown)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -344,7 +343,7 @@ func TestUpdatePreservesUnmanagedAndClearsManaged(t *testing.T) {
|
||||
}
|
||||
orig := Monitor{
|
||||
Type: "keyword", Name: "kw", Active: true, Parent: &gid,
|
||||
Interval: 60, RetryInterval: 60, URL: "https://example.com",
|
||||
Interval: 60, RetryInterval: 60, ResendInterval: 7, URL: "https://example.com",
|
||||
Headers: map[string]string{"A": "1"}, Keyword: "up", OwnerKey: "default/kw",
|
||||
}
|
||||
id, err := b.Create(ctx, orig)
|
||||
@@ -360,6 +359,7 @@ func TestUpdatePreservesUnmanagedAndClearsManaged(t *testing.T) {
|
||||
cleared.Headers = nil
|
||||
cleared.Keyword = ""
|
||||
cleared.Interval = 90
|
||||
cleared.ResendInterval = 0
|
||||
if err := b.Update(ctx, cleared); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -369,7 +369,7 @@ func TestUpdatePreservesUnmanagedAndClearsManaged(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := byID(got)[id]
|
||||
if m.Parent != nil || m.Headers != nil || m.Keyword != "" || m.Interval != 90 || m.NotificationIDs != nil {
|
||||
if m.Parent != nil || m.Headers != nil || m.Keyword != "" || m.Interval != 90 || m.ResendInterval != 0 || m.NotificationIDs != nil {
|
||||
t.Errorf("managed fields not cleared: %+v", m)
|
||||
}
|
||||
assertUnmanaged(t, fresh, id, "after plain update")
|
||||
@@ -391,51 +391,6 @@ func TestUpdatePreservesUnmanagedAndClearsManaged(t *testing.T) {
|
||||
assertUnmanaged(t, fresh, id, "after type change")
|
||||
}
|
||||
|
||||
func TestDefaultNotificationsAndWrite(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
url := startKuma(t)
|
||||
b := setupClient(t, url)
|
||||
other := freshClient(t, url)
|
||||
|
||||
if ids, err := other.DefaultNotifications(ctx); err != nil || ids != nil {
|
||||
t.Fatalf("no notifications: ids=%v err=%v", ids, err)
|
||||
}
|
||||
def := createWebhook(t, b, "default", true)
|
||||
createWebhook(t, b, "opt-in", false)
|
||||
// Kuma pushes the list to every session of the user, not only the writer.
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for {
|
||||
ids, err := other.DefaultNotifications(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reflect.DeepEqual(ids, []int64{def}) {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("other session sees defaults %v, want [%d]", ids, def)
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
|
||||
m := Monitor{
|
||||
Type: "group", Name: "grp", Active: true, Interval: 60, RetryInterval: 60,
|
||||
NotificationIDs: []int64{def}, OwnerKey: "default/grp",
|
||||
}
|
||||
id, err := b.Create(ctx, m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := freshClient(t, url).ListOwned(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.ID = id
|
||||
if !reflect.DeepEqual(byID(got)[id], m.Normalized()) {
|
||||
t.Errorf("read-back differs:\n got %+v\n want %+v", byID(got)[id], m.Normalized())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestartAdoptsByTag(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
url := startKuma(t)
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
package kumaclient
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
kuma "github.com/breml/go-uptime-kuma-client"
|
||||
"github.com/breml/go-uptime-kuma-client/notification"
|
||||
)
|
||||
|
||||
const typeSMTP = "smtp"
|
||||
|
||||
// ListNotifications reads the notification list cached by the session. Kuma
|
||||
// has no command to request it: it pushes the list at login and to every
|
||||
// session of the user after each change. After a write whose broadcast was
|
||||
// missed the cache is rebuilt first, so a created notification is never
|
||||
// reported absent.
|
||||
func (b *Breml) ListNotifications(ctx context.Context, name string) ([]Notification, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if b.notifStale {
|
||||
if err := b.c.Resync(ctx); err != nil && !b.recoverSession(ctx, err) {
|
||||
return nil, fmt.Errorf("resync notifications: %w", err)
|
||||
}
|
||||
b.notifStale = false
|
||||
}
|
||||
var out []Notification
|
||||
for _, base := range b.c.GetNotifications(ctx) {
|
||||
if base.Name != name {
|
||||
continue
|
||||
}
|
||||
n, err := toNotification(base)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
slices.SortFunc(out, func(a, b Notification) int { return cmp.Compare(a.ID, b.ID) })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CreateNotification is never retried: Kuma may have stored the notification
|
||||
// although the ack was lost; the next list finds it by name.
|
||||
func (b *Breml) CreateNotification(ctx context.Context, n Notification) (int64, error) {
|
||||
if n.Type != typeSMTP {
|
||||
return 0, fmt.Errorf("create notification: unsupported type %q", n.Type)
|
||||
}
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
wire := ¬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
|
||||
@@ -139,6 +141,9 @@ type Monitor struct {
|
||||
Interval int32
|
||||
RetryInterval int32
|
||||
MaxRetries int32
|
||||
// ResendInterval counts consecutive DOWN heartbeats between repeated DOWN
|
||||
// notifications; 0 disables resending.
|
||||
ResendInterval int32
|
||||
// Timeout is in seconds and only applies to http, keyword and json-query.
|
||||
// 0 is written as round(0.8*Interval), because Kuma treats a stored 0 as a
|
||||
// huge request timeout. For other types it is ignored and reads back as 0.
|
||||
@@ -196,9 +201,7 @@ type Client interface {
|
||||
// Delete removes an owned monitor. It wraps ErrNotFound for an unknown ID
|
||||
// and ErrNotOwned for a monitor without the kuma-sync tag.
|
||||
Delete(ctx context.Context, id int64) error
|
||||
// DefaultNotifications returns the IDs of all notifications marked
|
||||
// "default enabled" in Kuma, sorted. An empty result is valid.
|
||||
DefaultNotifications(ctx context.Context) ([]int64, error)
|
||||
|
||||
StatusPages
|
||||
Notifications
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package kumaclient
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"sync"
|
||||
)
|
||||
@@ -21,10 +20,6 @@ type Fake struct {
|
||||
UpdateErr error
|
||||
DeleteErr error
|
||||
ProbeErr error
|
||||
// NotificationsErr is returned by DefaultNotifications.
|
||||
NotificationsErr error
|
||||
// Defaults are the IDs DefaultNotifications returns.
|
||||
Defaults []int64
|
||||
// ListPartial makes ListOwned return the stored monitors together with
|
||||
// ListErr, as the real client does for owner conflicts.
|
||||
ListPartial bool
|
||||
@@ -35,6 +30,11 @@ type Fake struct {
|
||||
nextGroupID int64
|
||||
// PageErr, when set, is returned by every status page method.
|
||||
PageErr error
|
||||
|
||||
notifications map[int64]Notification
|
||||
nextNotifID int64
|
||||
// NotifErr, when set, is returned by every notification method.
|
||||
NotifErr error
|
||||
}
|
||||
|
||||
// SetListPartial makes ListOwned return the stored monitors and err.
|
||||
@@ -50,6 +50,7 @@ func NewFake() *Fake {
|
||||
return &Fake{
|
||||
monitors: map[int64]Monitor{}, nextID: 1,
|
||||
pages: map[string]StatusPage{}, pageGroups: map[int64]string{}, nextPageID: 1, nextGroupID: 1,
|
||||
notifications: map[int64]Notification{}, nextNotifID: 1,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,32 +106,6 @@ func (f *Fake) Probe(_ context.Context, id int64) (bool, string, error) {
|
||||
return ok, m.OwnerKey, nil
|
||||
}
|
||||
|
||||
// SetDefaultNotifications sets the IDs DefaultNotifications returns.
|
||||
func (f *Fake) SetDefaultNotifications(ids ...int64) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.Defaults = slices.Clone(ids)
|
||||
}
|
||||
|
||||
// SetNotificationsErr makes DefaultNotifications return err.
|
||||
func (f *Fake) SetNotificationsErr(err error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.NotificationsErr = err
|
||||
}
|
||||
|
||||
// DefaultNotifications returns Defaults, sorted.
|
||||
func (f *Fake) DefaultNotifications(_ context.Context) ([]int64, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.NotificationsErr != nil {
|
||||
return nil, f.NotificationsErr
|
||||
}
|
||||
ids := slices.Clone(f.Defaults)
|
||||
slices.Sort(ids)
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (f *Fake) allocID() int64 {
|
||||
id := f.nextID
|
||||
f.nextID++
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package kumaclient
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// SetNotifErr makes every notification method return err.
|
||||
func (f *Fake) SetNotifErr(err error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.NotifErr = err
|
||||
}
|
||||
|
||||
// Notification returns a copy of the stored notification, or nil.
|
||||
func (f *Fake) Notification(id int64) *Notification {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
n, ok := f.notifications[id]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return &n
|
||||
}
|
||||
|
||||
// PutNotification stores n as if made in the Kuma UI. A zero ID is assigned
|
||||
// the next free one.
|
||||
func (f *Fake) PutNotification(n Notification) int64 {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if n.ID == 0 {
|
||||
n.ID = f.nextNotifID
|
||||
f.nextNotifID++
|
||||
} else if n.ID >= f.nextNotifID {
|
||||
f.nextNotifID = n.ID + 1
|
||||
}
|
||||
f.notifications[n.ID] = n
|
||||
return n.ID
|
||||
}
|
||||
|
||||
func (f *Fake) ListNotifications(_ context.Context, name string) ([]Notification, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.NotifErr != nil {
|
||||
return nil, f.NotifErr
|
||||
}
|
||||
var out []Notification
|
||||
for _, n := range f.notifications {
|
||||
if n.Name == name {
|
||||
out = append(out, n)
|
||||
}
|
||||
}
|
||||
slices.SortFunc(out, func(a, b Notification) int { return cmp.Compare(a.ID, b.ID) })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *Fake) CreateNotification(_ context.Context, n Notification) (int64, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.NotifErr != nil {
|
||||
return 0, f.NotifErr
|
||||
}
|
||||
if n.Type != "smtp" {
|
||||
return 0, fmt.Errorf("create notification: unsupported type %q", n.Type)
|
||||
}
|
||||
n.ID = f.nextNotifID
|
||||
f.nextNotifID++
|
||||
f.notifications[n.ID] = n
|
||||
return n.ID, nil
|
||||
}
|
||||
|
||||
func (f *Fake) UpdateNotification(_ context.Context, n Notification) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.NotifErr != nil {
|
||||
return f.NotifErr
|
||||
}
|
||||
if _, ok := f.notifications[n.ID]; !ok {
|
||||
return fmt.Errorf("update notification %d: %w", n.ID, ErrNotificationNotFound)
|
||||
}
|
||||
f.notifications[n.ID] = n
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteNotification also detaches the notification from every monitor, as
|
||||
// Kuma does.
|
||||
func (f *Fake) DeleteNotification(_ context.Context, id int64) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.NotifErr != nil {
|
||||
return f.NotifErr
|
||||
}
|
||||
if _, ok := f.notifications[id]; !ok {
|
||||
return fmt.Errorf("delete notification %d: %w", id, ErrNotificationNotFound)
|
||||
}
|
||||
delete(f.notifications, id)
|
||||
for mid, m := range f.monitors {
|
||||
if i := slices.Index(m.NotificationIDs, id); i >= 0 {
|
||||
m = m.clone()
|
||||
m.NotificationIDs = slices.Delete(m.NotificationIDs, i, i+1)
|
||||
f.monitors[mid] = m.Normalized()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -118,21 +118,6 @@ func TestFakeInjectedErrors(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFakeDefaultNotifications(t *testing.T) {
|
||||
f := NewFake()
|
||||
if ids, err := f.DefaultNotifications(context.Background()); err != nil || ids != nil {
|
||||
t.Fatalf("empty: %v %v", ids, err)
|
||||
}
|
||||
f.SetDefaultNotifications(4, 1)
|
||||
if ids, err := f.DefaultNotifications(context.Background()); err != nil || len(ids) != 2 || ids[0] != 1 || ids[1] != 4 {
|
||||
t.Fatalf("set: %v %v", ids, err)
|
||||
}
|
||||
f.SetNotificationsErr(errors.New("boom"))
|
||||
if _, err := f.DefaultNotifications(context.Background()); err == nil {
|
||||
t.Error("expected injected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFakeUpdateRejectsOtherOwnerKey(t *testing.T) {
|
||||
f := NewFake()
|
||||
id, _ := f.Create(context.Background(), Monitor{Name: "a", OwnerKey: "ns/a"})
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"testing"
|
||||
|
||||
kuma "github.com/breml/go-uptime-kuma-client"
|
||||
"github.com/breml/go-uptime-kuma-client/notification"
|
||||
"github.com/breml/go-uptime-kuma-client/tag"
|
||||
)
|
||||
|
||||
@@ -182,15 +181,3 @@ func TestNormalizedNotificationIDs(t *testing.T) {
|
||||
t.Errorf("empty notifications must be nil: %v", e.NotificationIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultIDs(t *testing.T) {
|
||||
got := defaultIDs([]notification.Base{
|
||||
{ID: 5, IsDefault: true}, {ID: 1}, {ID: 3, IsDefault: true},
|
||||
})
|
||||
if !reflect.DeepEqual(got, []int64{3, 5}) {
|
||||
t.Errorf("defaultIDs = %v, want [3 5]", got)
|
||||
}
|
||||
if got := defaultIDs(nil); got != nil {
|
||||
t.Errorf("defaultIDs(nil) = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package kumaclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// Notification is the domain view of a Kuma notification. Only smtp is
|
||||
// modelled; for another type Type is set and SMTP is zero. Config keys
|
||||
// kuma-sync does not manage are kept as they are in Kuma.
|
||||
type Notification struct {
|
||||
// ID is read only.
|
||||
ID int64
|
||||
Name string
|
||||
Type string
|
||||
IsDefault bool
|
||||
Active bool
|
||||
SMTP SMTP
|
||||
}
|
||||
|
||||
// SMTP is the managed part of an smtp notification's config.
|
||||
type SMTP struct {
|
||||
Host string
|
||||
Port int32
|
||||
// Secure is implicit TLS; false is STARTTLS.
|
||||
Secure bool
|
||||
IgnoreSTARTTLS bool
|
||||
Username string
|
||||
Password string
|
||||
From string
|
||||
To string
|
||||
}
|
||||
|
||||
// SameContent reports whether n and o hold the same managed content. IDs are
|
||||
// ignored.
|
||||
func (n Notification) SameContent(o Notification) bool {
|
||||
n.ID, o.ID = 0, 0
|
||||
return n == o
|
||||
}
|
||||
|
||||
// Notifications manages Kuma notifications by name. Kuma does not enforce
|
||||
// unique names.
|
||||
type Notifications interface {
|
||||
// ListNotifications returns the notifications named name, by ascending ID.
|
||||
ListNotifications(ctx context.Context, name string) ([]Notification, error)
|
||||
// CreateNotification creates the notification and returns its ID. It never
|
||||
// applies the notification to existing monitors.
|
||||
CreateNotification(ctx context.Context, n Notification) (int64, error)
|
||||
// UpdateNotification overwrites the managed fields of notification n.ID.
|
||||
// It wraps ErrNotificationNotFound for an unknown ID.
|
||||
UpdateNotification(ctx context.Context, n Notification) error
|
||||
// DeleteNotification removes the notification and detaches it from every
|
||||
// monitor. It wraps ErrNotificationNotFound for an unknown ID.
|
||||
DeleteNotification(ctx context.Context, id int64) error
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
//go:build integration
|
||||
|
||||
package kumaclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/breml/go-uptime-kuma-client/notification"
|
||||
)
|
||||
|
||||
// eventuallyListed waits until c lists exactly want under name; Kuma pushes
|
||||
// the list to every session of the user, not only the writer.
|
||||
func eventuallyListed(t *testing.T, c *Breml, name string, want ...Notification) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for {
|
||||
got, err := c.ListNotifications(context.Background(), name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) == len(want) {
|
||||
same := true
|
||||
for i := range got {
|
||||
same = same && got[i] == want[i]
|
||||
}
|
||||
if same {
|
||||
return
|
||||
}
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("session lists %+v, want %+v", got, want)
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
url := startKuma(t)
|
||||
b := setupClient(t, url)
|
||||
other := freshClient(t, url)
|
||||
|
||||
n := Notification{
|
||||
Name: "email", Type: "smtp", Active: true,
|
||||
SMTP: SMTP{Host: "mail", Port: 587, Username: "u", Password: "p", From: "f@x", To: "t@x"},
|
||||
}
|
||||
id, err := b.CreateNotification(ctx, n)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n.ID = id
|
||||
eventuallyListed(t, b, "email", n)
|
||||
eventuallyListed(t, other, "email", n)
|
||||
|
||||
n.SMTP.Port, n.SMTP.Secure, n.SMTP.Password = 465, true, "q"
|
||||
if err := b.UpdateNotification(ctx, n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
eventuallyListed(t, freshClient(t, url), "email", n)
|
||||
|
||||
if err := b.DeleteNotification(ctx, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := b.DeleteNotification(ctx, id); !errors.Is(err, ErrNotificationNotFound) {
|
||||
t.Errorf("second delete: %v", err)
|
||||
}
|
||||
if err := b.UpdateNotification(ctx, n); !errors.Is(err, ErrNotificationNotFound) {
|
||||
t.Errorf("update deleted: %v", err)
|
||||
}
|
||||
eventuallyListed(t, freshClient(t, url), "email")
|
||||
}
|
||||
|
||||
// An adopted notification made in the UI keeps the config kuma-sync does not
|
||||
// manage, and loses its default flag.
|
||||
func TestNotificationUpdatePreservesUnmanaged(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
url := startKuma(t)
|
||||
b := setupClient(t, url)
|
||||
|
||||
id, err := b.c.CreateNotification(ctx, ¬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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package kumaclient
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/breml/go-uptime-kuma-client/monitor"
|
||||
)
|
||||
|
||||
// resendOnWire writes m over existing (nil for a create) and returns the wire
|
||||
// resendInterval and the value read back from it.
|
||||
func resendOnWire(t *testing.T, m Monitor, existing *monitor.Base) (wire, back int64) {
|
||||
t.Helper()
|
||||
w, err := toWire(m, existing)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := json.Marshal(w)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var f struct {
|
||||
ResendInterval int64 `json:"resendInterval"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &f); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var base monitor.Base
|
||||
if err := json.Unmarshal(raw, &base); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := toDomain(base, m.OwnerKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return f.ResendInterval, int64(got.ResendInterval)
|
||||
}
|
||||
|
||||
func TestResendIntervalRoundTrip(t *testing.T) {
|
||||
for _, m := range []Monitor{
|
||||
{ID: 1, Type: "group", Name: "g", Active: true, Interval: 60, RetryInterval: 60, ResendInterval: 60, OwnerKey: "ns/g"},
|
||||
{ID: 2, Type: "http", Name: "h", Active: true, Interval: 60, RetryInterval: 60, ResendInterval: 3, URL: "https://e.com", OwnerKey: "ns/h"},
|
||||
} {
|
||||
wire, back := resendOnWire(t, m, nil)
|
||||
if wire != int64(m.ResendInterval) || back != int64(m.ResendInterval) {
|
||||
t.Errorf("%s: wire=%d read back=%d, want %d", m.Type, wire, back, m.ResendInterval)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 0 must overwrite a resend interval stored in Kuma, not keep it.
|
||||
func TestResendIntervalClearedOnUpdate(t *testing.T) {
|
||||
var existing monitor.Base
|
||||
if err := json.Unmarshal([]byte(`{"id":1,"type":"group","name":"g","resendInterval":7}`), &existing); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := Monitor{ID: 1, Type: "group", Name: "g", Active: true, Interval: 60, RetryInterval: 60, OwnerKey: "ns/g"}
|
||||
if wire, back := resendOnWire(t, m, &existing); wire != 0 || back != 0 {
|
||||
t.Errorf("wire=%d read back=%d, want 0", wire, back)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
# domains -> TLS passthrough -> Traefik ClusterIP) from Ingresses carrying the
|
||||
# public label. Standard library only, so the image is the interpreter plus one
|
||||
# file; runs unprivileged as its own user.
|
||||
FROM python:3.15-slim@sha256:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51
|
||||
FROM python:3.15-slim@sha256:94d3426f1def7db4a106a232ac121bfd975393e5dfd40a619b955fae45fe7913
|
||||
|
||||
RUN useradd --system --no-create-home --shell /usr/sbin/nologin controller
|
||||
COPY controller.py /opt/controller.py
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
# 802.1X via authentik
|
||||
|
||||
FreeRADIUS terminates EAP-TTLS and proxies the inner PAP request to the
|
||||
authentik RADIUS outpost `802.1X Radius`. authentik checks the password and
|
||||
the access policy and returns the VLAN.
|
||||
|
||||
## Routing by outer identity
|
||||
|
||||
FreeRADIUS decides on the outer EAP identity (`User-Name`) of each request,
|
||||
before any local EAP processing:
|
||||
|
||||
| Outer identity | Path |
|
||||
|---|---|
|
||||
| `anonymous` or `anonymous@<anything>` (any case) | EAP-TTLS terminated in FreeRADIUS, inner PAP to authentik |
|
||||
| anything else | whole EAP conversation (all rounds, `State` included) proxied to the UDM RADIUS for UniFi Endpoint accounts |
|
||||
|
||||
UniFi Endpoint clients send their real account name as outer identity; their
|
||||
names look like ordinary usernames, so the rule cannot match on name patterns.
|
||||
The UDM's Access-Accept/Reject/Challenge goes to the NAS unchanged, including
|
||||
its Tunnel-* VLAN and MS-MPPE keys. The "Accept without VLAN is rejected" rule
|
||||
applies to the authentik path only. Accounting is answered locally on both
|
||||
paths.
|
||||
|
||||
The anonymous outer identity is mandatory for authentik clients: without it
|
||||
the login goes to the UDM and fails.
|
||||
|
||||
UDM target: home server `udm`, `10.2.11.1` UDP 1812 (the UDM on VLAN 11,
|
||||
UniFi network "(11) RADIUS"), shared secret `UDM_RADIUS_SECRET` from OpenBao
|
||||
`apps/radius`. The UDM RADIUS accepts clients from `10.2.11.0/24` only, not
|
||||
from the pod CIDR.
|
||||
|
||||
### Path to the UDM
|
||||
|
||||
Each FreeRADIUS pod has a second interface `net1` on VLAN 11
|
||||
(NetworkAttachmentDefinition `freeradius-udm`,
|
||||
`kubernetes/apps/authentik/freeradius-network-attachment.yaml`):
|
||||
|
||||
- macvlan on the node link `radius0` (VLAN 11 on `uplink`, no host addresses;
|
||||
VLANConfig in `kubernetes/talos/patches/k8s-{1,2,3}.yaml`). VLAN 11 is in
|
||||
Cilium's `bpf.vlanBypass`, otherwise the tagged frames are dropped on
|
||||
`uplink`.
|
||||
- IPv4 leased from the UDM DHCP server (cni-dhcp-daemon), requested without
|
||||
the routers option: `net1` holds only the connected `10.2.11.0/24`, no
|
||||
default route. The proxy socket is not bound to an address; the kernel's
|
||||
route lookup for `10.2.11.1` picks `net1` and its leased address as source.
|
||||
- IPv6 disabled on `net1` (VLAN 11 sends router advertisements).
|
||||
- The NAS listeners are bound to `eth0` (`interface = eth0`); nothing listens
|
||||
on `net1`.
|
||||
- Traffic on `net1` bypasses Cilium; there is no CNP for the UDM hop.
|
||||
|
||||
`radius0` must exist on every node before FreeRADIUS pods are scheduled
|
||||
there: without it the macvlan ADD fails and the pod stays in
|
||||
`ContainerCreating`.
|
||||
|
||||
## authentik objects
|
||||
|
||||
Defined in `kubernetes/apps/authentik/blueprints-configmap.yaml`, key
|
||||
`dot1x-radius.yaml`:
|
||||
|
||||
| Object | Name | Purpose |
|
||||
|---|---|---|
|
||||
| Flow | `dot1x-authentication-flow` | identification, password, login; no MFA, no enrollment |
|
||||
| Expression policy | `dot1x-has-vlan` | admits users with a valid `dot1x_vlan` group |
|
||||
| RADIUS property mapping | `dot1x-vlan` | puts the VLAN into the Access-Accept |
|
||||
| RADIUS provider | `802.1X Radius` | shared secret `DOT1X_OUTPOST_SECRET`, clients = cluster pod CIDRs |
|
||||
| Application | `dot1x` | bound to `dot1x-has-vlan` |
|
||||
| Outpost | `802.1X Radius` | radius, 2 replicas, ClusterIP, namespace `authentik` |
|
||||
|
||||
The outpost's Kubernetes objects are named from the slugified outpost name:
|
||||
Service/Deployment `ak-outpost-8021x-radius`, pod label
|
||||
`goauthentik.io/outpost-name: 8021x-radius`.
|
||||
|
||||
The shared secret is key `DOT1X_OUTPOST_SECRET` of Secret `radius-secrets`
|
||||
(ExternalSecret from OpenBao `apps/radius`), passed to the authentik worker
|
||||
only (it applies the blueprints) as env `DOT1X_OUTPOST_SECRET`, with
|
||||
`optional: true`: a missing Secret fails only the dot1x blueprint, not
|
||||
authentik. Reloader restarts the worker when `radius-secrets` changes.
|
||||
|
||||
On the FreeRADIUS → outpost hop the shared secret is the only guard: the
|
||||
authentik namespace baseline admits all same-namespace pods, and the
|
||||
provider's client_networks is the pod CIDR.
|
||||
|
||||
FreeRADIUS resolves the outpost Service name at startup; a re-created
|
||||
outpost Service needs the FreeRADIUS pods restarted.
|
||||
|
||||
## VLAN convention
|
||||
|
||||
- A group carries the VLAN as attribute `dot1x_vlan`, an integer VLAN id
|
||||
(1–4094), e.g. `dot1x_vlan: 50`. It must be an unquoted integer: a string
|
||||
such as `"50"` is ignored and the user is denied.
|
||||
- Membership counts through group inheritance (`User.all_groups()`).
|
||||
- A user without any such group is rejected (policy `dot1x-has-vlan`).
|
||||
- A user in several such groups gets the lowest `dot1x_vlan`.
|
||||
- The same attribute applies to WiFi and wired 802.1X.
|
||||
|
||||
Groups and memberships are managed by the owner in authentik, not in
|
||||
blueprints.
|
||||
|
||||
## Reply format
|
||||
|
||||
The mapping sets both:
|
||||
|
||||
- RFC 3580 `Tunnel-Type = VLAN`, `Tunnel-Medium-Type = IEEE-802`,
|
||||
`Tunnel-Private-Group-Id = "<id>"` (raw attribute codes 64, 65, 81; the
|
||||
dictionary bundled with authentik does not define them).
|
||||
- `Cisco-AV-Pair = "dot1x-vlan=<id>"`.
|
||||
|
||||
Contract for FreeRADIUS: if the outpost reply carries the standard Tunnel-*
|
||||
attributes, use them; otherwise translate `Cisco-AV-Pair = "dot1x-vlan=<id>"`
|
||||
into `Tunnel-Type = VLAN`, `Tunnel-Medium-Type = IEEE-802`,
|
||||
`Tunnel-Private-Group-Id = "<id>"`. An Access-Accept without a VLAN is
|
||||
rejected.
|
||||
|
||||
## Test-phase entries
|
||||
|
||||
The FreeRADIUS clients `test` (10.2.50.0/24) and `test6` (fd02:50::/64) and
|
||||
their CIDRs in `freeradius-cnp.yaml` are for the test phase only and must be
|
||||
removed after the test.
|
||||
|
||||
## UniFi objects
|
||||
|
||||
Created through the UniFi Network API (site `default`):
|
||||
|
||||
| Object | ID | Settings |
|
||||
|---|---|---|
|
||||
| Firewall policy "Allow APs RADIUS" | `62c21e63-8c15-4720-adf8-b2333c090e2f` | zone Isolated `10.2.10.0/24` → zone External `10.2.99.91`, UDP `1812-1813`, ALLOW, IPv4 |
|
||||
| RADIUS profile "Cluster 802.1X" | `3e5fcfed-63dc-4e7d-ad51-17cbc074e4bb` | auth `10.2.99.91:1812`, acct `10.2.99.91:1813`, secret `DOT1X_NAS_SECRET`, VLAN assignment on (`vlan_wlan_mode: optional`) |
|
||||
| WLAN "Renner UniFi" | `ef4122f2-4a83-4dca-9ef3-468d29f39a6b` | WPA3 Enterprise (no transition), PMF required, fast roaming, BSS transition, client isolation off, RADIUS profile "Cluster 802.1X"; Application Hotspot, type Passpoint (set in the UniFi UI: the API format differs from the UI's) |
|
||||
|
||||
Passpoint on "Renner UniFi" (UniFi offers Passpoint only with WPA3 Enterprise):
|
||||
venue "Renner", Residential / Private Residence, network type Private with
|
||||
Guest Access, IPv4 single NATed private, IPv6 available, NAI realm
|
||||
`renner.uno` with EAP-TTLS, sub-methods PAP and username/password, domain
|
||||
`renner.uno`, operator friendly name "Renner", connection capability ICMP
|
||||
allow. The AKMs on air are `FT/802.1X` and `802.1X/SHA-256`; plain `802.1X`
|
||||
is not offered.
|
||||
|
||||
## Live login check
|
||||
|
||||
`tests/radius/live.sh` runs EAP-TTLS/PAP logins with `eapol_test` against the
|
||||
deployed FreeRADIUS from a test client range and prints result, duration and
|
||||
the VLAN attributes of the Access-Accept (hex) per login; secrets are read from
|
||||
files and never printed:
|
||||
|
||||
```
|
||||
LIVE_PASSWORD_VAR=CLAUDE_BOT_PASSWORD tests/radius/live.sh 10.2.99.91 claude-bot \
|
||||
~/.config/claude-bot/authentik ~/.config/claude-bot/radius-secret 20
|
||||
```
|
||||
|
||||
For IPv6 set `LIVE_SOURCE` to the workstation's `fd02:50::` address and use
|
||||
`fd99::91`. `group-id=[3530]` is VLAN `50`, `tunnel-type=[0000000d]` VLAN,
|
||||
`medium=[00000006]` IEEE-802.
|
||||
|
||||
## Client setup (SSID "Renner UniFi")
|
||||
|
||||
Setup page: `https://wlan.renner.uno` (behind the Authentik gate, any
|
||||
authentik user), served by the Go service `wlan-setup/`
|
||||
(`kubernetes/apps/authentik/wlan-setup*.yaml`). Android, Apple and Windows get
|
||||
Passpoint profiles with their own app password; Linux an `nmcli` command
|
||||
(NetworkManager has no Passpoint support) that carries no password.
|
||||
|
||||
Passpoint: `/<platform>/install` (`android`, `apple` or `windows`) signs the
|
||||
user in through the authentik OAuth2 application `wlan-setup` (scope
|
||||
`goauthentik.io/api`). On `/<platform>/callback`, the service exchanges the
|
||||
code, creates an app password (`wlan-<platform>-<user>-<timestamp>-<random>`)
|
||||
with the user's own access token, and hands out the profile:
|
||||
|
||||
- Android: `application/x-wifi-config` in the response to that GET, as its
|
||||
installer requires.
|
||||
- Apple: a `.mobileconfig` download.
|
||||
- Windows: a CarrierProvisioning XML signed with XML-DSig (enveloped, C14N
|
||||
1.0, rsa-sha1, chain in KeyInfo) by the Let's Encrypt certificate
|
||||
`wlan-setup-signing`; kept for 5 minutes under a single-use token. The
|
||||
browser is sent to `ms-settings:wifi-provisioning?uri=https://wlan.renner.uno/windows/profile/<token>`;
|
||||
Windows Settings fetches it (Ingress `wlan-setup-provisioning`, without the
|
||||
gate) and offers "Add".
|
||||
|
||||
The service stores nothing else. Deleting the app password in authentik removes
|
||||
that device from the WLAN. The profiles match WLANs that advertise Hotspot 2.0
|
||||
with domain and NAI realm `renner.uno` (EAP-TTLS/PAP), i.e. "Renner UniFi".
|
||||
Creating the app password needs no admin rights.
|
||||
|
||||
App passwords expire when unused. The page creates them with 29 days (the API
|
||||
caps new tokens at 30 days unless the group attribute
|
||||
`goauthentik.io/user/token-maximum-lifetime` allows more). Every 802.1X login
|
||||
with an app password renews it to 60 days: the policy
|
||||
`dot1x-app-password-renew` on the `dot1x-login` binding (at most one write per
|
||||
day). authentik rejects expired app passwords and deletes them in its
|
||||
`clean_expired_models` task. A device that has not connected for 60 days, or a
|
||||
profile that was never installed, loses its app password and needs a new
|
||||
profile from the page.
|
||||
|
||||
All clients use EAP-TTLS with inner PAP. The server certificate is a public
|
||||
Let's Encrypt certificate for `radius.renner.uno`, so no CA needs to be
|
||||
installed. The 802.1X password stage (`dot1x-password`) accepts the authentik
|
||||
password and app passwords. PAP runs inside the TLS tunnel to FreeRADIUS and
|
||||
is never sent in clear over the air.
|
||||
|
||||
### Android
|
||||
|
||||
Passpoint profile from the setup page ("Auf Android installieren").
|
||||
|
||||
### Linux (NetworkManager)
|
||||
|
||||
- Security: WPA & WPA2/3 Enterprise
|
||||
- Authentication: Tunneled TLS (TTLS)
|
||||
- Inner authentication: PAP
|
||||
- CA certificate: system bundle (e.g. `/etc/ssl/certs/ca-certificates.crt`)
|
||||
- Domain: `radius.renner.uno`
|
||||
- Anonymous identity: `anonymous` (mandatory)
|
||||
- Username / password: authentik credentials
|
||||
|
||||
### Windows 11
|
||||
|
||||
Passpoint profile from the setup page ("Auf Windows installieren"). Manual
|
||||
setup without the page: Settings > Network & Internet > Wi-Fi > Manage known
|
||||
networks > Add network:
|
||||
|
||||
- Network name: `Renner UniFi`
|
||||
- Security type: WPA3-Enterprise AES
|
||||
- EAP method: EAP-TTLS
|
||||
- Authentication method: Unencrypted password (PAP)
|
||||
- Server validation: where offered ("Verify server identity" / trusted
|
||||
server name), set `radius.renner.uno`
|
||||
- Do not use Windows sign-in credentials; enter the authentik credentials
|
||||
|
||||
On the first join, check the certificate details before connecting: it must
|
||||
be issued to `radius.renner.uno` by Let's Encrypt. Otherwise cancel.
|
||||
|
||||
If EAP-TTLS is not offered, use Control Panel > "Manually connect to a
|
||||
wireless network" instead.
|
||||
|
||||
### Apple (iOS, iPadOS, macOS)
|
||||
|
||||
Passpoint configuration profile from the setup page ("Profil laden"),
|
||||
installed via Settings (Profile Downloaded) or, on a Mac, System Settings >
|
||||
General > Device Management. It sets `IsHotspot` with domain and NAI realm
|
||||
`renner.uno` (no SSID), security type Any, outer identity `anonymous`,
|
||||
trusted server name `radius.renner.uno`, username and app password; it
|
||||
contains no certificate. Its PayloadIdentifier is fixed, so installing it
|
||||
replaces an earlier profile. Apple devices may show a one-time "trust
|
||||
certificate" prompt for `radius.renner.uno` on the first join.
|
||||
|
||||
## Measurement: authentication latency per SSID
|
||||
|
||||
The UniFi access points log station events (`stahtd`) to Loki. The field
|
||||
`radius_auth_delta` (microseconds) is in a JSON payload after
|
||||
`stahtd_dump_event(): `.
|
||||
|
||||
VAP names per SSID are looked up through the UniFi API: `stat/device`,
|
||||
`vap_table`, map `essid` to `name`.
|
||||
|
||||
The JSON mixes `"key":"v"` and `"key": "v"`, so the filters tolerate an
|
||||
optional space. Values are microseconds. Run once per VAP of "Renner Test" and
|
||||
of "Renner UniFi" and compare avg and max:
|
||||
|
||||
```
|
||||
logcli instant-query --quiet \
|
||||
'avg_over_time({source="unifi-syslog",app="stahtd"} |~ "\"vap\": ?\"<vap>\"" | regexp "radius_auth_delta\": ?\"?(?P<d>[0-9]+)" | unwrap d [24h])'
|
||||
|
||||
logcli instant-query --quiet \
|
||||
'max_over_time({source="unifi-syslog",app="stahtd"} |~ "\"vap\": ?\"<vap>\"" | regexp "radius_auth_delta\": ?\"?(?P<d>[0-9]+)" | unwrap d [24h])'
|
||||
```
|
||||
|
||||
| SSID | VAP(s) | Events | avg radius_auth_delta (us) | max radius_auth_delta (us) |
|
||||
|---|---|---|---|---|
|
||||
| Renner Test | | | | |
|
||||
| Renner UniFi | | | | |
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user