Compare commits

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

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

Closes #1505
2026-10-27 23:25:12 +00:00
280 changed files with 2451 additions and 9659 deletions
+28 -51
View File
@@ -41,14 +41,13 @@ 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.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: ci/images/ansible
# Only rebuild when THIS image's source (or the pipeline file) changed, or
# on a manual run. Without this every step ran on any matching push, so a
@@ -75,7 +74,7 @@ steps:
--destination gitea.renner.uno/gitea_admin/ci-ansible:latest
- name: build-k8s
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: ci/images/k8s
when:
- event: deployment
@@ -98,7 +97,7 @@ steps:
--destination gitea.renner.uno/gitea_admin/ci-k8s:latest
- name: build-vmtest
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: ci/images/vmtest
when:
- event: deployment
@@ -121,7 +120,7 @@ steps:
--destination gitea.renner.uno/gitea_admin/ci-vmtest:latest
- name: build-tofu
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: ci/images/tofu
when:
- event: deployment
@@ -151,7 +150,7 @@ steps:
# Runtime image, not CI tooling: a static musl binary in a FROM-scratch image,
# used as the base for secret-rotation CronJobs. Replaces per-job inline Python.
- name: build-secret-rotator
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: secret-rotator
when:
- event: deployment
@@ -176,7 +175,7 @@ steps:
# review-bot: the Go service that reviews pull requests with a Mistral agent (webhook plus sweep).
# Runs after Gitea is up, so no ghcr mirror.
- name: build-review-bot
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: review-bot
when:
- event: deployment
@@ -198,31 +197,6 @@ 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
@@ -251,7 +225,7 @@ steps:
#
# The gitea_admin/ copy stays until nothing references it any more.
- name: build-alpine-jq
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: ci/images/alpine-jq
when:
- event: deployment
@@ -269,8 +243,7 @@ steps:
- AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n')
- GH_AUTH=$(printf '%s:%s' "$GHCR_USER" "$GHCR_TOKEN" | base64 | tr -d '\n')
- printf '{"auths":{"gitea.renner.uno":{"auth":"%s"},"ghcr.io":{"auth":"%s"}}}' "$AUTH" "$GH_AUTH" > /kaniko/.docker/config.json
# konairius/ gets one tag per build: Gitea's cleanup rule counts tags.
- TAG=$(date +%Y-%m-%d-%H%M)
- TAG=$(date +%Y-%m-%d)
- |
/kaniko/executor \
--context . \
@@ -278,7 +251,9 @@ steps:
--destination gitea.renner.uno/gitea_admin/alpine-jq:$TAG \
--destination gitea.renner.uno/gitea_admin/alpine-jq:latest \
--destination gitea.renner.uno/konairius/alpine-jq:$TAG \
--destination ghcr.io/konairius/alpine-jq:$TAG
--destination gitea.renner.uno/konairius/alpine-jq:latest \
--destination ghcr.io/konairius/alpine-jq:$TAG \
--destination ghcr.io/konairius/alpine-jq:latest
# Runtime image, not CI tooling: upstream containernetworking/plugins binaries
# baked in, consumed by the multus cni-plugins-installer DaemonSet. Replaces a
@@ -302,7 +277,7 @@ steps:
# note on build-alpine-jq. Anything an app needs while the cluster is still
# being built cannot live only in that cluster's own registry.
- name: build-cni-plugins
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: ci/images/cni-plugins
when:
- event: deployment
@@ -320,8 +295,7 @@ steps:
- AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n')
- GH_AUTH=$(printf '%s:%s' "$GHCR_USER" "$GHCR_TOKEN" | base64 | tr -d '\n')
- printf '{"auths":{"gitea.renner.uno":{"auth":"%s"},"ghcr.io":{"auth":"%s"}}}' "$AUTH" "$GH_AUTH" > /kaniko/.docker/config.json
# konairius/ gets one tag per build: Gitea's cleanup rule counts tags.
- TAG=$(date +%Y-%m-%d-%H%M)
- TAG=$(date +%Y-%m-%d)
- |
/kaniko/executor \
--context . \
@@ -329,7 +303,9 @@ steps:
--destination gitea.renner.uno/gitea_admin/cni-plugins:$TAG \
--destination gitea.renner.uno/gitea_admin/cni-plugins:latest \
--destination gitea.renner.uno/konairius/cni-plugins:$TAG \
--destination ghcr.io/konairius/cni-plugins:$TAG
--destination gitea.renner.uno/konairius/cni-plugins:latest \
--destination ghcr.io/konairius/cni-plugins:$TAG \
--destination ghcr.io/konairius/cni-plugins:latest
# Runtime image, not CI tooling: alpine + the openssl CLI, for shell scripts
# needing s_client/x509. Named for what it is; first consumer is the paperless
@@ -342,7 +318,7 @@ steps:
# gitea.renner.uno is not there to serve it. A cold-rebuild rehearsal left
# paperless in Init:ErrImagePull on exactly this image.
- name: build-alpine-openssl
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: ci/images/alpine-openssl
when:
- event: deployment
@@ -360,8 +336,7 @@ steps:
- AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n')
- GH_AUTH=$(printf '%s:%s' "$GHCR_USER" "$GHCR_TOKEN" | base64 | tr -d '\n')
- printf '{"auths":{"gitea.renner.uno":{"auth":"%s"},"ghcr.io":{"auth":"%s"}}}' "$AUTH" "$GH_AUTH" > /kaniko/.docker/config.json
# konairius/ gets one tag per build: Gitea's cleanup rule counts tags.
- TAG=$(date +%Y-%m-%d-%H%M)
- TAG=$(date +%Y-%m-%d)
- |
/kaniko/executor \
--context . \
@@ -369,7 +344,9 @@ steps:
--destination gitea.renner.uno/gitea_admin/alpine-openssl:$TAG \
--destination gitea.renner.uno/gitea_admin/alpine-openssl:latest \
--destination gitea.renner.uno/konairius/alpine-openssl:$TAG \
--destination ghcr.io/konairius/alpine-openssl:$TAG
--destination gitea.renner.uno/konairius/alpine-openssl:latest \
--destination ghcr.io/konairius/alpine-openssl:$TAG \
--destination ghcr.io/konairius/alpine-openssl:latest
# Heating CAN readers: open3e (Vitocal/VX3) + E3onCAN (E380 meter), one image
# run as two services on edge-1. Replaces pip-into-venv on the host, where an
@@ -377,7 +354,7 @@ steps:
# Ansible converge. Not a ci-* image -- it is a runtime artifact -- but it
# builds the same way and lands in the same registry so the audit sees it.
- name: build-heating-can
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: ci/images/heating-can
when:
- event: deployment
@@ -403,7 +380,7 @@ steps:
# services (public Ingress hosts -> TLS passthrough -> Traefik ClusterIP).
# Runtime artifact like heating-can; same registry so the audit tracks it.
- name: build-netbird-service-sync
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: ci/images/netbird-service-sync
when:
- event: deployment
@@ -428,7 +405,7 @@ steps:
# authentik-gate-sync: the authentik-gate ingress class (Ingress -> Authentik
# proxy gate + Traefik route). Its unit tests run as a stage of the image build.
- name: build-authentik-gate-sync
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: ci/images/authentik-gate-sync
when:
- event: deployment
@@ -454,7 +431,7 @@ steps:
# kuma-sync: reconciles KumaMonitor resources into Uptime Kuma. Its unit
# tests run as a stage of the image build.
- name: build-kuma-sync
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: ci/images/kuma-sync
when:
- event: deployment
@@ -480,7 +457,7 @@ steps:
# cloudflare-lb-sync: reconciles CloudflareLoadBalancer resources into
# Cloudflare. Its unit tests run as a stage of the image build.
- name: build-cloudflare-lb-sync
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: ci/images/cloudflare-lb-sync
when:
- event: deployment
@@ -506,7 +483,7 @@ steps:
# bird: BGP daemon for the pod-network mesh over pod0 (#576). Runtime
# artifact like heating-can; same registry so the audit tracks it.
- name: build-bird
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: ci/images/bird
when:
- event: deployment
@@ -530,7 +507,7 @@ steps:
# Orchestrator for the monthly backup-verify CronJob (velero ns).
- name: build-backup-verify
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: ci/images/backup-verify
when:
- event: deployment
+1 -1
View File
@@ -131,7 +131,7 @@ steps:
# --build-arg to the resolved version (not "latest") guarantees the tag and
# the image content agree even if a new release lands mid-run.
- name: build
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: ci/images/claude
depends_on: [decide]
environment:
+1 -1
View File
@@ -172,7 +172,7 @@ steps:
# whenever it trails the deployed server. If you bump the server, bump here.
- name: woodpecker-lint
depends_on: [] # see note above `steps:`
image: woodpeckerci/woodpecker-cli:v3.20.1-alpine@sha256:74c576faf49cce699825d11c7d7bd38ab5f007dd4f57cdbdd55099d2c0f2621a
image: woodpeckerci/woodpecker-cli:v3.19.0-alpine@sha256:27a77117e38d7bbdc380f0e90ff3ee4871ab1eefa78b5d9136a2c969fbecd9f4
commands:
- |
set -eu
+5 -5
View File
@@ -35,25 +35,25 @@ when:
steps:
- name: model-selection-tests
depends_on: []
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.318
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
commands:
- sh scripts/ci/test-model-selection.sh
- name: pr-review-mistral-tests
depends_on: []
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.318
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
commands:
- sh scripts/ci/test-pr-review-mistral.sh
- name: promtool-rules-guard-tests
depends_on: []
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.318
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
commands:
- sh scripts/ci/test-promtool-rules.sh
- name: yaml-citations-guard-tests
depends_on: []
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.318
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
commands:
- sh scripts/ci/test-yaml-citations.sh
@@ -63,7 +63,7 @@ steps:
# bridge, and the secret guard.
- name: hermes-skill-tests
depends_on: []
image: python:3.15-alpine@sha256:20a82dbc2783112ddb91d1534603f802d222a3e63e593cd002eb2ac534e70211
image: python:3.15-alpine@sha256:d44b1254cb92329418d164b22b20d86bbd3869d48e80e09e476531a409a728bb
environment:
PYTHONDONTWRITEBYTECODE: "1"
commands:
+1 -1
View File
@@ -26,7 +26,7 @@ steps:
# fetch-models.sh response mapping. No cluster, no secrets, no network — the
# ci-claude image just provides sh + jq. Keeps the flag-injection regression
# test from rotting (it exercises the exact injection shapes).
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.318
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
commands:
- sh scripts/ci/test-model-selection.sh
+5 -4
View File
@@ -1,5 +1,5 @@
# Builds ci/images/hermes (nousresearch/hermes-agent plus the agent's CLIs) as
# gitea.renner.uno/konairius/hermes:YYYY-MM-DD-HHMM, one tag per build.
# gitea.renner.uno/konairius/hermes:build.<unix-ts> and :latest.
when:
- event: push
branch: main
@@ -10,7 +10,7 @@ when:
steps:
- name: build
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: ci/images/hermes
environment:
# registry-bot write:package PAT (konairius org secret).
@@ -20,9 +20,10 @@ steps:
- mkdir -p /kaniko/.docker
- AUTH=$(printf '%s:%s' registry-bot "$GITEA_TOKEN" | base64 | tr -d '\n')
- printf '{"auths":{"gitea.renner.uno":{"auth":"%s"}}}' "$AUTH" > /kaniko/.docker/config.json
- TAG=$(date +%Y-%m-%d-%H%M)
- TAG="build.$(date +%s)"
- |
/kaniko/executor \
--context . \
--dockerfile Containerfile \
--destination gitea.renner.uno/konairius/hermes:$TAG
--destination gitea.renner.uno/konairius/hermes:$TAG \
--destination gitea.renner.uno/konairius/hermes:latest
+1 -1
View File
@@ -6,7 +6,7 @@ when:
steps:
- name: go-test
image: golang:1.27.3-bookworm@sha256:d9b6a1beb9474adc2a1ce842573639b44b2a0dd49b6669f51d3821baf9ec37c9
image: golang:1.27.3-bookworm@sha256:972b4110ba0f47539a975c31e52c64765e1ff19f2d74469ae4a595c6676e80a5
commands:
- test -z "$(gofmt -l review-bot)"
- go -C review-bot vet ./...
+1 -1
View File
@@ -70,7 +70,7 @@ steps:
# this step is skipped — otherwise we'd fork-bomb PRs.
when:
- branch: main
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.318
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
environment:
CLAUDE_CODE_OAUTH_TOKEN:
from_secret: claude_code_oauth_token
+1 -1
View File
@@ -30,7 +30,7 @@ when:
steps:
- name: update-audit-agent
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.318
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
environment:
CLAUDE_CODE_OAUTH_TOKEN:
from_secret: claude_code_oauth_token
-13
View File
@@ -1,13 +0,0 @@
# 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 ./...
+91 -25
View File
@@ -1,17 +1,13 @@
# 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`):
Talos node (opentofu `vm_153_k8s_edge.tf`), plus the new CAN bridge:
| 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) |
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.
| open3e | venv + systemd | Innomaker USB2CAN (gs_usb) | Mosquitto (`open3e` user) |
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
@@ -23,13 +19,17 @@ 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 <hostname>`
2. Write ISO to a USB stick, boot the target box from it, run `edge-install`
(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,18 +58,24 @@ 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. It depends on
the base play having run: it needs docker.
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.
A host with nothing attached is simply absent from these groups and needs
A host with nothing attached is simply absent from all three groups and needs
none of these commands.
## Secrets (before first play run)
Create `inventory/group_vars/edge_host/vault.yml` with ansible-vault. The
zwave-js value comes out of the cluster:
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 session secret
@@ -78,6 +84,7 @@ 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")
```
@@ -95,6 +102,9 @@ 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:
@@ -112,6 +122,11 @@ 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)
@@ -119,13 +134,22 @@ Afterwards:
## Day-2 notes
- 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).
- 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.
## Logging & eMMC
@@ -138,9 +162,7 @@ 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`, 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
- **Grafana Alloy ships the journal to Loki** (`edge-alloy.service`). 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.
@@ -168,7 +190,7 @@ journal commit).
- **Fix:** the `zwave-js` container preloads **`libeatmydata`**
(`LD_PRELOAD=/usr/lib/libeatmydata.so`, mounted read-only from
`zwave-js/libeatmydata-alpine<X.Y>.so`). This makes `fsync`/`fdatasync` no-ops *for that
`zwave-js/libeatmydata.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
@@ -177,9 +199,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
`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.
`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.
- **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
@@ -189,3 +211,47 @@ 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,6 +2,7 @@
# 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
@@ -35,8 +36,25 @@ 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
+1 -1
View File
@@ -8,7 +8,7 @@
# two plays over this group -- withdraw the static assignments fleet-wide, then
# hand the address to keepalived -- and both need the same value. Duplicating a
# service address across plays is exactly the sort of thing that drifts.
ntp_vip6: "fd02:40::123/128"
ntp_vip6: "fd40::123/128"
# Link-local address carried as the FIRST virtual_ipaddress of the IPv6 VRRP
# instance. RFC 5798 gives an IPv6 virtual router a link-local primary, and
@@ -1,8 +0,0 @@
---
# DSM's own python3 is 3.8; plays/configure-synology-python.yml installs this.
ansible_python_interpreter: /var/packages/Python3.9/target/usr/bin/python3.9
# DSM offers no sftp/scp.
ansible_ssh_transfer_method: piped
# claude-bot is a DSM administrator here; its password is unknown.
ssh_ca_claude_sudo_all: true
+24 -17
View File
@@ -8,6 +8,8 @@
# pve_cluster (jupiter/neptune/saturn) run Talos on bare metal; no SSH at all
# authentik_db / authentik_app authentik runs in-cluster (CNPG)
# grafana runs in-cluster
# synology_nas (nas-1) powered off; Garage and the PBS client
# it hosted are gone with it
# Their hosts no longer resolve, except the three that resolve to Talos nodes.
[netbird_server]
@@ -18,16 +20,10 @@ 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 VLAN sub-interface. Nothing here
# assumes any peripheral is attached.
# flash-wear handling, the log shipper, the MQTT bridge, 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]
nas-1.renner.uno ansible_user=root
nas-2.renner.uno ansible_user=root
# Capability groups. Membership states which hardware a host physically has,
# and each service playbook targets the group for its own concern. No flags and
@@ -37,18 +33,32 @@ 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.
# resolves group_vars/edge_host -- where the local MQTT contract lives -- so it
# cannot silently come up without edge_mqtt_host and friends.
[zwave_gateway]
edge-1.iot.renner.uno
[heating_can]
edge-1.iot.renner.uno
[edge_host:children]
zwave_gateway
heating_can
# 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 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_cluster:children]
edge_host
@@ -60,9 +70,6 @@ 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
# Hosts whose admin logins accept only ssh_admin_authorized_keys (break-glass);
# everything else needs a CA certificate. Members must be in [ssh_ca].
@@ -1,19 +0,0 @@
---
# Ansible needs Python >= 3.9 on the target; DSM ships 3.8. Synology's
# Python3.9 package installs beside it. raw, because the modules cannot run
# before it is there; raw is skipped in check mode.
- name: Install Python 3.9 on the Synology NAS
hosts: synology_nas
become: true
gather_facts: false
tasks:
- name: Install the Python3.9 package
ansible.builtin.raw: >-
if [ -x {{ ansible_python_interpreter }} ]; then echo present;
else /usr/syno/bin/synopkg install_from_server Python3.9; fi
register: synology_python_install
changed_when: "'present' not in synology_python_install.stdout"
- name: Check the interpreter exists
ansible.builtin.raw: test -x {{ ansible_python_interpreter }}
changed_when: false
+70
View File
@@ -0,0 +1,70 @@
---
# 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
+2 -86
View File
@@ -8,6 +8,7 @@
# 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.
@@ -30,96 +31,11 @@
# 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
+18
View File
@@ -0,0 +1,18 @@
---
# 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
-1
View File
@@ -22,7 +22,6 @@
become: true
roles:
- common
- timesyncd
- docker-install
- netbird-proxy
- netbird-proxy-watchdog
-1
View File
@@ -16,6 +16,5 @@
become: true
roles:
- common
- timesyncd
- docker-install
- netbird-server
+1 -4
View File
@@ -62,7 +62,7 @@
# One node at a time. The keepalived role withdraws the statically assigned
# IPv6 service address and restarts keepalived so the address is re-taken
# under VRRP control; doing that across every NTP server at once would leave
# the whole fleet with nobody holding the service address until the elections settle.
# the whole fleet with nobody holding fd40::123 until the elections settle.
# Serialised, the remaining members keep answering throughout.
serial: 1
@@ -83,9 +83,6 @@
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 fd02:40::123 -- so an IPv4-only
# service address ntp.srv.renner.uno resolves to fd40::123 -- so an IPv4-only
# allow list refuses every client that uses the documented name.
#
# The hosts carried `allow fd00::/8` for a long time without it ever being in
+15 -5
View File
@@ -8,11 +8,12 @@
lineinfile:
path: /etc/hosts
regexp: '^127\.0\.1\.1\s+{{ inventory_hostname }}'
line: "{{ ansible_facts['default_ipv4']['address'] }} {{ inventory_hostname }} {{ inventory_hostname_short | default(inventory_hostname.split('.')[0]) }}"
line: "{{ ansible_default_ipv4.address }} {{ inventory_hostname }} {{ inventory_hostname_short | default(inventory_hostname.split('.')[0]) }}"
state: present
when:
- ansible_facts['virtualization_type'] == 'lxc'
- ansible_facts['default_ipv4']['address'] is defined
- ansible_virtualization_type is defined
- ansible_virtualization_type == 'lxc'
- ansible_default_ipv4.address is defined
# -------------------------------------------------------
# 1. Install Common Packages
@@ -54,9 +55,18 @@
# via plays/configure-ssh-ca.yml.
# -------------------------------------------------------
# 5. Time sync
# 5. Disable NTP on VMs/LXC (use host time)
# -------------------------------------------------------
# Moved to separate roles: timesyncd (internet VPS), chrony-ntp-server (edge).
- name: Stop and disable systemd-timesyncd on VMs/LXC
systemd:
name: systemd-timesyncd
state: stopped
enabled: no
masked: yes
when:
- ansible_virtualization_type is defined
- ansible_virtualization_type in ['kvm', 'lxc']
ignore_errors: yes
# -------------------------------------------------------
# 6. Certificates
@@ -15,8 +15,6 @@
# amd64 asset for exactly this tag.
crowdsec_bouncer_version: v0.0.36
crowdsec_bouncer_sha256: f86e4b72693549d99f40a9402abefb894108f047a3fbe6e72fada25ee17ce88b
# Controller cache for the release archive; it outlives /tmp cleanups.
crowdsec_bouncer_download_dir: "{{ lookup('ansible.builtin.env', 'HOME') }}/.cache/ansible/crowdsec"
# The LAPI over the public path these hosts already serve, see
# kubernetes/infrastructure/crowdsec/lapi-ingress.yaml. The proxies cannot
@@ -32,23 +32,13 @@
# The controller has v4, so it does the fetching. The checksum is verified
# there, before anything is copied to a host. Also in check mode: the fetch
# only writes on the controller, and the copy below needs the file.
- name: Create the controller download directory
ansible.builtin.file:
path: "{{ crowdsec_bouncer_download_dir }}"
state: directory
mode: "0755"
delegate_to: localhost
become: false
run_once: true
check_mode: false
- name: Fetch the pinned bouncer release on the controller
check_mode: false
ansible.builtin.get_url:
url: >-
https://github.com/crowdsecurity/cs-firewall-bouncer/releases/download/{{
crowdsec_bouncer_version }}/crowdsec-firewall-bouncer-linux-amd64.tgz
dest: "{{ crowdsec_bouncer_download_dir }}/crowdsec-firewall-bouncer-{{ crowdsec_bouncer_version }}.tgz"
dest: /tmp/crowdsec-firewall-bouncer-{{ crowdsec_bouncer_version }}.tgz
checksum: sha256:{{ crowdsec_bouncer_sha256 }}
mode: "0644"
delegate_to: localhost
@@ -63,7 +53,7 @@
# host.
- name: Copy the bouncer archive to the host
ansible.builtin.copy:
src: "{{ crowdsec_bouncer_download_dir }}/crowdsec-firewall-bouncer-{{ crowdsec_bouncer_version }}.tgz"
src: /tmp/crowdsec-firewall-bouncer-{{ crowdsec_bouncer_version }}.tgz
dest: /opt/crowdsec-firewall-bouncer-{{ crowdsec_bouncer_version }}.tgz
mode: "0644"
@@ -7,9 +7,7 @@
edge_alloy_config_file: /etc/alloy/edge.alloy
edge_alloy_http_port: 12345
# 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_alloy_bin: /usr/bin/grafana-alloy # Arch `grafana-alloy` pkg names the binary this, not `alloy`
edge_loki_push_url: "https://loki.renner.uno/loki/api/v1/push"
edge_loki_username: edge-1
edge_alloy_labels:
+2 -24
View File
@@ -6,18 +6,15 @@
# directly, so one journal source covers everything. Alloy s storage/WAL sits
# on /run (tmpfs) so it does not write to flash either.
# Alloy runs from the upstream image (see edge-alloy.service); the Arch package
# lacks journal support.
- name: Remove the grafana-alloy package
- name: Install grafana-alloy
ansible.builtin.package:
name: grafana-alloy
state: absent
state: present
- name: Create alloy system group
ansible.builtin.group:
name: alloy
system: true
register: edge_alloy_group
- name: Create alloy system user
ansible.builtin.user:
@@ -27,25 +24,6 @@
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,35 +1,32 @@
[Unit]
Description=Grafana Alloy (journal -> Loki)
Description=Grafana Alloy (edge-1 log shipper -> Loki)
Documentation=https://grafana.com/docs/alloy/
After=docker.service network-online.target
Requires=docker.service
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
# 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 \
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 \
--server.http.listen-addr=127.0.0.1:{{ edge_alloy_http_port }} \
--storage.path=/var/lib/alloy \
/etc/alloy/config.alloy
ExecStop=/usr/bin/docker stop edge-alloy
Restart=always
--storage.path=%t/alloy \
{{ edge_alloy_config_file }}
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
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
@@ -0,0 +1,71 @@
---
# 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"
@@ -0,0 +1,54 @@
# 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},
}
}
@@ -0,0 +1,20 @@
# 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,
}
}
@@ -0,0 +1,10 @@
---
- 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
+172
View File
@@ -0,0 +1,172 @@
---
# 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
@@ -0,0 +1,36 @@
# 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 }}"
@@ -0,0 +1,8 @@
# 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 }}
+3 -3
View File
@@ -99,7 +99,7 @@
# quietly proceed either. install-edge-host.yml is a separate play with no
# ordering relationship to this one, so nothing guarantees it has been re-run
# to drop the [Address] block. If it has not, edge-1 would end up holding
# the service address statically AND running a VRRP instance for the same address:
# fd40::123 statically AND running a VRRP instance for the same address:
# keepalived would believe it had withdrawn the address while networkd kept it
# assigned, and the host would go on answering NTP with a clock the health
# check had already rejected.
@@ -107,9 +107,9 @@
# That is the precise failure of 2026-08-04, on the host it happened to, so it
# fails fast rather than being left to inspection.
# Matched on the bare address, not the full address/prefixlen, and anchored
# with \b so it cannot also hit a longer address sharing the prefix (fd02:40::1234).
# with \b so it cannot also hit a longer address sharing the prefix (fd40::1234).
#
# Searching for the literal "fd02:40::123/128" would fail open: a hand-written
# Searching for the literal "fd40::123/128" would fail open: a hand-written
# [Address] using any other mask -- /64, say -- would not match, the assert
# below would pass, and keepalived would be handed an address networkd is
# still assigning. Since this check is detect-only, with no delete afterwards
@@ -2,7 +2,7 @@
# Managed by Ansible - Do not edit manually
global_defs {
router_id {{ ansible_facts['hostname'] }}_ntp
router_id {{ ansible_hostname }}_ntp
enable_script_security
script_user root
}
@@ -0,0 +1,23 @@
---
# --- 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
@@ -0,0 +1,141 @@
#!/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()
@@ -0,0 +1,10 @@
-----BEGIN CERTIFICATE-----
MIIBdTCCARygAwIBAgIUN8ne4k4Wif9O83GDyKiBP0Ma7EIwCgYIKoZIzj0EAwIw
GTEXMBUGA1UEAxMOUmVubmVyIE1RVFQgQ0EwHhcNMjYxMDA2MTUzMDE1WhcNMzYx
MDAzMTUzMDE1WjAZMRcwFQYDVQQDEw5SZW5uZXIgTVFUVCBDQTBZMBMGByqGSM49
AgEGCCqGSM49AwEHA0IABOZhZF2JDoyvVg2oIXSbJAuAWDcoG22RxPA4oMKRMA/b
XzIY566D2k6cVnWqbil9s/q4O2mj+Epo7plBw8NwuOOjQjBAMA4GA1UdDwEB/wQE
AwICpDAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBQcqSDDiLZzhrvqq9f16Y08
CO7QfzAKBggqhkjOPQQDAgNHADBEAiBFkxd9pXnqIcjReEcOTvCBZ3HxKu2jNQGL
/le7gunTGgIgZapWAcu4awa+wmZcR/z2luOjD2GhIT15um14Z+sstJk=
-----END CERTIFICATE-----
@@ -0,0 +1,13 @@
---
# 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
+109
View File
@@ -0,0 +1,109 @@
---
# 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
@@ -0,0 +1,21 @@
# 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
@@ -0,0 +1,21 @@
# 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
@@ -0,0 +1,30 @@
# 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
@@ -0,0 +1,13 @@
{{ {
"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 }}
@@ -0,0 +1,19 @@
# 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
+4 -15
View File
@@ -11,8 +11,6 @@
- curl
- ca-certificates
- gnupg
# deb822_repository
- python3-debian
state: present
update_cache: true
@@ -23,19 +21,10 @@
creates: /usr/share/keyrings/netbird-archive-keyring.gpg
- name: Add NetBird repository
ansible.builtin.deb822_repository:
name: netbird
types: deb
uris: https://pkgs.netbird.io/debian
suites: stable
components: main
signed_by: /usr/share/keyrings/netbird-archive-keyring.gpg
# A one-line netbird.list beside netbird.sources lists the repository twice.
- name: Remove the one-line NetBird repository
ansible.builtin.file:
path: /etc/apt/sources.list.d/netbird.list
state: absent
ansible.builtin.apt_repository:
repo: "deb [signed-by=/usr/share/keyrings/netbird-archive-keyring.gpg] https://pkgs.netbird.io/debian stable main"
state: present
filename: netbird
- name: Install NetBird client
ansible.builtin.apt:
@@ -1,14 +0,0 @@
# 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
-21
View File
@@ -1,21 +0,0 @@
---
# 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
@@ -6,15 +6,15 @@
apt:
name: qemu-guest-agent
state: present
when:
- ansible_facts['os_family'] == 'Debian'
when:
- ansible_os_family is defined and ansible_os_family == 'Debian'
- name: Install QEMU Guest Agent (RHEL/Rocky VMs)
dnf:
name: qemu-guest-agent
state: present
when:
- ansible_facts['os_family'] == 'RedHat'
when:
- ansible_os_family is defined and ansible_os_family == 'RedHat'
- name: Ensure qemu-guest-agent is started and enabled
service:
@@ -0,0 +1,5 @@
---
# SocketCAN interface. Consumed by heating-can.
# --- CAN / open3e (Viessmann E3 external bus) ---
edge_can_interface: can0
edge_can_bitrate: 250000
@@ -0,0 +1,7 @@
---
# 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
+20
View File
@@ -0,0 +1,20 @@
---
# 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
@@ -0,0 +1,17 @@
# 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.
-3
View File
@@ -16,6 +16,3 @@ ssh_ca_claude_principal: claude-bot
# Units claude-bot may restart/reload via sudo on this host, e.g. [netbird].
ssh_ca_claude_sudo_units: []
# Full passwordless sudo for claude-bot on this host.
ssh_ca_claude_sudo_all: false
+12 -4
View File
@@ -1,15 +1,23 @@
---
# Replaces the admin logins' authorized_keys with ssh_admin_authorized_keys
# (break-glass + CI). Everything else logs in with a CA certificate.
# ssh_ca_admin_logins comes from main.yml, which runs first in the same play.
- name: Look up the admin logins
ansible.builtin.getent:
database: passwd
key: "{{ item }}"
fail_key: false
loop: "{{ ssh_ca_owner_users }}"
register: ssh_ca_admin_lookup
- name: Set the admin logins' authorized_keys to the managed list
ansible.posix.authorized_key:
user: "{{ item[0] }}"
user: "{{ item.item }}"
key: |
{% for k in ssh_admin_authorized_keys %}
{{ (k.options ~ ' ') if (k.options | default('')) else '' }}{{ k.key }}
{% endfor %}
exclusive: true
loop: "{{ ssh_ca_admin_logins }}"
loop: "{{ ssh_ca_admin_lookup.results }}"
loop_control:
label: "{{ item[0] }}"
label: "{{ item.item }}"
when: (item.ansible_facts.getent_passwd | default({})).get(item.item) is not none
+14 -129
View File
@@ -23,7 +23,6 @@
owner: root
group: root
mode: "0644"
register: ssh_ca_trusted_file
# sshd refuses all public key logins when RevokedKeys is set but unreadable,
# so the file is always deployed, even when empty.
@@ -35,26 +34,6 @@
group: root
mode: "0644"
# /etc/passwd, not getent: DSM has no getent binary. The admin logins are
# local accounts. Each entry is the split passwd line (0 name, 5 home).
- name: Read the local accounts
ansible.builtin.slurp:
src: /etc/passwd
register: ssh_ca_passwd_file
- name: Find the existing admin logins
vars:
ssh_ca_passwd: "{{ ssh_ca_passwd_file.content | b64decode | trim | split('\n') | map('split', ':') | list }}"
ansible.builtin.set_fact:
ssh_ca_admin_logins: "{{ ssh_ca_passwd | selectattr('0', 'in', ssh_ca_owner_users) | list }}"
ssh_ca_claude_exists: "{{ ssh_ca_claude_user in ssh_ca_passwd | map('first') }}"
# DSM manages accounts with synouser/synogroup and has no useradd.
- name: Detect DSM
ansible.builtin.stat:
path: /usr/syno/sbin/synouser
register: ssh_ca_synouser
# systemd-journal: read every journal without sudo (see sudoers-claude.j2).
- name: Create the Claude account
ansible.builtin.user:
@@ -64,57 +43,14 @@
password: "!"
groups: systemd-journal
append: true
when: not ssh_ca_synouser.stat.exists
- name: Create the Claude account on DSM
when: ssh_ca_synouser.stat.exists
block:
# synouser requires a password; nobody knows it and sshd refuses passwords.
- name: Create the DSM account
ansible.builtin.command:
argv:
- /usr/syno/sbin/synouser
- --add
- "{{ ssh_ca_claude_user }}"
- "{{ lookup('ansible.builtin.password', '/dev/null', length=40, chars=['ascii_letters', 'digits']) }}"
- Claude Bot
- "0"
- ""
- "0"
# command runs in check mode here.
when:
- not ssh_ca_claude_exists
- not ansible_check_mode
no_log: true
# Members are listed as "<n>:[<name>]".
- name: Read the administrators members
ansible.builtin.command: /usr/syno/sbin/synogroup --get administrators
register: ssh_ca_dsm_admins_out
changed_when: false
check_mode: false
- name: Find the administrators members
ansible.builtin.set_fact:
ssh_ca_dsm_admins: >-
{{ ssh_ca_dsm_admins_out.stdout_lines | select('match', '^[0-9]+:\[.*\]$')
| map('regex_replace', '^[0-9]+:\[(.*)\]$', '\1') | list }}
# synogroup --member replaces the member list; a parse that lost the owner
# admin login would drop it.
- name: Check the member list holds the owner admin login
ansible.builtin.assert:
that:
- ssh_ca_dsm_admins | intersect(ssh_ca_owner_users) | length > 0
fail_msg: "administrators members lack {{ ssh_ca_owner_users }}: {{ ssh_ca_dsm_admins }}"
# DSM allows SSH for administrators only.
- name: Add the Claude account to administrators
ansible.builtin.command:
argv: "{{ ['/usr/syno/sbin/synogroup', '--member', 'administrators'] + ssh_ca_dsm_admins + [ssh_ca_claude_user] }}"
when:
- ssh_ca_claude_user not in ssh_ca_dsm_admins
- not ansible_check_mode
- name: Look up the owner admin logins
ansible.builtin.getent:
database: passwd
key: "{{ item }}"
fail_key: false
loop: "{{ ssh_ca_owner_users }}"
register: ssh_ca_owner_lookup
- name: Create the principals directory
ansible.builtin.file:
@@ -127,13 +63,14 @@
- name: Accept the owner principal for the existing admin logins
ansible.builtin.copy:
content: "{{ ssh_ca_owner_principal }}\n"
dest: "/etc/ssh/auth_principals/{{ item[0] }}"
dest: "/etc/ssh/auth_principals/{{ item.item }}"
owner: root
group: root
mode: "0644"
loop: "{{ ssh_ca_admin_logins }}"
loop: "{{ ssh_ca_owner_lookup.results }}"
loop_control:
label: "{{ item[0] }}"
label: "{{ item.item }}"
when: (item.ansible_facts.getent_passwd | default({})).get(item.item) is not none
- name: Accept the Claude principal for the Claude account
ansible.builtin.copy:
@@ -143,12 +80,6 @@
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
@@ -157,48 +88,6 @@
group: root
mode: "0440"
validate: visudo -cf %s
when: not ssh_ca_synouser.stat.exists
# DSM has no visudo, so the rules are checked on the controller.
- name: Install the Claude sudo rules on DSM
when: ssh_ca_synouser.stat.exists
vars:
ssh_ca_sudoers: "{{ lookup('ansible.builtin.template', 'sudoers-claude.j2') | trim }}"
block:
- name: Check the sudo rules on the controller
ansible.builtin.command:
cmd: visudo -c -f -
stdin: "{{ ssh_ca_sudoers }}"
delegate_to: localhost
become: false
changed_when: false
check_mode: false
- name: Install the checked sudo rules
ansible.builtin.copy:
content: "{{ ssh_ca_sudoers }}\n"
dest: "/etc/sudoers.d/{{ ssh_ca_claude_user }}"
owner: root
group: root
mode: "0440"
- name: Create the sshd drop-in directory
ansible.builtin.file:
path: /etc/ssh/sshd_config.d
state: directory
owner: root
group: root
mode: "0755"
# DSM's sshd_config has no Include. sshd keeps the first value it reads, so
# the Include goes first for the drop-ins to win.
- name: Include the sshd drop-in directory
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
line: Include /etc/ssh/sshd_config.d/*.conf
insertbefore: BOF
validate: sshd -t -f %s
register: ssh_ca_include_line
# A broken drop-in only bites on the next sshd restart, so it is tested here
# and removed again if the full config does not parse.
@@ -211,7 +100,6 @@
owner: root
group: root
mode: "0644"
register: ssh_ca_dropin_file
- name: Test the full sshd config
ansible.builtin.command: sshd -t
@@ -248,11 +136,10 @@
fail_msg: "sshd -T does not show the SSH CA settings; another config line takes precedence"
# The unit is sshd.service on Arch and RedHat, ssh.service on Debian/Ubuntu,
# where sshd.service is an alias that resolves to it. The running daemon is
# reloaded when a file it reads at start changed; a reload keeps open
# where sshd.service is an alias that resolves to it. sshd -T only parses the
# files, so the running daemon is reloaded on every run; a reload keeps open
# sessions. None runs when sshd is socket activated and stopped, or per
# connection (ssh-mesh-only role); the next start reads the config.
# Principals and revoked keys are read per login and need no reload.
# systemctl directly: service_facts took 3-12 s per host.
- name: Find the running sshd unit
ansible.builtin.command: systemctl list-units --state=running --plain --no-legend --type=service sshd.service ssh.service
@@ -274,9 +161,7 @@
ansible.builtin.systemd:
name: "{{ ssh_ca_sshd_units | first }}"
state: reloaded
when:
- ssh_ca_sshd_units | length == 1
- ssh_ca_trusted_file is changed or ssh_ca_include_line is changed or ssh_ca_dropin_file is changed
when: ssh_ca_sshd_units | length == 1
- name: Report the admin logins' authorized keys
ansible.builtin.include_tasks: report.yml
+4 -3
View File
@@ -3,10 +3,11 @@
# [ssh_keys_managed] have this reduced to ssh_admin_authorized_keys.
- name: Find the admin logins' authorized_keys
ansible.builtin.stat:
path: "{{ item[5] }}/.ssh/authorized_keys"
loop: "{{ ssh_ca_admin_logins }}"
path: "{{ item.ansible_facts.getent_passwd[item.item][4] }}/.ssh/authorized_keys"
loop: "{{ ssh_ca_owner_lookup.results }}"
loop_control:
label: "{{ item[0] }}"
label: "{{ item.item }}"
when: (item.ansible_facts.getent_passwd | default({})).get(item.item) is not none
register: ssh_ca_keyfiles
- name: Fingerprint the admin logins' authorized keys
@@ -10,6 +10,3 @@
{% for unit in ssh_ca_claude_sudo_units %}
{{ ssh_ca_claude_user }} ALL=(root) NOPASSWD:NOEXEC: /usr/bin/systemctl restart {{ unit }}, /usr/bin/systemctl reload {{ unit }}
{% endfor %}
{% if ssh_ca_claude_sudo_all %}
{{ ssh_ca_claude_user }} ALL=(ALL) NOPASSWD: ALL
{% endif %}
-26
View File
@@ -1,26 +0,0 @@
---
# 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,9 +5,14 @@ uptime_kuma_base_url: "https://{{ uptime_kuma_host }}"
uptime_kuma_dir: /opt/uptime-kuma
uptime_kuma_traefik_network: netbird_netbird
uptime_kuma_smtp_from: uptime@renner.uno
# Alertmanager email receiver in kubernetes/apps/monitoring/helmrelease-prometheus.yaml.
uptime_kuma_smtp_to: admin@renner.uno
uptime_kuma_openbao_url: https://openbao.renner.uno
uptime_kuma_openbao_mount: homelab
uptime_kuma_openbao_path: infrastructure/uptime-kuma
uptime_kuma_openbao_smtp_path: infrastructure/uptime-kuma/smtp
uptime_kuma_openbao_metrics_path: infrastructure/uptime-kuma-metrics
# Token with policy uptime-kuma-vps, looked up like the bao CLI does:
# VAULT_TOKEN, else the token helper file written by `bao login -method=oidc`.
+42 -3
View File
@@ -1,4 +1,4 @@
// Bootstraps Uptime Kuma: admin user, general settings,
// Bootstraps Uptime Kuma: admin user, general settings, SMTP notification,
// and (when KUMA_METRICS_ROTATE=true) a fresh Prometheus API key written to KUMA_METRICS_KEY_FILE.
// All input comes from environment variables; secrets are never printed.
// Output lines: "admin user created" / "already set up", then "changed: <step>" or "ok: <step>".
@@ -20,7 +20,17 @@ const SETTINGS_DEFAULTS = {
trustProxy: false,
};
const REQUIRED = ["KUMA_SETUP_USERNAME", "KUMA_SETUP_PASSWORD", "KUMA_BASE_URL"];
const REQUIRED = [
"KUMA_SETUP_USERNAME",
"KUMA_SETUP_PASSWORD",
"KUMA_BASE_URL",
"KUMA_SMTP_HOST",
"KUMA_SMTP_PORT",
"KUMA_SMTP_USERNAME",
"KUMA_SMTP_PASSWORD",
"KUMA_SMTP_FROM",
"KUMA_SMTP_TO",
];
const missing = REQUIRED.filter((name) => !process.env[name]);
const rotateMetricsKey = process.env.KUMA_METRICS_ROTATE === "true";
if (rotateMetricsKey && !process.env.KUMA_METRICS_KEY_FILE) {
@@ -34,7 +44,7 @@ const env = process.env;
const socket = io(KUMA_URL, { transports: ["websocket"], reconnection: false });
const lists = {};
const LIST_EVENTS = ["apiKeyList"];
const LIST_EVENTS = ["notificationList", "apiKeyList"];
for (const event of LIST_EVENTS) {
socket.on(event, (data) => {
lists[event] = data;
@@ -113,6 +123,34 @@ async function ensureSettings() {
report("settings", true);
}
async function ensureNotification() {
const port = Number(env.KUMA_SMTP_PORT);
const desired = {
name: "email",
type: "smtp",
isDefault: true,
smtpHost: env.KUMA_SMTP_HOST,
smtpPort: port,
// 465 is implicit TLS; any other port uses STARTTLS.
smtpSecure: port === 465,
smtpIgnoreSTARTTLS: false,
smtpUsername: env.KUMA_SMTP_USERNAME,
smtpPassword: env.KUMA_SMTP_PASSWORD,
smtpFrom: env.KUMA_SMTP_FROM,
smtpTo: env.KUMA_SMTP_TO,
};
const existing = (lists.notificationList || []).filter((n) => n.name === desired.name).sort((a, b) => a.id - b.id)[0];
const current = existing ? JSON.parse(existing.config || "{}") : {};
const upToDate =
existing && existing.isDefault === true && Object.keys(desired).every((key) => current[key] === desired[key]);
if (upToDate) {
report("notification", false);
return;
}
await callOk("addNotification", { ...current, ...desired, applyExisting: true }, existing ? existing.id : null);
report("notification", true);
}
async function rotateMetricsApiKey() {
if (!rotateMetricsKey) {
report("metrics api key", false);
@@ -131,6 +169,7 @@ async function main() {
await setupAdmin();
await login();
await ensureSettings();
await ensureNotification();
await rotateMetricsApiKey();
}
+31
View File
@@ -23,6 +23,18 @@
register: _uptime_kuma_secret_read
no_log: true
- name: Read SMTP credentials from OpenBao
ansible.builtin.uri:
url: "{{ uptime_kuma_openbao_url }}/v1/{{ uptime_kuma_openbao_mount }}/data/{{ uptime_kuma_openbao_smtp_path }}"
headers:
X-Vault-Token: "{{ uptime_kuma_openbao_token }}"
delegate_to: localhost
become: false
run_once: true
check_mode: false
register: _uptime_kuma_smtp_read
no_log: true
# 404 until the role has stored a key for the first time.
- name: Read metrics API key from OpenBao
ansible.builtin.uri:
@@ -40,6 +52,7 @@
- name: Extract the OpenBao secrets
ansible.builtin.set_fact:
_uptime_kuma_secret: "{{ _uptime_kuma_secret_read.json.data.data }}"
_uptime_kuma_smtp: "{{ _uptime_kuma_smtp_read.json.data.data }}"
_uptime_kuma_metrics_key: "{{ _uptime_kuma_metrics_read.json.data.data.api_key if _uptime_kuma_metrics_read.status == 200 else '' }}"
no_log: true
@@ -127,6 +140,18 @@
- -e
- KUMA_BASE_URL
- -e
- KUMA_SMTP_HOST
- -e
- KUMA_SMTP_PORT
- -e
- KUMA_SMTP_USERNAME
- -e
- KUMA_SMTP_PASSWORD
- -e
- KUMA_SMTP_FROM
- -e
- KUMA_SMTP_TO
- -e
- KUMA_METRICS_ROTATE
- -e
- KUMA_METRICS_KEY_FILE
@@ -139,6 +164,12 @@
KUMA_SETUP_USERNAME: "{{ _uptime_kuma_secret.username }}"
KUMA_SETUP_PASSWORD: "{{ _uptime_kuma_secret.password }}"
KUMA_BASE_URL: "{{ uptime_kuma_base_url }}"
KUMA_SMTP_HOST: "{{ _uptime_kuma_smtp.server }}"
KUMA_SMTP_PORT: "{{ _uptime_kuma_smtp.port }}"
KUMA_SMTP_USERNAME: "{{ _uptime_kuma_smtp.username }}"
KUMA_SMTP_PASSWORD: "{{ _uptime_kuma_smtp.token }}"
KUMA_SMTP_FROM: "{{ uptime_kuma_smtp_from }}"
KUMA_SMTP_TO: "{{ uptime_kuma_smtp_to }}"
KUMA_METRICS_ROTATE: "{{ 'false' if (_uptime_kuma_metrics_check.status | default(0)) == 200 else 'true' }}"
KUMA_METRICS_KEY_FILE: /app/data/.metrics-api-key
register: _uptime_kuma_setup
@@ -13,8 +13,12 @@ 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
# Renovate-tracked (customManagers in renovate.json).
edge_zwavejs_image: ghcr.io/zwave-js/zwave-js-ui:11.25.2@sha256:ac4874a3cbea61c0c60002a8fd8ae05104cc8eb2eae91aa05bd184972aa06853
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
# Secret from inventory/group_vars/edge_host/vault.yml:
# edge_zwavejs_session_secret
+3 -32
View File
@@ -15,31 +15,17 @@
- "{{ 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 alpine:{{ zwave_alpine_minor }} \
docker run --rm --network=host {{ edge_zwave_alpine_base }} \
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 }}/{{ zwave_libeatmydata }}
creates: "{{ zwave_base_dir }}/{{ zwave_libeatmydata }}"
executable: /bin/bash
install -m 0644 "$tmp" {{ zwave_base_dir }}/libeatmydata.so
creates: "{{ zwave_base_dir }}/libeatmydata.so"
notify: Restart zwave-js
# Because fsync is neutralized, a hard power-loss during the (rare) settings
@@ -107,18 +93,3 @@
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)
- ./{{ zwave_libeatmydata }}:/usr/lib/libeatmydata.so:ro
- ./libeatmydata.so:/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
+4 -2
View File
@@ -8,10 +8,10 @@
# One-off actions are not state and stay separate: plays/update-os.yml,
# plays/update-dist-upgrade.yml, plays/rotate-netbird-secrets.yml.
- name: Synology Python
ansible.builtin.import_playbook: plays/configure-synology-python.yml
- name: Base system
ansible.builtin.import_playbook: plays/common.yml
- name: VM time sync
ansible.builtin.import_playbook: plays/configure-vm-time-sync.yml
- name: SSH user CA
ansible.builtin.import_playbook: plays/configure-ssh-ca.yml
@@ -31,6 +31,8 @@
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
+2 -2
View File
@@ -5,12 +5,12 @@
# The unit tests gate the image: the final stage copies the controller out of
# this stage, so a failing test fails the build.
FROM python:3.15-slim@sha256:94d3426f1def7db4a106a232ac121bfd975393e5dfd40a619b955fae45fe7913 AS test
FROM python:3.15-slim@sha256:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51 AS test
WORKDIR /src
COPY controller.py test_controller.py ./
RUN python3 -B -m unittest -v
FROM python:3.15-slim@sha256:94d3426f1def7db4a106a232ac121bfd975393e5dfd40a619b955fae45fe7913
FROM python:3.15-slim@sha256:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51
RUN useradd --system --no-create-home --shell /usr/sbin/nologin controller
COPY --from=test /src/controller.py /opt/controller.py
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:26-alpine@sha256:c6ad9e9c1d80f6eb607c26b4b0a132ff7a2344fad1c78ea3baa2a7d4c8de6d94
FROM node:24-alpine@sha256:06d7adf3e0ef61a13f1d28682726366f97a15aa1888c934d3ecec86c33918b53
# Default to the latest Claude Code. Pinning a fixed version is the wrong
# default here: old releases eventually stop working against the API, so a
+2 -2
View File
@@ -2,7 +2,7 @@
# Cloudflare. The unit tests (no tags, so no envtest) gate the image: the
# final stage copies the binary out of this stage, so a failing test fails the
# build.
FROM golang:1.27@sha256:2920bb124f1a2e12a1d39f0cab756ae0068441ee1da7655dec7f5e374944095b AS build
FROM golang:1.27@sha256:f20d83a83e18dbd2a4714b8ffd4b30b287b6243962dd75608e86abcc63ba4efc AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
@@ -10,7 +10,7 @@ COPY . .
RUN go test ./...
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/cloudflare-lb-sync ./cmd/cloudflare-lb-sync
FROM gcr.io/distroless/static:nonroot@sha256:25ac8f0c3b2334a9eb05452266bd4697864a8c8ef0ef71e5e6480d7f92868329
FROM gcr.io/distroless/static:nonroot@sha256:18f4cc26c9ffa7f20a359298be7deadac5c607c8e03a02b8119f1a5fc2f82c99
COPY --from=build /out/cloudflare-lb-sync /cloudflare-lb-sync
USER 65532
ENTRYPOINT ["/cloudflare-lb-sync"]
+2 -2
View File
@@ -23,7 +23,7 @@
# namespace, so can0 is only reachable with network_mode: host. That is also
# how the local MQTT broker on 127.0.0.1:1883 is reached. CAN_RAW sockets need
# no capabilities, so both run unprivileged.
FROM python:3.15-slim@sha256:94d3426f1def7db4a106a232ac121bfd975393e5dfd40a619b955fae45fe7913 AS build
FROM python:3.15-slim@sha256:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51 AS build
# Pins live here, in the artifact, rather than in Ansible variables.
#
@@ -56,7 +56,7 @@ RUN git clone --quiet https://github.com/MyHomeMyData/E3onCAN.git /opt/e3oncan \
&& rm -rf /opt/e3oncan/.git
FROM python:3.15-slim@sha256:94d3426f1def7db4a106a232ac121bfd975393e5dfd40a619b955fae45fe7913
FROM python:3.15-slim@sha256:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51
ARG OPEN3E_REF=6553c10453a6bae1210908f38a5af2481a46a3e5
ARG E3ONCAN_REF=461dcbdf97e8d72d2edb9929c63b39382af6c3a1
+4 -4
View File
@@ -6,10 +6,10 @@ RUN apt-get update \
&& apt-get install -y --no-install-recommends jq \
&& rm -rf /var/lib/apt/lists/*
COPY --from=ghcr.io/siderolabs/talosctl:v1.14.5@sha256:0b852e35c1f513ee5eacc22183d6193486012420569fde39fcd5260c51938443 /talosctl /usr/local/bin/talosctl
COPY --from=alpine/kubectl:1.37.2@sha256:5e2de75d37e811ba6eb92e06890b1b3263eae4f0a1c17b49269edec589b1352a /usr/local/bin/kubectl /usr/local/bin/kubectl
COPY --from=ghcr.io/fluxcd/flux-cli:v2.10.0@sha256:1a5178b1770052d6e88ab1c7c8434c5ac9bca8a8ab984dcaf6b9df31814ce9f2 /usr/local/bin/flux /usr/local/bin/flux
COPY --from=quay.io/openbao/openbao:2.7.2@sha256:5046911075e137e154ce49ab051502f4ec7cf367d484ae4a4dce665d9b90561f /usr/bin/bao /usr/local/bin/bao
COPY --from=ghcr.io/siderolabs/talosctl:v1.14.4@sha256:b3233234327350f20ed194f3fad83d90a64344c1940f839c7b0c0abef50c8c33 /talosctl /usr/local/bin/talosctl
COPY --from=alpine/kubectl:1.37.1@sha256:a5d0fbecab3f7b6c2ab7398f1daa73a3e0ed6ece18556d21698233e9e3759cc7 /usr/local/bin/kubectl /usr/local/bin/kubectl
COPY --from=ghcr.io/fluxcd/flux-cli:v2.9.6@sha256:b1ac18156f227af9a524b842a96f2c20986c7a779ef721df3ba4e4540f449d76 /usr/local/bin/flux /usr/local/bin/flux
COPY --from=quay.io/openbao/openbao:2.7.1@sha256:6d2b93856e3fcf7b18ad855a0b51eaba474dc8b79cf554379ea32034797d2acf /usr/bin/bao /usr/local/bin/bao
COPY --from=gitea/tea:0.16.0@sha256:af50a2060ce4d8fd76b7ebe75a3d6db4bb0c615c5bc8f2bdfd8ea83207c4752d /usr/bin/tea /usr/local/bin/tea
RUN talosctl version --client \
+2 -2
View File
@@ -1,7 +1,7 @@
# kuma-sync: reconciles KumaMonitor resources into Uptime Kuma. The unit tests
# (no tags, so no Docker and no envtest) gate the image: the final stage copies
# the binary out of this stage, so a failing test fails the build.
FROM golang:1.27@sha256:2920bb124f1a2e12a1d39f0cab756ae0068441ee1da7655dec7f5e374944095b AS build
FROM golang:1.27@sha256:f20d83a83e18dbd2a4714b8ffd4b30b287b6243962dd75608e86abcc63ba4efc AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
@@ -9,7 +9,7 @@ COPY . .
RUN go test ./...
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/kuma-sync ./cmd/kuma-sync
FROM gcr.io/distroless/static:nonroot@sha256:25ac8f0c3b2334a9eb05452266bd4697864a8c8ef0ef71e5e6480d7f92868329
FROM gcr.io/distroless/static:nonroot@sha256:18f4cc26c9ffa7f20a359298be7deadac5c607c8e03a02b8119f1a5fc2f82c99
COPY --from=build /out/kuma-sync /kuma-sync
USER 65532
ENTRYPOINT ["/kuma-sync"]
@@ -97,10 +97,6 @@ type KumaMonitorSpec struct {
// +kubebuilder:validation:Minimum=0
// +kubebuilder:default=0
MaxRetries int32 `json:"maxRetries,omitempty"`
// Consecutive DOWN heartbeats after which Kuma sends the DOWN notification
// again, not minutes; 0 sends it once per outage.
// +kubebuilder:validation:Minimum=0
ResendInterval int32 `json:"resendInterval,omitempty"`
// Seconds.
// +kubebuilder:validation:Minimum=1
Timeout *int32 `json:"timeout,omitempty"`
@@ -112,17 +108,6 @@ type KumaMonitorSpec struct {
JSONQuery *JSONQuerySpec `json:"jsonQuery,omitempty"`
DNS *DNSSpec `json:"dns,omitempty"`
Port *PortSpec `json:"port,omitempty"`
// KumaNotifications attached to the monitor; none attaches no notification.
// +listType=map
// +listMapKey=name
NotificationRefs []NotificationRef `json:"notificationRefs,omitempty"`
}
// NotificationRef references a KumaNotification.
type NotificationRef struct {
// +kubebuilder:validation:MinLength=1
Name string `json:"name"`
}
type KumaMonitorStatus struct {
@@ -1,66 +0,0 @@
package v1alpha1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// SecretRef references a Secret in any namespace.
type SecretRef struct {
// +kubebuilder:validation:MinLength=1
Namespace string `json:"namespace"`
// +kubebuilder:validation:MinLength=1
Name string `json:"name"`
}
// SMTPSpec configures an smtp notification. The server and its credentials
// come from the Secret: keys host, port, username and password, all required.
// Port 465 uses implicit TLS, any other port STARTTLS.
type SMTPSpec struct {
// +kubebuilder:validation:MinLength=1
From string `json:"from"`
// +kubebuilder:validation:MinLength=1
To string `json:"to"`
SecretRef SecretRef `json:"secretRef"`
}
// +kubebuilder:validation:XValidation:rule="(self.type == 'smtp') == has(self.smtp)",message="smtp is required for type smtp and forbidden otherwise"
type KumaNotificationSpec struct {
// +kubebuilder:validation:Enum=smtp
Type string `json:"type"`
SMTP *SMTPSpec `json:"smtp,omitempty"`
}
type KumaNotificationStatus struct {
NotificationID int64 `json:"notificationID,omitempty"`
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
// +listType=map
// +listMapKey=type
Conditions []metav1.Condition `json:"conditions,omitempty"`
}
// KumaNotification is the Kuma notification named like the resource. It is
// never a default notification and never applied to existing monitors;
// KumaMonitors attach it through notificationRefs.
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:resource:path=kumanotifications,scope=Cluster
// +kubebuilder:printcolumn:name="Type",type=string,JSONPath=`.spec.type`
// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status`
type KumaNotification struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec KumaNotificationSpec `json:"spec"`
Status KumaNotificationStatus `json:"status,omitempty"`
}
// +kubebuilder:object:root=true
type KumaNotificationList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []KumaNotification `json:"items"`
}
func init() {
SchemeBuilder.Register(&KumaNotification{}, &KumaNotificationList{})
}
@@ -1,78 +0,0 @@
//go:build envtest
package v1alpha1
import (
"context"
"strings"
"testing"
"k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
func smtpNotification(name string) *KumaNotification {
return &KumaNotification{
ObjectMeta: metav1.ObjectMeta{Name: name},
Spec: KumaNotificationSpec{Type: "smtp", SMTP: &SMTPSpec{
From: "kuma@renner.uno", To: "ops@renner.uno",
SecretRef: SecretRef{Namespace: "kuma-sync", Name: "smtp"},
}},
}
}
func expectNotificationInvalid(t *testing.T, n *KumaNotification, wants ...string) {
t.Helper()
err := k8s.Create(context.Background(), n)
if err == nil || !errors.IsInvalid(err) {
t.Fatalf("expected invalid, got %v", err)
}
for _, w := range wants {
if !strings.Contains(err.Error(), w) {
t.Fatalf("error %q does not contain %q", err.Error(), w)
}
}
}
func TestValidNotification(t *testing.T) {
if err := k8s.Create(context.Background(), smtpNotification("valid-smtp")); err != nil {
t.Fatalf("expected accept, got %v", err)
}
}
func TestNotificationInvalidSpecs(t *testing.T) {
noSMTP := smtpNotification("no-smtp")
noSMTP.Spec.SMTP = nil
expectNotificationInvalid(t, noSMTP, "smtp is required for type smtp")
webhook := smtpNotification("webhook")
webhook.Spec.Type = "webhook"
expectNotificationInvalid(t, webhook, "spec.type")
noTo := smtpNotification("no-to")
noTo.Spec.SMTP.To = ""
expectNotificationInvalid(t, noTo, "spec.smtp.to")
noSecret := smtpNotification("no-secret")
noSecret.Spec.SMTP.SecretRef.Namespace = ""
expectNotificationInvalid(t, noSecret, "spec.smtp.secretRef.namespace")
}
func TestMonitorNotificationRefs(t *testing.T) {
m := monitor("with-refs", KumaMonitorSpec{
Type: "group", DisplayName: "g",
NotificationRefs: []NotificationRef{{Name: "mail"}, {Name: "ops"}},
})
if err := k8s.Create(context.Background(), m); err != nil {
t.Fatalf("expected accept, got %v", err)
}
dup := monitor("dup-refs", KumaMonitorSpec{
Type: "group", DisplayName: "g",
NotificationRefs: []NotificationRef{{Name: "mail"}, {Name: "mail"}},
})
expectInvalid(t, dup, "Duplicate value")
empty := monitor("empty-ref", KumaMonitorSpec{
Type: "group", DisplayName: "g", NotificationRefs: []NotificationRef{{Name: ""}},
})
expectInvalid(t, empty, "spec.notificationRefs[0].name")
}
@@ -131,15 +131,6 @@ func TestNegativeMaxRetriesRejected(t *testing.T) {
expectInvalid(t, m, "spec.maxRetries")
}
func TestNegativeResendIntervalRejected(t *testing.T) {
m := monitor("neg-resend", KumaMonitorSpec{
Type: "group",
DisplayName: "g",
ResendInterval: -1,
})
expectInvalid(t, m, "spec.resendInterval")
}
func TestJSONQueryBadOperator(t *testing.T) {
m := monitor("jq-bad-op", KumaMonitorSpec{
Type: "json-query",
@@ -203,11 +203,6 @@ func (in *KumaMonitorSpec) DeepCopyInto(out *KumaMonitorSpec) {
*out = new(PortSpec)
**out = **in
}
if in.NotificationRefs != nil {
in, out := &in.NotificationRefs, &out.NotificationRefs
*out = make([]NotificationRef, len(*in))
copy(*out, *in)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KumaMonitorSpec.
@@ -242,107 +237,6 @@ func (in *KumaMonitorStatus) DeepCopy() *KumaMonitorStatus {
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *KumaNotification) DeepCopyInto(out *KumaNotification) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
in.Spec.DeepCopyInto(&out.Spec)
in.Status.DeepCopyInto(&out.Status)
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KumaNotification.
func (in *KumaNotification) DeepCopy() *KumaNotification {
if in == nil {
return nil
}
out := new(KumaNotification)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *KumaNotification) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *KumaNotificationList) DeepCopyInto(out *KumaNotificationList) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ListMeta.DeepCopyInto(&out.ListMeta)
if in.Items != nil {
in, out := &in.Items, &out.Items
*out = make([]KumaNotification, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KumaNotificationList.
func (in *KumaNotificationList) DeepCopy() *KumaNotificationList {
if in == nil {
return nil
}
out := new(KumaNotificationList)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *KumaNotificationList) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *KumaNotificationSpec) DeepCopyInto(out *KumaNotificationSpec) {
*out = *in
if in.SMTP != nil {
in, out := &in.SMTP, &out.SMTP
*out = new(SMTPSpec)
**out = **in
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KumaNotificationSpec.
func (in *KumaNotificationSpec) DeepCopy() *KumaNotificationSpec {
if in == nil {
return nil
}
out := new(KumaNotificationSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *KumaNotificationStatus) DeepCopyInto(out *KumaNotificationStatus) {
*out = *in
if in.Conditions != nil {
in, out := &in.Conditions, &out.Conditions
*out = make([]v1.Condition, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KumaNotificationStatus.
func (in *KumaNotificationStatus) DeepCopy() *KumaNotificationStatus {
if in == nil {
return nil
}
out := new(KumaNotificationStatus)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *KumaStatusPage) DeepCopyInto(out *KumaStatusPage) {
*out = *in
@@ -471,21 +365,6 @@ func (in *LocalRef) DeepCopy() *LocalRef {
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *NotificationRef) DeepCopyInto(out *NotificationRef) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NotificationRef.
func (in *NotificationRef) DeepCopy() *NotificationRef {
if in == nil {
return nil
}
out := new(NotificationRef)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *PortSpec) DeepCopyInto(out *PortSpec) {
*out = *in
@@ -501,37 +380,6 @@ func (in *PortSpec) DeepCopy() *PortSpec {
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *SMTPSpec) DeepCopyInto(out *SMTPSpec) {
*out = *in
out.SecretRef = in.SecretRef
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SMTPSpec.
func (in *SMTPSpec) DeepCopy() *SMTPSpec {
if in == nil {
return nil
}
out := new(SMTPSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *SecretRef) DeepCopyInto(out *SecretRef) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecretRef.
func (in *SecretRef) DeepCopy() *SecretRef {
if in == nil {
return nil
}
out := new(SecretRef)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *SectionStatus) DeepCopyInto(out *SectionStatus) {
*out = *in
+4 -32
View File
@@ -3,25 +3,19 @@ package main
import (
"errors"
"fmt"
"strings"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/validation"
)
type config struct {
URL string
Username string
Password string
// RootGroup is the zero value when KUMA_ROOT_GROUP is unset.
RootGroup types.NamespacedName
}
// loadConfig reads the Kuma connection settings from the environment. It
// names every missing or invalid variable.
// names every missing variable.
func loadConfig(getenv func(string) string) (config, error) {
var cfg config
var errs []error
var missing []error
for _, v := range []struct {
name string
dst *string
@@ -31,30 +25,8 @@ func loadConfig(getenv func(string) string) (config, error) {
{"KUMA_PASSWORD", &cfg.Password},
} {
if *v.dst = getenv(v.name); *v.dst == "" {
errs = append(errs, fmt.Errorf("missing required environment variable %s", v.name))
missing = append(missing, fmt.Errorf("missing required environment variable %s", v.name))
}
}
if raw := getenv("KUMA_ROOT_GROUP"); raw != "" {
root, err := parseRootGroup(raw)
if err != nil {
errs = append(errs, fmt.Errorf("KUMA_ROOT_GROUP: %w", err))
}
cfg.RootGroup = root
}
return cfg, errors.Join(errs...)
}
// parseRootGroup parses namespace/name of a KumaMonitor.
func parseRootGroup(raw string) (types.NamespacedName, error) {
ns, name, ok := strings.Cut(raw, "/")
if !ok || strings.Contains(name, "/") {
return types.NamespacedName{}, fmt.Errorf("%q is not namespace/name", raw)
}
if msgs := validation.IsDNS1123Label(ns); len(msgs) > 0 {
return types.NamespacedName{}, fmt.Errorf("namespace %q: %s", ns, strings.Join(msgs, "; "))
}
if msgs := validation.IsDNS1123Subdomain(name); len(msgs) > 0 {
return types.NamespacedName{}, fmt.Errorf("name %q: %s", name, strings.Join(msgs, "; "))
}
return types.NamespacedName{Namespace: ns, Name: name}, nil
return cfg, errors.Join(missing...)
}
@@ -3,8 +3,6 @@ package main
import (
"strings"
"testing"
"k8s.io/apimachinery/pkg/types"
)
func TestLoadConfig(t *testing.T) {
@@ -38,21 +36,3 @@ func TestLoadConfig(t *testing.T) {
}
}
}
func TestLoadConfigRootGroup(t *testing.T) {
env := func(root string) func(string) string {
m := map[string]string{"KUMA_URL": "http://kuma", "KUMA_USERNAME": "u", "KUMA_PASSWORD": "p", "KUMA_ROOT_GROUP": root}
return func(k string) string { return m[k] }
}
cfg, err := loadConfig(env("kuma-sync/renner-uno"))
if err != nil || cfg.RootGroup != (types.NamespacedName{Namespace: "kuma-sync", Name: "renner-uno"}) {
t.Fatalf("cfg=%+v err=%v", cfg, err)
}
for _, bad := range []string{"renner-uno", "/renner-uno", "kuma-sync/", "a/b/c", "Kuma/x", "ns/UPPER", " ns/x"} {
if _, err := loadConfig(env(bad)); err == nil || !strings.Contains(err.Error(), "KUMA_ROOT_GROUP") {
t.Errorf("%q: err = %v, want KUMA_ROOT_GROUP rejected", bad, err)
}
}
}
@@ -48,7 +48,6 @@ func run() error {
GCInterval: 5 * time.Minute,
OrphanGrace: time.Hour,
Resync: 5 * time.Minute,
RootGroup: cfg.RootGroup,
})
if err != nil {
return err
@@ -182,22 +182,6 @@ spec:
format: int32
minimum: 0
type: integer
notificationRefs:
description: KumaNotifications attached to the monitor; none attaches
no notification.
items:
description: NotificationRef references a KumaNotification.
properties:
name:
minLength: 1
type: string
required:
- name
type: object
type: array
x-kubernetes-list-map-keys:
- name
x-kubernetes-list-type: map
parentRef:
description: LocalRef references a KumaMonitor in the same namespace.
properties:
@@ -222,13 +206,6 @@ spec:
- hostname
- port
type: object
resendInterval:
description: |-
Consecutive DOWN heartbeats after which Kuma sends the DOWN notification
again, not minutes; 0 sends it once per outage.
format: int32
minimum: 0
type: integer
retryInterval:
default: 60
description: Seconds.
@@ -1,165 +0,0 @@
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.23.0
name: kumanotifications.uptime.renner.uno
spec:
group: uptime.renner.uno
names:
kind: KumaNotification
listKind: KumaNotificationList
plural: kumanotifications
singular: kumanotification
scope: Cluster
versions:
- additionalPrinterColumns:
- jsonPath: .spec.type
name: Type
type: string
- jsonPath: .status.conditions[?(@.type=="Ready")].status
name: Ready
type: string
name: v1alpha1
schema:
openAPIV3Schema:
description: |-
KumaNotification is the Kuma notification named like the resource. It is
never a default notification and never applied to existing monitors;
KumaMonitors attach it through notificationRefs.
properties:
apiVersion:
description: |-
APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
type: string
kind:
description: |-
Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
type: string
metadata:
type: object
spec:
properties:
smtp:
description: |-
SMTPSpec configures an smtp notification. The server and its credentials
come from the Secret: keys host, port, username and password, all required.
Port 465 uses implicit TLS, any other port STARTTLS.
properties:
from:
minLength: 1
type: string
secretRef:
description: SecretRef references a Secret in any namespace.
properties:
name:
minLength: 1
type: string
namespace:
minLength: 1
type: string
required:
- name
- namespace
type: object
to:
minLength: 1
type: string
required:
- from
- secretRef
- to
type: object
type:
enum:
- smtp
type: string
required:
- type
type: object
x-kubernetes-validations:
- message: smtp is required for type smtp and forbidden otherwise
rule: (self.type == 'smtp') == has(self.smtp)
status:
properties:
conditions:
items:
description: Condition contains details for one aspect of the current
state of this API Resource.
properties:
lastTransitionTime:
description: |-
lastTransitionTime is the last time the condition transitioned from one status to another.
This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
format: date-time
type: string
message:
description: |-
message is a human readable message indicating details about the transition.
This may be an empty string.
maxLength: 32768
type: string
observedGeneration:
description: |-
observedGeneration represents the .metadata.generation that the condition was set based upon.
For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
with respect to the current state of the instance.
format: int64
minimum: 0
type: integer
reason:
description: |-
reason contains a programmatic identifier indicating the reason for the condition's last transition.
Producers of specific condition types may define expected values and meanings for this field,
and whether the values are considered a guaranteed API.
The value should be a CamelCase string.
This field may not be empty.
maxLength: 1024
minLength: 1
pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
type: string
status:
description: status of the condition, one of True, False, Unknown.
enum:
- "True"
- "False"
- Unknown
type: string
type:
description: type of condition in CamelCase or in foo.example.com/CamelCase.
maxLength: 316
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
type: string
required:
- lastTransitionTime
- message
- reason
- status
- type
type: object
type: array
x-kubernetes-list-map-keys:
- type
x-kubernetes-list-type: map
notificationID:
format: int64
type: integer
observedGeneration:
format: int64
type: integer
type: object
required:
- spec
type: object
served: true
storage: true
subresources:
status: {}
@@ -21,7 +21,6 @@ rules:
- uptime.renner.uno
resources:
- kumamonitors
- kumanotifications
- kumastatuspages
verbs:
- get
@@ -33,7 +32,6 @@ rules:
- uptime.renner.uno
resources:
- kumamonitors/finalizers
- kumanotifications/finalizers
- kumastatuspages/finalizers
verbs:
- update
@@ -41,7 +39,6 @@ rules:
- uptime.renner.uno
resources:
- kumamonitors/status
- kumanotifications/status
- kumastatuspages/status
verbs:
- get
+1 -12
View File
@@ -10,7 +10,6 @@ import (
"time"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/rest"
"k8s.io/utils/ptr"
@@ -42,9 +41,6 @@ type Options struct {
// Resync is how often a healthy resource is reconciled again, which is
// what repairs drift made in Kuma; 0 means 5 minutes.
Resync time.Duration
// RootGroup is the group KumaMonitor that monitors without a parentRef are
// placed under; the zero value leaves them at the top level.
RootGroup types.NamespacedName
// SkipNameValidation allows several managers in one process, as in tests
// that restart the manager.
@@ -76,19 +72,12 @@ func NewManager(o Options) (manager.Manager, error) {
return nil, fmt.Errorf("create manager: %w", err)
}
if err := (&controller.Reconciler{
Client: mgr.GetClient(), Kuma: o.Kuma, Resync: o.Resync, RootGroup: o.RootGroup,
}).SetupWithManager(mgr); err != nil {
if err := (&controller.Reconciler{Client: mgr.GetClient(), Kuma: o.Kuma, Resync: o.Resync}).SetupWithManager(mgr); err != nil {
return nil, fmt.Errorf("set up reconciler: %w", err)
}
if err := (&controller.StatusPageReconciler{Client: mgr.GetClient(), Kuma: o.Kuma, Resync: o.Resync}).SetupWithManager(mgr); err != nil {
return nil, fmt.Errorf("set up status page reconciler: %w", err)
}
if err := (&controller.NotificationReconciler{
Client: mgr.GetClient(), APIReader: mgr.GetAPIReader(), Kuma: o.Kuma, Resync: o.Resync,
}).SetupWithManager(mgr); err != nil {
return nil, fmt.Errorf("set up notification reconciler: %w", err)
}
if err := mgr.Add(&controller.GC{
Client: mgr.GetClient(),
Kuma: o.Kuma,
@@ -7,6 +7,7 @@ import (
"fmt"
"io"
"path/filepath"
"slices"
"sync"
"testing"
"time"
@@ -20,7 +21,6 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/util/retry"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/envtest"
@@ -85,9 +85,6 @@ func newE2E(t *testing.T) (*e2e, *envtest.Environment) {
t.Cleanup(func() { _ = env.Stop() })
scheme := runtime.NewScheme()
if err := clientgoscheme.AddToScheme(scheme); err != nil {
t.Fatal(err)
}
if err := v1.AddToScheme(scheme); err != nil {
t.Fatal(err)
}
@@ -414,6 +411,46 @@ func (f *e2e) webhook(name string, isDefault bool) int64 {
return id
}
func TestE2EDefaultNotifications(t *testing.T) {
f, env := newE2E(t)
first := f.webhook("first", true)
f.webhook("opt-in", false)
f.start(env)
f.apply(group("g", ""), dns("probe", "g"))
carry := func(want ...int64) func() (bool, string) {
return func() (bool, string) {
m := byKey(f.owned())
for _, k := range []string{"default/g", "default/probe"} {
got, ok := m[k]
if !ok {
return false, k + " missing"
}
if !slices.Equal(got.NotificationIDs, want) {
return false, fmt.Sprintf("%s carries %v, want %v", k, got.NotificationIDs, want)
}
}
return true, ""
}
}
eventually(t, 30*time.Second, "monitors created with the default notification", carry(first))
second := f.webhook("second", true)
want := []int64{first, second}
slices.Sort(want)
eventually(t, 30*time.Second, "new default notification attached by a resync", carry(want...))
var live monitor.DNS
if err := f.raw.GetMonitorAs(t.Context(), byKey(f.owned())["default/probe"].ID, &live); err != nil {
t.Fatal(err)
}
live.NotificationIDs = nil
if err := f.raw.UpdateMonitor(t.Context(), &live); err != nil {
t.Fatal(err)
}
eventually(t, 30*time.Second, "notifications removed by hand restored by a resync", carry(want...))
}
// A monitor that lost its ownership tag in the UI must not get a twin when the
// controller restarts: the resource reports NotOwned and nothing is created.
func TestE2ETagRemovedNeverDuplicates(t *testing.T) {
@@ -17,11 +17,8 @@ import (
"sigs.k8s.io/controller-runtime/pkg/builder"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
"sigs.k8s.io/controller-runtime/pkg/event"
"sigs.k8s.io/controller-runtime/pkg/handler"
"sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/predicate"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
v1 "gitea.renner.uno/konairius/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/kumaclient"
@@ -30,16 +27,15 @@ import (
const (
Finalizer = "uptime.renner.uno/kuma-sync"
ReasonSynced = "Synced"
ReasonParentPending = "ParentPending"
ReasonInvalidParent = "InvalidParent"
ReasonOwnerTagMissing = "OwnerTagMissing"
ReasonOwnerConflict = "OwnerConflict"
ReasonDuplicateOwner = "DuplicateOwner"
ReasonKumaUnavailable = "KumaUnavailable"
ReasonPossibleOrphan = "PossibleOrphan"
ReasonNotOwned = "NotOwned"
ReasonNotificationPending = "NotificationPending"
ReasonSynced = "Synced"
ReasonParentPending = "ParentPending"
ReasonInvalidParent = "InvalidParent"
ReasonOwnerTagMissing = "OwnerTagMissing"
ReasonOwnerConflict = "OwnerConflict"
ReasonDuplicateOwner = "DuplicateOwner"
ReasonKumaUnavailable = "KumaUnavailable"
ReasonPossibleOrphan = "PossibleOrphan"
ReasonNotOwned = "NotOwned"
parentRetry = 10 * time.Second
resyncPeriod = 5 * time.Minute
@@ -58,15 +54,6 @@ type Reconciler struct {
// Resync is how often a healthy resource is reconciled again, which repairs
// drift made in Kuma. 0 means 5 minutes.
Resync time.Duration
// RootGroup is the group KumaMonitor every monitor without a parentRef is
// placed under in Kuma; the zero value places them at the top level.
RootGroup types.NamespacedName
}
func (r *Reconciler) hasRoot() bool { return r.RootGroup.Name != "" }
func (r *Reconciler) isRoot(o client.Object) bool {
return r.hasRoot() && o.GetNamespace() == r.RootGroup.Namespace && o.GetName() == r.RootGroup.Name
}
func (r *Reconciler) resync() time.Duration {
@@ -78,19 +65,12 @@ func (r *Reconciler) resync() time.Duration {
// SetupWithManager ignores status-only updates, which keeps failure backoff
// intact. Deletion still reconciles: setting deletionTimestamp bumps
// metadata.generation. A KumaNotification whose ID changes enqueues the
// monitors referencing it; a change to the root group enqueues the monitors
// without a parentRef.
// metadata.generation.
func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error {
b := ctrl.NewControllerManagedBy(mgr).
return ctrl.NewControllerManagedBy(mgr).
For(&v1.KumaMonitor{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})).
Watches(&v1.KumaNotification{}, handler.EnqueueRequestsFromMapFunc(r.referencingMonitors),
builder.WithPredicates(predicate.Funcs{UpdateFunc: notificationIDChanged}))
if r.hasRoot() {
b = b.Watches(&v1.KumaMonitor{}, handler.EnqueueRequestsFromMapFunc(r.rootChildren),
builder.WithPredicates(r.rootPredicate()))
}
return b.Named("kumamonitor").Complete(r)
Named("kumamonitor").
Complete(r)
}
// failure is a Ready=False outcome. The message is fixed per reason; the cause
@@ -102,22 +82,18 @@ type failure struct {
}
const (
msgKumaUnavailable = "Kuma request failed"
msgListIncomplete = "monitor list incomplete: owner conflict on another monitor"
msgOwnerTagMissing = "kuma-sync tag missing in Kuma"
msgOwnerConflict = "monitor carries conflicting kuma-sync tag values"
msgPossibleOrphan = "create outcome unknown, an untagged monitor may exist in Kuma"
msgDuplicateOwner = "several monitors carry this owner key; syncing the lowest ID"
msgParentPending = "parent has no monitor ID yet"
msgParentNotGroup = "parent must be of type group"
msgParentCycle = "parentRef forms a cycle"
msgParentTooDeep = "parentRef chain is too deep"
msgRootPending = "root group has no monitor ID yet"
msgRootNotGroup = "root group must be of type group"
msgRootHasParent = "root group must not have a parentRef"
msgNotificationPending = "a referenced notification has no notification ID yet"
msgSynced = "monitor in sync"
msgNotOwned = "previous monitor exists without our ownership tag; restore the tag or delete it in Kuma"
msgKumaUnavailable = "Kuma request failed"
msgListIncomplete = "monitor list incomplete: owner conflict on another monitor"
msgOwnerTagMissing = "kuma-sync tag missing in Kuma"
msgOwnerConflict = "monitor carries conflicting kuma-sync tag values"
msgPossibleOrphan = "create outcome unknown, an untagged monitor may exist in Kuma"
msgDuplicateOwner = "several monitors carry this owner key; syncing the lowest ID"
msgParentPending = "parent has no monitor ID yet"
msgParentNotGroup = "parent must be of type group"
msgParentCycle = "parentRef forms a cycle"
msgParentTooDeep = "parentRef chain is too deep"
msgSynced = "monitor in sync"
msgNotOwned = "previous monitor exists without our ownership tag; restore the tag or delete it in Kuma"
)
func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
@@ -135,43 +111,29 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
}
desired := Desired(km)
var p parentOutcome
var err error
switch {
case km.Spec.ParentRef != nil:
p, err = r.resolveParent(ctx, km)
case r.hasRoot() && !r.isRoot(km):
p, err = r.resolveRoot(ctx)
}
if err != nil {
return ctrl.Result{}, err
}
if p.reason != "" {
if err := r.setReady(ctx, km, metav1.ConditionFalse, p.reason, p.message, nil); err != nil {
if km.Spec.ParentRef != nil {
p, err := r.resolveParent(ctx, km)
if err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{RequeueAfter: p.requeue}, nil
}
if p.id != 0 {
if p.reason != "" {
if err := r.setReady(ctx, km, metav1.ConditionFalse, p.reason, p.message, nil); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{RequeueAfter: p.requeue}, nil
}
desired.Parent = &p.id
}
notifications, pending, err := r.resolveNotifications(ctx, km)
if err != nil {
return ctrl.Result{}, err
}
if pending {
if err := r.setReady(ctx, km, metav1.ConditionFalse, ReasonNotificationPending, msgNotificationPending, nil); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{RequeueAfter: parentRetry}, nil
}
desired.NotificationIDs = notifications
res, fail := r.lookup(ctx, desired.OwnerKey)
if fail != nil {
return r.fail(ctx, km, fail)
}
notifications, err := r.Kuma.DefaultNotifications(ctx)
if err != nil {
return r.fail(ctx, km, &failure{ReasonKumaUnavailable, msgKumaUnavailable, err})
}
desired.NotificationIDs = notifications
var id int64
if res.mon == nil {
@@ -288,57 +250,6 @@ func (r *Reconciler) lookup(ctx context.Context, key string) (lookupResult, *fai
return res, &failure{ReasonKumaUnavailable, msgListIncomplete, err}
}
// resolveNotifications returns the Kuma IDs of the referenced
// KumaNotifications. pending reports a reference that does not exist, has no
// notification ID yet or is being deleted. No references yield nil, which
// detaches every notification in Kuma.
func (r *Reconciler) resolveNotifications(ctx context.Context, km *v1.KumaMonitor) (ids []int64, pending bool, err error) {
for _, ref := range km.Spec.NotificationRefs {
kn := &v1.KumaNotification{}
err := r.Client.Get(ctx, types.NamespacedName{Name: ref.Name}, kn)
if apierrors.IsNotFound(err) {
return nil, true, nil
}
if err != nil {
return nil, false, err
}
if kn.Status.NotificationID == 0 || !kn.DeletionTimestamp.IsZero() {
return nil, true, nil
}
ids = append(ids, kn.Status.NotificationID)
}
return ids, false, nil
}
// notificationIDChanged reports changes that alter what a referencing
// monitor attaches.
func notificationIDChanged(e event.UpdateEvent) bool {
o, ok1 := e.ObjectOld.(*v1.KumaNotification)
n, ok2 := e.ObjectNew.(*v1.KumaNotification)
if !ok1 || !ok2 {
return true
}
return o.Status.NotificationID != n.Status.NotificationID ||
o.DeletionTimestamp.IsZero() != n.DeletionTimestamp.IsZero()
}
// referencingMonitors enqueues the KumaMonitors of all namespaces that
// reference the notification.
func (r *Reconciler) referencingMonitors(ctx context.Context, o client.Object) []reconcile.Request {
var list v1.KumaMonitorList
if err := r.Client.List(ctx, &list); err != nil {
log.FromContext(ctx).Error(err, "list KumaMonitors to enqueue")
return nil
}
var reqs []reconcile.Request
for _, km := range list.Items {
if slices.ContainsFunc(km.Spec.NotificationRefs, func(ref v1.NotificationRef) bool { return ref.Name == o.GetName() }) {
reqs = append(reqs, reconcile.Request{NamespacedName: types.NamespacedName{Namespace: km.Namespace, Name: km.Name}})
}
}
return reqs
}
type parentOutcome struct {
id int64
reason string // empty: resolved
@@ -346,75 +257,8 @@ type parentOutcome struct {
requeue time.Duration
}
// rootChanged reports changes to the root group that alter what the monitors
// without a parentRef resolve to.
func rootChanged(e event.UpdateEvent) bool {
o, ok1 := e.ObjectOld.(*v1.KumaMonitor)
n, ok2 := e.ObjectNew.(*v1.KumaMonitor)
if !ok1 || !ok2 {
return true
}
return o.Status.MonitorID != n.Status.MonitorID ||
o.Spec.Type != n.Spec.Type ||
(o.Spec.ParentRef == nil) != (n.Spec.ParentRef == nil) ||
o.DeletionTimestamp.IsZero() != n.DeletionTimestamp.IsZero()
}
// rootPredicate passes events of the root group only.
func (r *Reconciler) rootPredicate() predicate.Funcs {
return predicate.Funcs{
CreateFunc: func(e event.CreateEvent) bool { return r.isRoot(e.Object) },
DeleteFunc: func(e event.DeleteEvent) bool { return r.isRoot(e.Object) },
GenericFunc: func(e event.GenericEvent) bool { return r.isRoot(e.Object) },
UpdateFunc: func(e event.UpdateEvent) bool { return r.isRoot(e.ObjectNew) && rootChanged(e) },
}
}
// rootChildren enqueues the KumaMonitors of all namespaces that have no
// parentRef, except the root group itself.
func (r *Reconciler) rootChildren(ctx context.Context, _ client.Object) []reconcile.Request {
var list v1.KumaMonitorList
if err := r.Client.List(ctx, &list); err != nil {
log.FromContext(ctx).Error(err, "list KumaMonitors to enqueue")
return nil
}
var reqs []reconcile.Request
for i := range list.Items {
km := &list.Items[i]
if km.Spec.ParentRef == nil && !r.isRoot(km) {
reqs = append(reqs, reconcile.Request{NamespacedName: types.NamespacedName{Namespace: km.Namespace, Name: km.Name}})
}
}
return reqs
}
// resolveRoot returns the Kuma ID of the root group for a monitor without a
// parentRef. The root must be a group without a parentRef: under one of its
// own ancestors it would close a cycle in Kuma.
func (r *Reconciler) resolveRoot(ctx context.Context) (parentOutcome, error) {
pending := parentOutcome{reason: ReasonParentPending, message: msgRootPending, requeue: parentRetry}
root := &v1.KumaMonitor{}
err := r.Client.Get(ctx, r.RootGroup, root)
if apierrors.IsNotFound(err) {
return pending, nil
}
if err != nil {
return parentOutcome{}, err
}
switch {
case root.Spec.Type != "group":
return parentOutcome{reason: ReasonInvalidParent, message: msgRootNotGroup, requeue: r.resync()}, nil
case root.Spec.ParentRef != nil:
return parentOutcome{reason: ReasonInvalidParent, message: msgRootHasParent, requeue: r.resync()}, nil
case root.Status.MonitorID == 0 || !root.DeletionTimestamp.IsZero():
return pending, nil
}
return parentOutcome{id: root.Status.MonitorID}, nil
}
// resolveParent checks the whole parentRef chain: every ancestor must exist
// and be a group, without cycles or more than maxParentDepth levels. The root
// group, which the topmost ancestor is placed under, counts as a level.
// and be a group, without cycles or more than maxParentDepth levels.
func (r *Reconciler) resolveParent(ctx context.Context, km *v1.KumaMonitor) (parentOutcome, error) {
invalid := func(msg string) (parentOutcome, error) {
return parentOutcome{reason: ReasonInvalidParent, message: msg, requeue: r.resync()}, nil
@@ -424,8 +268,7 @@ func (r *Reconciler) resolveParent(ctx context.Context, km *v1.KumaMonitor) (par
visited := map[string]bool{km.Name: true}
cur := km
var directID int64
depth := 0
for ; cur.Spec.ParentRef != nil; depth++ {
for depth := 0; cur.Spec.ParentRef != nil; depth++ {
if depth >= maxParentDepth {
return invalid(msgParentTooDeep)
}
@@ -450,9 +293,6 @@ func (r *Reconciler) resolveParent(ctx context.Context, km *v1.KumaMonitor) (par
}
cur = parent
}
if r.hasRoot() && !r.isRoot(cur) && depth >= maxParentDepth {
return invalid(msgParentTooDeep)
}
if directID == 0 {
return pending, nil
}
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"slices"
"testing"
"time"
@@ -310,6 +311,79 @@ func TestReconcileListErrorNoCreate(t *testing.T) {
}
}
func TestReconcileCreateGetsDefaultNotifications(t *testing.T) {
group := &v1.KumaMonitor{
ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "grp"},
Spec: v1.KumaMonitorSpec{Type: "group", DisplayName: "grp", Interval: 60, RetryInterval: 60},
}
e := newEnv(t, httpMonitor("a"), group)
e.kuma.SetDefaultNotifications(7, 3)
for _, name := range []string{"a", "grp"} {
if _, err := e.reconcile(t, name); err != nil {
t.Fatal(err)
}
got := e.kuma.Get(e.get(t, name).Status.MonitorID)
if got == nil || !slices.Equal(got.NotificationIDs, []int64{3, 7}) {
t.Errorf("%s: kuma monitor = %+v, want notifications [3 7]", name, got)
}
}
}
func TestReconcileRevertsNotificationDrift(t *testing.T) {
km := httpMonitor("a")
e := newEnv(t, km)
e.kuma.SetDefaultNotifications(1, 2)
drifted := Desired(km)
drifted.NotificationIDs = []int64{2, 9}
id, _ := e.kuma.Create(t.Context(), drifted)
if _, err := e.reconcile(t, "a"); err != nil {
t.Fatal(err)
}
if e.cnt.updates != 1 || e.cnt.creates != 0 {
t.Fatalf("updates=%d creates=%d", e.cnt.updates, e.cnt.creates)
}
if got := e.kuma.Get(id).NotificationIDs; !slices.Equal(got, []int64{1, 2}) {
t.Errorf("notifications = %v, want [1 2]", got)
}
}
func TestReconcileUnchangedNotificationsNoUpdate(t *testing.T) {
km := httpMonitor("a")
e := newEnv(t, km)
e.kuma.SetDefaultNotifications(4, 2)
existing := Desired(km)
existing.NotificationIDs = []int64{4, 2}
e.kuma.Create(t.Context(), existing)
for range 2 {
if _, err := e.reconcile(t, "a"); err != nil {
t.Fatal(err)
}
}
if e.cnt.updates != 0 || e.cnt.creates != 0 {
t.Fatalf("updates=%d creates=%d, want 0 and 0", e.cnt.updates, e.cnt.creates)
}
}
func TestReconcileNotificationListErrorNoWrite(t *testing.T) {
adopt := httpMonitor("b")
e := newEnv(t, httpMonitor("a"), adopt)
drifted := Desired(adopt)
drifted.Interval = 30
e.kuma.Create(t.Context(), drifted)
e.kuma.SetNotificationsErr(errors.New("boom"))
for _, name := range []string{"a", "b"} {
if _, err := e.reconcile(t, name); err == nil {
t.Fatalf("%s: expected error", name)
}
if c := ready(t, e.get(t, name)); c.Status != metav1.ConditionFalse || c.Reason != ReasonKumaUnavailable {
t.Errorf("%s: Ready = %+v", name, c)
}
}
if e.cnt.creates != 0 || e.cnt.updates != 0 {
t.Errorf("creates=%d updates=%d", e.cnt.creates, e.cnt.updates)
}
}
func TestReconcileOwnerTagMissing(t *testing.T) {
e := newEnv(t, httpMonitor("a"))
e.kuma.SetErrs(kumaclient.ErrOwnerTagMissing, nil, nil, nil)
@@ -24,15 +24,14 @@ func OwnerKey(km *v1.KumaMonitor) string {
func Desired(km *v1.KumaMonitor) kumaclient.Monitor {
s := km.Spec
m := kumaclient.Monitor{
Type: s.Type,
Name: s.DisplayName,
Description: s.Description,
Active: !s.Paused,
Interval: s.Interval,
RetryInterval: s.RetryInterval,
MaxRetries: s.MaxRetries,
ResendInterval: s.ResendInterval,
OwnerKey: OwnerKey(km),
Type: s.Type,
Name: s.DisplayName,
Description: s.Description,
Active: !s.Paused,
Interval: s.Interval,
RetryInterval: s.RetryInterval,
MaxRetries: s.MaxRetries,
OwnerKey: OwnerKey(km),
}
if s.Timeout != nil {
m.Timeout = *s.Timeout
@@ -57,8 +57,6 @@ func TestMappingAllTypes(t *testing.T) {
port.Port = &v1.PortSpec{Hostname: "p.com", Port: 443}
paused := base("group")
paused.Paused = true
resend := base("group")
resend.ResendInterval = 60
wantKW := withHTTP(common("keyword"))
wantKW.Keyword, wantKW.InvertKeyword = "ok", true
@@ -76,8 +74,6 @@ func TestMappingAllTypes(t *testing.T) {
wantPort.Hostname, wantPort.Port = "p.com", 443
wantPaused := common("group")
wantPaused.Active = false
wantResend := common("group")
wantResend.ResendInterval = 60
cases := []struct {
name string
@@ -92,7 +88,6 @@ func TestMappingAllTypes(t *testing.T) {
{"dns with conditions", dnsCond, wantDNSCond},
{"port", port, wantPort},
{"paused maps to inactive", paused, wantPaused},
{"resend interval", resend, wantResend},
}
for _, c := range cases {
got := Desired(mk(c.spec))
@@ -1,240 +0,0 @@
package controller
import (
"context"
"errors"
"reflect"
"strconv"
"strings"
"time"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/builder"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
"sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/predicate"
v1 "gitea.renner.uno/konairius/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/kumaclient"
)
const (
ReasonSecretMissing = "SecretMissing"
ReasonInvalidSecret = "InvalidSecret"
ReasonInvalidSpec = "InvalidSpec"
msgNotificationSynced = "notification in sync"
msgDuplicateNotification = "several notifications carry this name; syncing the lowest ID"
msgSecretMissing = "secret not found or not readable"
msgSecretKeyMissing = "secret lacks host, port, username or password"
msgInvalidSecretPort = "secret port is not a number from 1 to 65535"
msgInvalidType = "notification type is not supported"
// smtpsPort is the port that uses implicit TLS; any other uses STARTTLS.
smtpsPort = 465
)
// smtpSecretKeys are the keys the SMTP Secret must hold, none empty.
var smtpSecretKeys = []string{"host", "port", "username", "password"}
// +kubebuilder:rbac:groups=uptime.renner.uno,resources=kumanotifications,verbs=get;list;watch;update;patch
// +kubebuilder:rbac:groups=uptime.renner.uno,resources=kumanotifications/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=uptime.renner.uno,resources=kumanotifications/finalizers,verbs=update
// NotificationReconciler syncs KumaNotification resources to Kuma. The Kuma
// notification is owned by name: the lowest-ID notification with the
// resource's name is adopted, whoever made it.
type NotificationReconciler struct {
Client client.Client
// APIReader reads the SMTP Secret uncached, so no Secret watch is needed:
// only get on the referenced Secret.
APIReader client.Reader
Kuma kumaclient.Notifications
// Resync is how often a resource is reconciled again, which reverts edits
// made in Kuma and picks up Secret changes. 0 means 5 minutes.
Resync time.Duration
}
func (r *NotificationReconciler) resync() time.Duration {
if r.Resync > 0 {
return r.Resync
}
return resyncPeriod
}
// SetupWithManager ignores status-only updates. Secrets are not watched.
func (r *NotificationReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&v1.KumaNotification{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})).
Named("kumanotification").
Complete(r)
}
// specProblem is a Ready=False outcome that waits for a resync.
type specProblem struct {
reason string
message string
}
func (r *NotificationReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
kn := &v1.KumaNotification{}
if err := r.Client.Get(ctx, req.NamespacedName, kn); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
if !kn.DeletionTimestamp.IsZero() {
return r.reconcileDelete(ctx, kn)
}
if controllerutil.AddFinalizer(kn, Finalizer) {
if err := r.Client.Update(ctx, kn); err != nil {
return ctrl.Result{}, err
}
}
desired, problem, err := r.desired(ctx, kn)
if err != nil {
return ctrl.Result{}, err
}
if problem != nil {
err := r.setReady(ctx, kn, metav1.ConditionFalse, problem.reason, problem.message, nil)
return ctrl.Result{RequeueAfter: r.resync()}, err
}
list, err := r.Kuma.ListNotifications(ctx, kn.Name)
if err != nil {
return r.fail(ctx, kn, &failure{ReasonKumaUnavailable, msgKumaUnavailable, err})
}
var id int64
if len(list) == 0 {
id, err = r.Kuma.CreateNotification(ctx, desired)
if err != nil {
return r.fail(ctx, kn, &failure{ReasonKumaUnavailable, msgKumaUnavailable, err})
}
} else {
id = list[0].ID
desired.ID = id
if !desired.SameContent(list[0]) {
log.FromContext(ctx).Info("notification differs from spec, updating", "notificationID", id)
if err := r.Kuma.UpdateNotification(ctx, desired); err != nil {
return r.fail(ctx, kn, &failure{ReasonKumaUnavailable, msgKumaUnavailable, err})
}
}
}
if len(list) > 1 {
ids := make([]int64, 0, len(list))
for _, n := range list {
ids = append(ids, n.ID)
}
log.FromContext(ctx).Error(nil, "several Kuma notifications carry the same name, syncing the lowest ID",
"name", kn.Name, "notificationIDs", ids)
err := r.setReady(ctx, kn, metav1.ConditionFalse, ReasonDuplicateOwner, msgDuplicateNotification, &id)
return ctrl.Result{RequeueAfter: r.resync()}, err
}
if err := r.setReady(ctx, kn, metav1.ConditionTrue, ReasonSynced, msgNotificationSynced, &id); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{RequeueAfter: r.resync()}, nil
}
// reconcileDelete removes every notification carrying the name, duplicates
// included. Kuma detaches them from the monitors.
func (r *NotificationReconciler) reconcileDelete(ctx context.Context, kn *v1.KumaNotification) (ctrl.Result, error) {
if !controllerutil.ContainsFinalizer(kn, Finalizer) {
return ctrl.Result{}, nil
}
list, err := r.Kuma.ListNotifications(ctx, kn.Name)
if err != nil {
return r.fail(ctx, kn, &failure{ReasonKumaUnavailable, msgKumaUnavailable, err})
}
for _, n := range list {
err := r.Kuma.DeleteNotification(ctx, n.ID)
if err != nil && !errors.Is(err, kumaclient.ErrNotificationNotFound) {
return r.fail(ctx, kn, &failure{ReasonKumaUnavailable, msgKumaUnavailable, err})
}
}
controllerutil.RemoveFinalizer(kn, Finalizer)
return ctrl.Result{}, r.Client.Update(ctx, kn)
}
// desired builds the notification from the spec and the SMTP Secret. A
// missing or invalid Secret is a problem, not an error; the cause is never
// logged, as it may carry Secret data.
func (r *NotificationReconciler) desired(
ctx context.Context, kn *v1.KumaNotification,
) (kumaclient.Notification, *specProblem, error) {
s := kn.Spec.SMTP
if kn.Spec.Type != "smtp" || s == nil {
return kumaclient.Notification{}, &specProblem{ReasonInvalidSpec, msgInvalidType}, nil
}
secret := &corev1.Secret{}
err := r.APIReader.Get(ctx, types.NamespacedName{Namespace: s.SecretRef.Namespace, Name: s.SecretRef.Name}, secret)
if apierrors.IsNotFound(err) || apierrors.IsForbidden(err) {
return kumaclient.Notification{}, &specProblem{ReasonSecretMissing, msgSecretMissing}, nil
}
if err != nil {
return kumaclient.Notification{}, nil, err
}
for _, k := range smtpSecretKeys {
if len(secret.Data[k]) == 0 {
return kumaclient.Notification{}, &specProblem{ReasonSecretMissing, msgSecretKeyMissing}, nil
}
}
port, err := strconv.Atoi(strings.TrimSpace(string(secret.Data["port"])))
if err != nil || port < 1 || port > 65535 {
return kumaclient.Notification{}, &specProblem{ReasonInvalidSecret, msgInvalidSecretPort}, nil
}
return kumaclient.Notification{
Name: kn.Name,
Type: "smtp",
Active: true,
SMTP: kumaclient.SMTP{
Host: strings.TrimSpace(string(secret.Data["host"])),
Port: int32(port),
Secure: port == smtpsPort,
Username: string(secret.Data["username"]),
Password: string(secret.Data["password"]),
From: s.From,
To: s.To,
},
}, nil, nil
}
// fail records a Ready=False outcome and returns its cause so the request is
// requeued with backoff.
func (r *NotificationReconciler) fail(ctx context.Context, kn *v1.KumaNotification, f *failure) (ctrl.Result, error) {
log.FromContext(ctx).Info("reconcile failed", "reason", f.reason, "error", f.err.Error())
if serr := r.setReady(ctx, kn, metav1.ConditionFalse, f.reason, f.message, nil); serr != nil {
return ctrl.Result{}, errors.Join(f.err, serr)
}
return ctrl.Result{}, f.err
}
// setReady writes the Ready condition, and the notification ID when given,
// through the status subresource. It writes nothing if the status is
// unchanged.
func (r *NotificationReconciler) setReady(
ctx context.Context, kn *v1.KumaNotification, status metav1.ConditionStatus, reason, msg string, id *int64,
) error {
orig := kn.Status.DeepCopy()
if id != nil {
kn.Status.NotificationID = *id
kn.Status.ObservedGeneration = kn.Generation
}
meta.SetStatusCondition(&kn.Status.Conditions, metav1.Condition{
Type: v1.ConditionReady,
Status: status,
Reason: reason,
Message: msg,
ObservedGeneration: kn.Generation,
})
if reflect.DeepEqual(*orig, kn.Status) {
return nil
}
return r.Client.Status().Update(ctx, kn)
}
@@ -1,278 +0,0 @@
package controller
import (
"context"
"errors"
"testing"
"time"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
v1 "gitea.renner.uno/konairius/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/kumaclient"
)
// countingNotifs records notification writes on top of the Kuma fake.
type countingNotifs struct {
kumaclient.Notifications
creates, updates, deletes int
}
func (c *countingNotifs) CreateNotification(ctx context.Context, n kumaclient.Notification) (int64, error) {
c.creates++
return c.Notifications.CreateNotification(ctx, n)
}
func (c *countingNotifs) UpdateNotification(ctx context.Context, n kumaclient.Notification) error {
c.updates++
return c.Notifications.UpdateNotification(ctx, n)
}
func (c *countingNotifs) DeleteNotification(ctx context.Context, id int64) error {
c.deletes++
return c.Notifications.DeleteNotification(ctx, id)
}
type notifEnv struct {
r *NotificationReconciler
k8s client.Client
kuma *kumaclient.Fake
cnt *countingNotifs
}
func newNotifEnv(t *testing.T, objs ...client.Object) *notifEnv {
t.Helper()
s := runtime.NewScheme()
if err := clientgoscheme.AddToScheme(s); err != nil {
t.Fatal(err)
}
if err := v1.AddToScheme(s); err != nil {
t.Fatal(err)
}
k8s := fake.NewClientBuilder().WithScheme(s).WithObjects(objs...).
WithStatusSubresource(&v1.KumaNotification{}).Build()
kf := kumaclient.NewFake()
cnt := &countingNotifs{Notifications: kf}
return &notifEnv{
r: &NotificationReconciler{Client: k8s, APIReader: k8s, Kuma: cnt},
k8s: k8s, kuma: kf, cnt: cnt,
}
}
func smtpNotification(name string) *v1.KumaNotification {
return &v1.KumaNotification{
ObjectMeta: metav1.ObjectMeta{Name: name},
Spec: v1.KumaNotificationSpec{Type: "smtp", SMTP: &v1.SMTPSpec{
From: "kuma@renner.uno", To: "ops@renner.uno",
SecretRef: v1.SecretRef{Namespace: "kuma-sync", Name: "smtp"},
}},
}
}
func smtpSecret(port string) *corev1.Secret {
return &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Namespace: "kuma-sync", Name: "smtp"},
Data: map[string][]byte{
"host": []byte("mail.example.com\n"), "port": []byte(port),
"username": []byte("kuma"), "password": []byte("s3cret"),
},
}
}
func wantSMTP(name string, port int32, secure bool) kumaclient.Notification {
return kumaclient.Notification{
Name: name, Type: "smtp", Active: true,
SMTP: kumaclient.SMTP{
Host: "mail.example.com", Port: port, Secure: secure,
Username: "kuma", Password: "s3cret", From: "kuma@renner.uno", To: "ops@renner.uno",
},
}
}
func (e *notifEnv) reconcile(t *testing.T, name string) (ctrl.Result, error) {
t.Helper()
return e.r.Reconcile(t.Context(), ctrl.Request{NamespacedName: types.NamespacedName{Name: name}})
}
func (e *notifEnv) get(t *testing.T, name string) *v1.KumaNotification {
t.Helper()
kn := &v1.KumaNotification{}
if err := e.k8s.Get(t.Context(), types.NamespacedName{Name: name}, kn); err != nil {
t.Fatal(err)
}
return kn
}
func notifReady(t *testing.T, kn *v1.KumaNotification) *metav1.Condition {
t.Helper()
for i := range kn.Status.Conditions {
if kn.Status.Conditions[i].Type == v1.ConditionReady {
return &kn.Status.Conditions[i]
}
}
t.Fatal("no Ready condition")
return nil
}
func TestNotificationCreates(t *testing.T) {
for port, secure := range map[string]bool{"587": false, "465": true, " 25\n": false} {
e := newNotifEnv(t, smtpNotification("mail"), smtpSecret(port))
res, err := e.reconcile(t, "mail")
if err != nil {
t.Fatal(err)
}
if res.RequeueAfter != 5*time.Minute {
t.Errorf("RequeueAfter = %v", res.RequeueAfter)
}
kn := e.get(t, "mail")
if kn.Status.NotificationID == 0 || kn.Status.ObservedGeneration != kn.Generation || len(kn.Finalizers) != 1 {
t.Fatalf("resource = %+v", kn)
}
if c := notifReady(t, kn); c.Status != metav1.ConditionTrue || c.Reason != ReasonSynced {
t.Errorf("Ready = %+v", c)
}
want := wantSMTP("mail", map[string]int32{"587": 587, "465": 465, " 25\n": 25}[port], secure)
want.ID = kn.Status.NotificationID
if got := e.kuma.Notification(kn.Status.NotificationID); got == nil || *got != want {
t.Errorf("port %q: kuma notification = %+v, want %+v", port, got, want)
}
}
}
func TestNotificationAdoptsLowestAndRevertsDrift(t *testing.T) {
e := newNotifEnv(t, smtpNotification("email"), smtpSecret("587"))
drifted := wantSMTP("email", 25, false)
drifted.IsDefault = true
drifted.SMTP.Password = "old"
first := e.kuma.PutNotification(drifted)
if _, err := e.reconcile(t, "email"); err != nil {
t.Fatal(err)
}
if e.cnt.creates != 0 || e.cnt.updates != 1 {
t.Fatalf("creates=%d updates=%d, want 0 and 1", e.cnt.creates, e.cnt.updates)
}
want := wantSMTP("email", 587, false)
want.ID = first
if got := e.kuma.Notification(first); *got != want {
t.Errorf("kuma notification = %+v, want %+v", *got, want)
}
if got := e.get(t, "email").Status.NotificationID; got != first {
t.Errorf("notificationID = %d, want %d", got, first)
}
if _, err := e.reconcile(t, "email"); err != nil {
t.Fatal(err)
}
if e.cnt.updates != 1 {
t.Errorf("updates = %d after a second reconcile, want 1", e.cnt.updates)
}
}
func TestNotificationDuplicateName(t *testing.T) {
e := newNotifEnv(t, smtpNotification("email"), smtpSecret("587"))
first := e.kuma.PutNotification(wantSMTP("email", 587, false))
second := e.kuma.PutNotification(wantSMTP("email", 25, false))
if _, err := e.reconcile(t, "email"); err != nil {
t.Fatal(err)
}
kn := e.get(t, "email")
if kn.Status.NotificationID != first {
t.Errorf("notificationID = %d, want lowest %d", kn.Status.NotificationID, first)
}
if c := notifReady(t, kn); c.Status != metav1.ConditionFalse || c.Reason != ReasonDuplicateOwner {
t.Errorf("Ready = %+v", c)
}
if e.cnt.updates != 0 || e.kuma.Notification(second).SMTP.Port != 25 {
t.Error("only the lowest ID may be synced")
}
}
func TestNotificationSecretProblems(t *testing.T) {
noKey := smtpSecret("587")
delete(noKey.Data, "password")
for name, tc := range map[string]struct {
secret client.Object
reason string
message string
}{
"missing secret": {nil, ReasonSecretMissing, "secret not found or not readable"},
"missing key": {noKey, ReasonSecretMissing, "secret lacks host, port, username or password"},
"bad port": {smtpSecret("smtp"), ReasonInvalidSecret, "secret port is not a number from 1 to 65535"},
"port too large": {smtpSecret("70000"), ReasonInvalidSecret, "secret port is not a number from 1 to 65535"},
} {
objs := []client.Object{smtpNotification("mail")}
if tc.secret != nil {
objs = append(objs, tc.secret)
}
e := newNotifEnv(t, objs...)
res, err := e.reconcile(t, "mail")
if err != nil {
t.Fatalf("%s: %v", name, err)
}
if res.RequeueAfter != 5*time.Minute {
t.Errorf("%s: RequeueAfter = %v", name, res.RequeueAfter)
}
if c := notifReady(t, e.get(t, "mail")); c.Status != metav1.ConditionFalse || c.Reason != tc.reason || c.Message != tc.message {
t.Errorf("%s: Ready = %+v", name, c)
}
if e.cnt.creates != 0 || e.cnt.updates != 0 {
t.Errorf("%s: creates=%d updates=%d", name, e.cnt.creates, e.cnt.updates)
}
}
}
func TestNotificationKumaErrorNoWrite(t *testing.T) {
e := newNotifEnv(t, smtpNotification("mail"), smtpSecret("587"))
e.kuma.SetNotifErr(errors.New("boom"))
if _, err := e.reconcile(t, "mail"); err == nil {
t.Fatal("expected error")
}
if c := notifReady(t, e.get(t, "mail")); c.Reason != ReasonKumaUnavailable || c.Message != msgKumaUnavailable {
t.Errorf("Ready = %+v", c)
}
}
func TestNotificationDeleteRemovesAll(t *testing.T) {
kn := smtpNotification("email")
now := metav1.Now()
kn.DeletionTimestamp = &now
kn.Finalizers = []string{Finalizer}
e := newNotifEnv(t, kn)
first := e.kuma.PutNotification(wantSMTP("email", 587, false))
second := e.kuma.PutNotification(wantSMTP("email", 25, false))
other := e.kuma.PutNotification(wantSMTP("other", 25, false))
if _, err := e.reconcile(t, "email"); err != nil {
t.Fatal(err)
}
if e.kuma.Notification(first) != nil || e.kuma.Notification(second) != nil {
t.Error("every notification with the name must be deleted")
}
if e.kuma.Notification(other) == nil {
t.Error("other notification deleted")
}
if err := e.k8s.Get(t.Context(), client.ObjectKeyFromObject(kn), &v1.KumaNotification{}); !apierrors.IsNotFound(err) {
t.Errorf("finalizer not removed: %v", err)
}
}
func TestNotificationDeleteKumaErrorKeepsFinalizer(t *testing.T) {
kn := smtpNotification("email")
now := metav1.Now()
kn.DeletionTimestamp = &now
kn.Finalizers = []string{Finalizer}
e := newNotifEnv(t, kn)
e.kuma.SetNotifErr(errors.New("boom"))
if _, err := e.reconcile(t, "email"); err == nil {
t.Fatal("expected error")
}
if got := e.get(t, "email").Finalizers; len(got) != 1 {
t.Errorf("finalizers = %v", got)
}
}
@@ -1,98 +0,0 @@
//go:build integration
package controller_test
import (
"fmt"
"slices"
"testing"
"time"
"github.com/breml/go-uptime-kuma-client/monitor"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
v1 "gitea.renner.uno/konairius/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/kumaclient"
)
// A KumaNotification adopts the hand-made notification of its name, and only
// monitors referencing it carry it; a default notification is never attached.
func TestE2ENotificationRefs(t *testing.T) {
f, env := newE2E(t)
legacy := f.webhook("legacy", true)
adopted := f.webhook("mail", false)
f.start(env)
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "smtp"},
Data: map[string][]byte{
"host": []byte("mail.example.com"), "port": []byte("587"),
"username": []byte("kuma"), "password": []byte("s3cret"),
},
}
kn := &v1.KumaNotification{
ObjectMeta: metav1.ObjectMeta{Name: "mail"},
Spec: v1.KumaNotificationSpec{Type: "smtp", SMTP: &v1.SMTPSpec{
From: "kuma@example.com", To: "ops@example.com",
SecretRef: v1.SecretRef{Namespace: "default", Name: "smtp"},
}},
}
if err := f.k8s.Create(t.Context(), secret); err != nil {
t.Fatal(err)
}
if err := f.k8s.Create(t.Context(), kn); err != nil {
t.Fatal(err)
}
g := group("g", "")
g.Spec.NotificationRefs = []v1.NotificationRef{{Name: "mail"}}
f.apply(g, dns("probe", "g"))
want := kumaclient.Notification{
ID: adopted, Name: "mail", Type: "smtp", Active: true,
SMTP: kumaclient.SMTP{
Host: "mail.example.com", Port: 587, Username: "kuma", Password: "s3cret",
From: "kuma@example.com", To: "ops@example.com",
},
}
eventually(t, 30*time.Second, "notification adopted and converted", func() (bool, string) {
got, err := f.census.ListNotifications(t.Context(), "mail")
if err != nil {
return false, err.Error()
}
cur := &v1.KumaNotification{}
if err := f.k8s.Get(t.Context(), types.NamespacedName{Name: "mail"}, cur); err != nil {
return false, err.Error()
}
if len(got) != 1 || got[0] != want || cur.Status.NotificationID != adopted {
return false, fmt.Sprintf("kuma %+v, status ID %d", got, cur.Status.NotificationID)
}
return true, ""
})
carry := func(key string, want ...int64) func() (bool, string) {
return func() (bool, string) {
got, ok := byKey(f.owned())[key]
if !ok {
return false, key + " missing"
}
if !slices.Equal(got.NotificationIDs, want) {
return false, fmt.Sprintf("%s carries %v, want %v", key, got.NotificationIDs, want)
}
return true, ""
}
}
eventually(t, 30*time.Second, "group carries the referenced notification", carry("default/g", adopted))
eventually(t, 30*time.Second, "child carries none", carry("default/probe"))
var live monitor.Group
if err := f.raw.GetMonitorAs(t.Context(), byKey(f.owned())["default/g"].ID, &live); err != nil {
t.Fatal(err)
}
live.NotificationIDs = []int64{legacy}
if err := f.raw.UpdateMonitor(t.Context(), &live); err != nil {
t.Fatal(err)
}
eventually(t, 30*time.Second, "notifications changed by hand restored by a resync", carry("default/g", adopted))
}
@@ -1,160 +0,0 @@
package controller
import (
"slices"
"testing"
"time"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/event"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
v1 "gitea.renner.uno/konairius/homelab/kuma-sync/api/v1alpha1"
)
func kumaNotification(name string, id int64) *v1.KumaNotification {
return &v1.KumaNotification{
ObjectMeta: metav1.ObjectMeta{Name: name},
Spec: v1.KumaNotificationSpec{Type: "smtp"},
Status: v1.KumaNotificationStatus{NotificationID: id},
}
}
func withRefs(km *v1.KumaMonitor, names ...string) *v1.KumaMonitor {
for _, n := range names {
km.Spec.NotificationRefs = append(km.Spec.NotificationRefs, v1.NotificationRef{Name: n})
}
return km
}
func TestReconcileAttachesReferencedNotifications(t *testing.T) {
group := withRefs(&v1.KumaMonitor{
ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "grp"},
Spec: v1.KumaMonitorSpec{Type: "group", DisplayName: "grp", Interval: 60, RetryInterval: 60},
}, "mail", "ops")
e := newEnv(t, withRefs(httpMonitor("a"), "ops"), group, kumaNotification("mail", 7), kumaNotification("ops", 3))
for name, want := range map[string][]int64{"a": {3}, "grp": {3, 7}} {
if _, err := e.reconcile(t, name); err != nil {
t.Fatal(err)
}
got := e.kuma.Get(e.get(t, name).Status.MonitorID)
if got == nil || !slices.Equal(got.NotificationIDs, want) {
t.Errorf("%s: kuma monitor = %+v, want notifications %v", name, got, want)
}
}
}
func TestReconcileNotificationPending(t *testing.T) {
deleted := kumaNotification("going", 5)
now := metav1.Now()
deleted.DeletionTimestamp = &now
deleted.Finalizers = []string{Finalizer}
e := newEnv(t,
withRefs(httpMonitor("missing"), "nope"),
withRefs(httpMonitor("unsynced"), "new"), kumaNotification("new", 0),
withRefs(httpMonitor("deleting"), "going"), deleted,
)
for _, name := range []string{"missing", "unsynced", "deleting"} {
res, err := e.reconcile(t, name)
if err != nil {
t.Fatalf("%s: %v", name, err)
}
if res.RequeueAfter != 10*time.Second {
t.Errorf("%s: RequeueAfter = %v", name, res.RequeueAfter)
}
c := ready(t, e.get(t, name))
if c.Status != metav1.ConditionFalse || c.Reason != ReasonNotificationPending ||
c.Message != "a referenced notification has no notification ID yet" {
t.Errorf("%s: Ready = %+v", name, c)
}
}
if e.cnt.creates != 0 || e.cnt.updates != 0 {
t.Errorf("creates=%d updates=%d", e.cnt.creates, e.cnt.updates)
}
}
func TestReconcileNoRefsDetachesNotifications(t *testing.T) {
km := httpMonitor("a")
e := newEnv(t, km)
attached := Desired(km)
attached.NotificationIDs = []int64{2, 9}
id, _ := e.kuma.Create(t.Context(), attached)
if _, err := e.reconcile(t, "a"); err != nil {
t.Fatal(err)
}
if e.cnt.updates != 1 {
t.Fatalf("updates = %d", e.cnt.updates)
}
if got := e.kuma.Get(id).NotificationIDs; got != nil {
t.Errorf("notifications = %v, want none", got)
}
}
func TestReconcileRevertsNotificationDrift(t *testing.T) {
km := withRefs(httpMonitor("a"), "mail")
e := newEnv(t, km, kumaNotification("mail", 1))
drifted := Desired(km)
drifted.NotificationIDs = []int64{2, 9}
id, _ := e.kuma.Create(t.Context(), drifted)
if _, err := e.reconcile(t, "a"); err != nil {
t.Fatal(err)
}
if e.cnt.updates != 1 || e.cnt.creates != 0 {
t.Fatalf("updates=%d creates=%d", e.cnt.updates, e.cnt.creates)
}
if got := e.kuma.Get(id).NotificationIDs; !slices.Equal(got, []int64{1}) {
t.Errorf("notifications = %v, want [1]", got)
}
}
func TestReconcileUnchangedNotificationsNoUpdate(t *testing.T) {
km := withRefs(httpMonitor("a"), "mail", "ops")
e := newEnv(t, km, kumaNotification("mail", 4), kumaNotification("ops", 2))
existing := Desired(km)
existing.NotificationIDs = []int64{4, 2}
e.kuma.Create(t.Context(), existing)
for range 2 {
if _, err := e.reconcile(t, "a"); err != nil {
t.Fatal(err)
}
}
if e.cnt.updates != 0 || e.cnt.creates != 0 {
t.Fatalf("updates=%d creates=%d, want 0 and 0", e.cnt.updates, e.cnt.creates)
}
}
func TestReferencingMonitors(t *testing.T) {
other := withRefs(httpMonitor("b"), "mail")
other.Namespace = "other"
e := newEnv(t, withRefs(httpMonitor("a"), "ops", "mail"), other, httpMonitor("none"), withRefs(httpMonitor("c"), "ops"))
got := e.r.referencingMonitors(t.Context(), kumaNotification("mail", 1))
want := []reconcile.Request{
{NamespacedName: types.NamespacedName{Namespace: "default", Name: "a"}},
{NamespacedName: types.NamespacedName{Namespace: "other", Name: "b"}},
}
if !slices.Equal(got, want) {
t.Errorf("requests = %v, want %v", got, want)
}
}
func TestNotificationIDChanged(t *testing.T) {
base := kumaNotification("mail", 1)
changed := base.DeepCopy()
changed.Status.NotificationID = 2
cond := base.DeepCopy()
cond.Status.Conditions = []metav1.Condition{{Type: v1.ConditionReady, Status: metav1.ConditionTrue}}
deleting := base.DeepCopy()
now := metav1.Now()
deleting.DeletionTimestamp = &now
for name, tc := range map[string]struct {
n *v1.KumaNotification
want bool
}{
"id changed": {changed, true}, "condition only": {cond, false}, "deleting": {deleting, true},
} {
if got := notificationIDChanged(event.UpdateEvent{ObjectOld: base, ObjectNew: tc.n}); got != tc.want {
t.Errorf("%s: %v, want %v", name, got, tc.want)
}
}
}
@@ -1,209 +0,0 @@
package controller
import (
"fmt"
"slices"
"testing"
"time"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/event"
v1 "gitea.renner.uno/konairius/homelab/kuma-sync/api/v1alpha1"
)
const rootID = 7
var rootName = types.NamespacedName{Namespace: "default", Name: "root"}
func rootGroup() *v1.KumaMonitor {
g := group("root", "")
g.Status.MonitorID = rootID
return g
}
func newRootEnv(t *testing.T, objs ...client.Object) *env {
t.Helper()
e := newEnv(t, objs...)
e.r.RootGroup = rootName
return e
}
func (e *env) parentOf(t *testing.T, name string) *int64 {
t.Helper()
got := e.kuma.Get(e.get(t, name).Status.MonitorID)
if got == nil {
t.Fatalf("%s: no Kuma monitor", name)
}
return got.Parent
}
func TestRootGroupParentsParentless(t *testing.T) {
e := newRootEnv(t, rootGroup(), httpMonitor("a"))
if _, err := e.reconcile(t, "a"); err != nil {
t.Fatal(err)
}
if p := e.parentOf(t, "a"); p == nil || *p != rootID {
t.Errorf("parent = %v, want %d", p, rootID)
}
}
func TestRootGroupKeepsParentRef(t *testing.T) {
grp := group("grp", "")
grp.Status.MonitorID = 42
child := httpMonitor("child")
child.Spec.ParentRef = &v1.LocalRef{Name: "grp"}
e := newRootEnv(t, rootGroup(), grp, child)
if _, err := e.reconcile(t, "child"); err != nil {
t.Fatal(err)
}
if p := e.parentOf(t, "child"); p == nil || *p != 42 {
t.Errorf("parent = %v, want 42", p)
}
}
func TestRootGroupItselfHasNoParent(t *testing.T) {
root := rootGroup()
root.Status.MonitorID = 0
e := newRootEnv(t, root)
if _, err := e.reconcile(t, "root"); err != nil {
t.Fatal(err)
}
if p := e.parentOf(t, "root"); p != nil {
t.Errorf("parent = %d, want none", *p)
}
}
func TestRootGroupUnsetLeavesTopLevel(t *testing.T) {
e := newEnv(t, rootGroup(), httpMonitor("a"))
if _, err := e.reconcile(t, "a"); err != nil {
t.Fatal(err)
}
if p := e.parentOf(t, "a"); p != nil {
t.Errorf("parent = %d, want none", *p)
}
}
func TestRootGroupPending(t *testing.T) {
noID := rootGroup()
noID.Status.MonitorID = 0
deletingRoot := deleting(rootGroup())
cases := map[string][]client.Object{
"missing": nil,
"no ID": {noID},
"deleting": {deletingRoot},
"not group": {func() *v1.KumaMonitor { m := httpMonitor("root"); m.Status.MonitorID = rootID; return m }()},
"has parent": {func() *v1.KumaMonitor {
m := rootGroup()
m.Spec.ParentRef = &v1.LocalRef{Name: "x"}
return m
}()},
}
want := map[string]struct {
reason string
requeue time.Duration
}{
"missing": {ReasonParentPending, 10 * time.Second},
"no ID": {ReasonParentPending, 10 * time.Second},
"deleting": {ReasonParentPending, 10 * time.Second},
"not group": {ReasonInvalidParent, 5 * time.Minute},
"has parent": {ReasonInvalidParent, 5 * time.Minute},
}
for name, objs := range cases {
e := newRootEnv(t, append(objs, httpMonitor("a"))...)
res, err := e.reconcile(t, "a")
if err != nil {
t.Fatalf("%s: %v", name, err)
}
if res.RequeueAfter != want[name].requeue {
t.Errorf("%s: RequeueAfter = %v, want %v", name, res.RequeueAfter, want[name].requeue)
}
if c := ready(t, e.get(t, "a")); c.Status != metav1.ConditionFalse || c.Reason != want[name].reason {
t.Errorf("%s: Ready = %+v", name, c)
}
if e.cnt.creates != 0 {
t.Errorf("%s: creates = %d", name, e.cnt.creates)
}
}
}
// A chain of maxParentDepth groups is allowed on its own, but the root group
// adds one more level above it.
func TestRootGroupCountsTowardsDepth(t *testing.T) {
var objs []client.Object
for i := range maxParentDepth {
parent := ""
if i < maxParentDepth-1 {
parent = fmt.Sprintf("c%d", i+1)
}
objs = append(objs, group(fmt.Sprintf("c%d", i), parent))
}
deep := httpMonitor("deep")
deep.Spec.ParentRef = &v1.LocalRef{Name: "c0"}
objs = append(objs, deep)
if _, err := newEnv(t, objs...).reconcile(t, "deep"); err != nil {
t.Fatal(err)
}
e := newRootEnv(t, append(objs, rootGroup())...)
if _, err := e.reconcile(t, "deep"); err != nil {
t.Fatal(err)
}
if c := ready(t, e.get(t, "deep")); c.Reason != ReasonInvalidParent || c.Message != msgParentTooDeep {
t.Errorf("Ready = %+v", c)
}
// A chain ending in the root itself gets no extra level.
objs[maxParentDepth-1] = func() *v1.KumaMonitor { g := rootGroup(); g.Name = "c9"; return g }()
e = newEnv(t, objs...)
e.r.RootGroup = types.NamespacedName{Namespace: "default", Name: "c9"}
if _, err := e.reconcile(t, "deep"); err != nil {
t.Fatal(err)
}
if c := ready(t, e.get(t, "deep")); c.Status != metav1.ConditionTrue {
t.Errorf("chain ending in root: Ready = %+v", c)
}
}
func TestRootChildrenEnqueuesParentless(t *testing.T) {
child := httpMonitor("child")
child.Spec.ParentRef = &v1.LocalRef{Name: "grp"}
other := httpMonitor("other")
other.Namespace = "elsewhere"
e := newRootEnv(t, rootGroup(), group("grp", ""), child, httpMonitor("a"), other)
var got []string
for _, req := range e.r.rootChildren(t.Context(), rootGroup()) {
got = append(got, req.String())
}
slices.Sort(got)
want := []string{"default/a", "default/grp", "elsewhere/other"}
if !slices.Equal(got, want) {
t.Errorf("enqueued %v, want %v", got, want)
}
}
func TestRootPredicate(t *testing.T) {
r := &Reconciler{RootGroup: rootName}
p := r.rootPredicate()
old := rootGroup()
old.Status.MonitorID = 0
withID := rootGroup()
other := group("other", "")
if !p.Update(event.UpdateEvent{ObjectOld: old, ObjectNew: withID}) {
t.Error("root ID change must pass")
}
if p.Update(event.UpdateEvent{ObjectOld: withID, ObjectNew: withID.DeepCopy()}) {
t.Error("unchanged root must not pass")
}
if p.Update(event.UpdateEvent{ObjectOld: other, ObjectNew: other.DeepCopy()}) || p.Create(event.CreateEvent{Object: other}) {
t.Error("other monitors must not pass")
}
if !p.Create(event.CreateEvent{Object: withID}) || !p.Delete(event.DeleteEvent{Object: withID}) {
t.Error("root create and delete must pass")
}
}
@@ -13,6 +13,7 @@ import (
kuma "github.com/breml/go-uptime-kuma-client"
"github.com/breml/go-uptime-kuma-client/monitor"
"github.com/breml/go-uptime-kuma-client/notification"
"github.com/breml/go-uptime-kuma-client/tag"
)
@@ -46,9 +47,6 @@ type Breml struct {
opts []kuma.Option
token string
backoff0 time.Duration
// notifStale means a notification write may be missing from the
// session's cached list.
notifStale bool
}
var _ Client = (*Breml)(nil)
@@ -160,7 +158,7 @@ func (b *Breml) checkSession(ctx context.Context, err error) (rebuilt, dead bool
if ctx.Err() != nil {
return false, false
}
for _, app := range []error{ErrNotFound, ErrNotOwned, ErrOwnerTagMissing, ErrOwnerConflict, ErrStatusPageNotFound, ErrNotificationNotFound} {
for _, app := range []error{ErrNotFound, ErrNotOwned, ErrOwnerTagMissing, ErrOwnerConflict, ErrStatusPageNotFound} {
if errors.Is(err, app) {
return false, false
}
@@ -198,8 +196,6 @@ func (b *Breml) reconnect(ctx context.Context) error {
}
_ = b.c.Disconnect()
b.c = c
// A new session starts with a fresh notification list.
b.notifStale = false
if t := c.SessionToken(); t != "" {
b.token = t
}
@@ -378,7 +374,7 @@ func (b *Breml) update(ctx context.Context, m Monitor) error {
return fmt.Errorf("update %d: monitor carries owner key %q, not %q: %w", m.ID, key, m.OwnerKey, ErrNotOwned)
}
// Start from the full current object, also across a type change, so
// fields we do not manage (proxy, upside down, auth)
// fields we do not manage (proxy, resend interval, upside down, auth)
// survive the overwrite.
wire, err := toWire(m, &cur)
if err != nil {
@@ -409,6 +405,29 @@ func (b *Breml) Delete(ctx context.Context, id int64) error {
return err
}
// DefaultNotifications reads the notification list cached by the session.
// Kuma has no command to request it: it pushes the list at login and to every
// session of the user after each change.
func (b *Breml) DefaultNotifications(ctx context.Context) ([]int64, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
b.mu.Lock()
defer b.mu.Unlock()
return defaultIDs(b.c.GetNotifications(ctx)), nil
}
func defaultIDs(list []notification.Base) []int64 {
var ids []int64
for _, n := range list {
if n.IsDefault {
ids = append(ids, n.ID)
}
}
slices.Sort(ids)
return ids
}
// toDomain converts a monitor from the Kuma monitor list.
func toDomain(base monitor.Base, ownerKey string) (Monitor, error) {
m := Monitor{
@@ -420,7 +439,6 @@ func toDomain(base monitor.Base, ownerKey string) (Monitor, error) {
Interval: int32(base.Interval),
RetryInterval: int32(base.RetryInterval),
MaxRetries: int32(base.MaxRetries),
ResendInterval: int32(base.ResendInterval),
NotificationIDs: slices.Clone(base.NotificationIDs),
OwnerKey: ownerKey,
}
@@ -582,7 +600,6 @@ func writeBase(b *monitor.Base, m Monitor) {
b.Interval = int64(m.Interval)
b.RetryInterval = int64(m.RetryInterval)
b.MaxRetries = int64(m.MaxRetries)
b.ResendInterval = int64(m.ResendInterval)
b.IsActive = m.Active
b.NotificationIDs = slices.Clone(m.NotificationIDs)
}
@@ -127,7 +127,7 @@ func TestCreateFieldsIntact(t *testing.T) {
group := Monitor{
Type: "group", Name: "grp", Description: "a group", Active: true,
Interval: 60, RetryInterval: 60, MaxRetries: 0, ResendInterval: 60, OwnerKey: "default/grp",
Interval: 60, RetryInterval: 60, MaxRetries: 0, OwnerKey: "default/grp",
}
gid, err := b.Create(ctx, group)
if err != nil {
@@ -313,6 +313,7 @@ func setUnmanaged(t *testing.T, b *Breml, id int64) (notificationID int64) {
t.Fatal(err)
}
kw.UpsideDown = true
kw.ResendInterval = 7
kw.NotificationIDs = []int64{nid}
if err := b.c.UpdateMonitor(ctx, &kw); err != nil {
t.Fatal(err)
@@ -327,8 +328,8 @@ func assertUnmanaged(t *testing.T, c *Breml, id int64, when string) {
if err != nil {
t.Fatal(err)
}
if !base.UpsideDown {
t.Errorf("%s: unmanaged state lost: upsideDown=%v", when, base.UpsideDown)
if !base.UpsideDown || base.ResendInterval != 7 {
t.Errorf("%s: unmanaged state lost: upsideDown=%v resend=%d", when, base.UpsideDown, base.ResendInterval)
}
}
@@ -343,7 +344,7 @@ func TestUpdatePreservesUnmanagedAndClearsManaged(t *testing.T) {
}
orig := Monitor{
Type: "keyword", Name: "kw", Active: true, Parent: &gid,
Interval: 60, RetryInterval: 60, ResendInterval: 7, URL: "https://example.com",
Interval: 60, RetryInterval: 60, URL: "https://example.com",
Headers: map[string]string{"A": "1"}, Keyword: "up", OwnerKey: "default/kw",
}
id, err := b.Create(ctx, orig)
@@ -359,7 +360,6 @@ func TestUpdatePreservesUnmanagedAndClearsManaged(t *testing.T) {
cleared.Headers = nil
cleared.Keyword = ""
cleared.Interval = 90
cleared.ResendInterval = 0
if err := b.Update(ctx, cleared); err != nil {
t.Fatal(err)
}
@@ -369,7 +369,7 @@ func TestUpdatePreservesUnmanagedAndClearsManaged(t *testing.T) {
t.Fatal(err)
}
m := byID(got)[id]
if m.Parent != nil || m.Headers != nil || m.Keyword != "" || m.Interval != 90 || m.ResendInterval != 0 || m.NotificationIDs != nil {
if m.Parent != nil || m.Headers != nil || m.Keyword != "" || m.Interval != 90 || m.NotificationIDs != nil {
t.Errorf("managed fields not cleared: %+v", m)
}
assertUnmanaged(t, fresh, id, "after plain update")
@@ -391,6 +391,51 @@ func TestUpdatePreservesUnmanagedAndClearsManaged(t *testing.T) {
assertUnmanaged(t, fresh, id, "after type change")
}
func TestDefaultNotificationsAndWrite(t *testing.T) {
ctx := context.Background()
url := startKuma(t)
b := setupClient(t, url)
other := freshClient(t, url)
if ids, err := other.DefaultNotifications(ctx); err != nil || ids != nil {
t.Fatalf("no notifications: ids=%v err=%v", ids, err)
}
def := createWebhook(t, b, "default", true)
createWebhook(t, b, "opt-in", false)
// Kuma pushes the list to every session of the user, not only the writer.
deadline := time.Now().Add(10 * time.Second)
for {
ids, err := other.DefaultNotifications(ctx)
if err != nil {
t.Fatal(err)
}
if reflect.DeepEqual(ids, []int64{def}) {
break
}
if time.Now().After(deadline) {
t.Fatalf("other session sees defaults %v, want [%d]", ids, def)
}
time.Sleep(100 * time.Millisecond)
}
m := Monitor{
Type: "group", Name: "grp", Active: true, Interval: 60, RetryInterval: 60,
NotificationIDs: []int64{def}, OwnerKey: "default/grp",
}
id, err := b.Create(ctx, m)
if err != nil {
t.Fatal(err)
}
got, err := freshClient(t, url).ListOwned(ctx)
if err != nil {
t.Fatal(err)
}
m.ID = id
if !reflect.DeepEqual(byID(got)[id], m.Normalized()) {
t.Errorf("read-back differs:\n got %+v\n want %+v", byID(got)[id], m.Normalized())
}
}
func TestRestartAdoptsByTag(t *testing.T) {
ctx := context.Background()
url := startKuma(t)
@@ -1,202 +0,0 @@
package kumaclient
import (
"cmp"
"context"
"errors"
"fmt"
"maps"
"slices"
"strconv"
kuma "github.com/breml/go-uptime-kuma-client"
"github.com/breml/go-uptime-kuma-client/notification"
)
const typeSMTP = "smtp"
// ListNotifications reads the notification list cached by the session. Kuma
// has no command to request it: it pushes the list at login and to every
// session of the user after each change. After a write whose broadcast was
// missed the cache is rebuilt first, so a created notification is never
// reported absent.
func (b *Breml) ListNotifications(ctx context.Context, name string) ([]Notification, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
b.mu.Lock()
defer b.mu.Unlock()
if b.notifStale {
if err := b.c.Resync(ctx); err != nil && !b.recoverSession(ctx, err) {
return nil, fmt.Errorf("resync notifications: %w", err)
}
b.notifStale = false
}
var out []Notification
for _, base := range b.c.GetNotifications(ctx) {
if base.Name != name {
continue
}
n, err := toNotification(base)
if err != nil {
return nil, err
}
out = append(out, n)
}
slices.SortFunc(out, func(a, b Notification) int { return cmp.Compare(a.ID, b.ID) })
return out, nil
}
// CreateNotification is never retried: Kuma may have stored the notification
// although the ack was lost; the next list finds it by name.
func (b *Breml) CreateNotification(ctx context.Context, n Notification) (int64, error) {
if n.Type != typeSMTP {
return 0, fmt.Errorf("create notification: unsupported type %q", n.Type)
}
b.mu.Lock()
defer b.mu.Unlock()
wire := &notification.SMTP{
Base: notification.Base{Name: n.Name, IsActive: n.Active, IsDefault: n.IsDefault},
}
writeSMTP(&wire.SMTPDetails, n.SMTP)
id, err := b.c.CreateNotification(ctx, wire)
if err != nil {
b.notifStale = true
}
if createdDespite(id, err) {
return id, nil
}
b.recoverSession(ctx, err)
return 0, err
}
func (b *Breml) UpdateNotification(ctx context.Context, n Notification) error {
if n.Type != typeSMTP {
return fmt.Errorf("update notification %d: unsupported type %q", n.ID, n.Type)
}
b.mu.Lock()
defer b.mu.Unlock()
_, err := withRecovery(ctx, b, func() (struct{}, error) { return struct{}{}, b.updateNotification(ctx, n) })
return err
}
// updateNotification starts from the notification's current config, so keys
// kuma-sync does not manage are written back unchanged, also across a type
// change.
func (b *Breml) updateNotification(ctx context.Context, n Notification) error {
cur, err := b.c.GetNotification(ctx, n.ID)
if errors.Is(err, kuma.ErrNotFound) {
return fmt.Errorf("update notification %d: %w", n.ID, ErrNotificationNotFound)
}
if err != nil {
return err
}
var g notification.Generic
if err := cur.As(&g); err != nil {
return fmt.Errorf("notification %d: %w", n.ID, err)
}
g.Name = n.Name
g.IsActive = n.Active
g.IsDefault = n.IsDefault
g.ApplyExisting = false
g.TypeName = typeSMTP
g.GenericDetails = maps.Clone(g.GenericDetails)
if g.GenericDetails == nil {
g.GenericDetails = notification.GenericDetails{}
}
var d notification.SMTPDetails
writeSMTP(&d, n.SMTP)
g.GenericDetails["smtpHost"] = d.Host
g.GenericDetails["smtpPort"] = d.Port
g.GenericDetails["smtpSecure"] = d.Secure
g.GenericDetails["smtpIgnoreSTARTTLS"] = d.IgnoreSTARTTLS
g.GenericDetails["smtpUsername"] = d.Username
g.GenericDetails["smtpPassword"] = d.Password
g.GenericDetails["smtpFrom"] = d.From
g.GenericDetails["smtpTo"] = d.To
err = b.c.UpdateNotification(ctx, g)
if errors.Is(err, kuma.ErrUpdateEventTimeout) {
// Applied; only the broadcast is missing.
b.notifStale = true
return nil
}
return err
}
func (b *Breml) DeleteNotification(ctx context.Context, id int64) error {
b.mu.Lock()
defer b.mu.Unlock()
_, err := withRecovery(ctx, b, func() (struct{}, error) {
if _, err := b.c.GetNotification(ctx, id); errors.Is(err, kuma.ErrNotFound) {
return struct{}{}, fmt.Errorf("delete notification %d: %w", id, ErrNotificationNotFound)
}
err := b.c.DeleteNotification(ctx, id)
if errors.Is(err, kuma.ErrUpdateEventTimeout) {
b.notifStale = true
return struct{}{}, nil
}
return struct{}{}, err
})
return err
}
func writeSMTP(d *notification.SMTPDetails, s SMTP) {
d.Host = s.Host
d.Port = int(s.Port)
d.Secure = s.Secure
d.IgnoreSTARTTLS = s.IgnoreSTARTTLS
d.Username = s.Username
d.Password = s.Password
d.From = s.From
d.To = s.To
}
// toNotification reads the config loosely: a notification made in the Kuma UI
// may hold the port as a string.
func toNotification(base notification.Base) (Notification, error) {
n := Notification{
ID: base.ID, Name: base.Name, Type: base.Type(), IsDefault: base.IsDefault, Active: base.IsActive,
}
if n.Type != typeSMTP {
return n, nil
}
var g notification.Generic
if err := base.As(&g); err != nil {
return Notification{}, fmt.Errorf("notification %d: %w", base.ID, err)
}
d := g.GenericDetails
n.SMTP = SMTP{
Host: configString(d, "smtpHost"),
Port: configPort(d, "smtpPort"),
Secure: configBool(d, "smtpSecure"),
IgnoreSTARTTLS: configBool(d, "smtpIgnoreSTARTTLS"),
Username: configString(d, "smtpUsername"),
Password: configString(d, "smtpPassword"),
From: configString(d, "smtpFrom"),
To: configString(d, "smtpTo"),
}
return n, nil
}
func configString(d notification.GenericDetails, key string) string {
s, _ := d[key].(string)
return s
}
func configBool(d notification.GenericDetails, key string) bool {
v, _ := d[key].(bool)
return v
}
// configPort returns 0 for a missing or unreadable port, which never matches a
// desired one.
func configPort(d notification.GenericDetails, key string) int32 {
switch v := d[key].(type) {
case float64:
return int32(v)
case string:
p, _ := strconv.ParseInt(v, 10, 32)
return int32(p)
}
return 0
}

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