Compare commits
109
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbeaf622ed | ||
|
|
35a387a3c1 | ||
|
|
093857c353 | ||
|
|
7f29ebb878 | ||
|
|
b07167ae1a | ||
|
|
df0b344f7f | ||
|
|
46b7d458b3 | ||
|
|
2d04575c51 | ||
|
|
97310db78c | ||
|
|
085f969f63 | ||
|
|
d00ee70b23 | ||
|
|
8ea8318c6e | ||
|
|
fbc1aaef21 | ||
|
|
be9898f7b0 | ||
|
|
3c5c04d4a5 | ||
|
|
3ed7741c68 | ||
|
|
f73cc54705 | ||
|
|
c8849b8d9c | ||
|
|
9d0707c1aa | ||
|
|
74db40710e | ||
|
|
368dc24add | ||
|
|
dfb2f71c85 | ||
|
|
6b78f4cb5b | ||
|
|
d8d356779d | ||
|
|
2b1016bd4a | ||
|
|
58695ceb9a | ||
|
|
055cc6a6e3 | ||
|
|
2a0a14fb25 | ||
|
|
67372c673f | ||
|
|
ab1f004b48 | ||
|
|
afbc21edc3 | ||
|
|
91d940499e | ||
|
|
4d4ea98b7c | ||
|
|
7e1b46108a | ||
|
|
4fb3a4ba40 | ||
|
|
72927293e2 | ||
|
|
67aadbc0f9 | ||
|
|
bb992833c2 | ||
|
|
fd2f3b7be4 | ||
|
|
a35bc6b91d | ||
|
|
e614c748b9 | ||
|
|
30b0f43911 | ||
|
|
10819fac62 | ||
|
|
286564a0d7 | ||
|
|
1dc5ae7816 | ||
|
|
dd954ce23a | ||
|
|
ada5b7ea4d | ||
|
|
38c186d424 | ||
|
|
d659097b8f | ||
|
|
4baf5aa8be | ||
|
|
fafc73cb3f | ||
|
|
ab876a1231 | ||
|
|
790e258894 | ||
|
|
d8d676b711 | ||
|
|
3f43d5fa17 | ||
|
|
142a21883d | ||
|
|
dc3a299cf6 | ||
|
|
b73e1b5636 | ||
|
|
c8d2d073a5 | ||
|
|
8d3b3c4324 | ||
|
|
7b1041c188 | ||
|
|
9e62fadcb1 | ||
|
|
5e99ca4370 | ||
|
|
94090c68c3 | ||
|
|
b555277634 | ||
|
|
f8b98907e8 | ||
|
|
d7d99365e8 | ||
|
|
e1c0e73f61 | ||
|
|
59ccd74a89 | ||
|
|
15a4e2a0dc | ||
|
|
2af1d880e4 | ||
|
|
18ef262d93 | ||
|
|
185738bd2c | ||
|
|
0c487656e8 | ||
|
|
b3f68753d1 | ||
|
|
1968e91516 | ||
|
|
4f0c7c414e | ||
|
|
734f546d8b | ||
|
|
90534f8ce7 | ||
|
|
3a684d5452 | ||
|
|
ee0d416b80 | ||
|
|
fdb381305e | ||
|
|
5ebf7a04c4 | ||
|
|
7f4c9c60c3 | ||
|
|
fde532b6a4 | ||
|
|
333f331aed | ||
|
|
1c4f4583fa | ||
|
|
b3a32435a8 | ||
|
|
b31ee3189a | ||
|
|
64a5281bd0 | ||
|
|
f7a0719524 | ||
|
|
4a302c2cbe | ||
|
|
7617a6725b | ||
|
|
c863b9845c | ||
|
|
f74239bb40 | ||
|
|
d43cd54cba | ||
|
|
81f0adce5d | ||
|
|
1d99975267 | ||
|
|
ee5f4894a5 | ||
|
|
2503b353b7 | ||
|
|
4aabb1a601 | ||
|
|
5efb1dd8c8 | ||
|
|
4e5a97ae0b | ||
|
|
8063bfb7b4 | ||
|
|
d8cb907aca | ||
|
|
3c0cb24d19 | ||
|
|
16686fcec3 | ||
|
|
95f1854f3b | ||
|
|
bbd166880b |
@@ -30,9 +30,9 @@
|
||||
# Triggering:
|
||||
# - cron : a cron NAMED exactly "alert-monitor" (name filter, same
|
||||
# mechanism as update-audit.yml):
|
||||
# woodpecker repo cron add --repo k.renner/homelab \
|
||||
# woodpecker repo cron add --repo konairius/homelab \
|
||||
# --name alert-monitor --branch master --schedule '*/15 * * * *'
|
||||
# - deploy : woodpecker-cli pipeline deploy k.renner/homelab <n> alert-monitor
|
||||
# - deploy : woodpecker-cli pipeline deploy konairius/homelab <n> alert-monitor
|
||||
# where <n> is an existing pipeline to promote.
|
||||
when:
|
||||
- event: cron
|
||||
@@ -101,7 +101,7 @@ steps:
|
||||
bash scripts/ci/make-diag-kubeconfig.sh
|
||||
|
||||
- name: alert-triage-agent
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.302
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.303
|
||||
environment:
|
||||
CLAUDE_CODE_OAUTH_TOKEN:
|
||||
from_secret: claude_code_oauth_token
|
||||
@@ -130,7 +130,7 @@ steps:
|
||||
| select([.receivers[]?.name] | (length == 0) or any(. != "null"))]' \
|
||||
.ci-alert-monitor/alerts-raw.json > .ci-alert-monitor/alerts-active.json
|
||||
echo "Active alerts (after heartbeat filter): $$(jq length .ci-alert-monitor/alerts-active.json)"
|
||||
API="https://gitea.renner.uno/api/v1/repos/k.renner/homelab"
|
||||
API="https://gitea.renner.uno/api/v1/repos/konairius/homelab"
|
||||
curl -sfL "$$API/issues?state=open&type=issues&limit=100" > .ci-alert-monitor/open-issues.json
|
||||
TITLES=$$(jq -c '[.[].title]' .ci-alert-monitor/open-issues.json)
|
||||
NOISE_BODY=$$(jq -r 'map(select(.title == "Alert monitor: noise log")) | .[0].body // ""' \
|
||||
@@ -238,7 +238,7 @@ steps:
|
||||
exit 0
|
||||
fi
|
||||
jq . /tmp/alert-verdicts.json > /dev/null # fail loudly on malformed output
|
||||
API="https://gitea.renner.uno/api/v1/repos/k.renner/homelab"
|
||||
API="https://gitea.renner.uno/api/v1/repos/konairius/homelab"
|
||||
STAMP=$$(date -u +%Y-%m-%d)
|
||||
|
||||
gitea_write() {
|
||||
|
||||
@@ -428,6 +428,32 @@ steps:
|
||||
--destination gitea.renner.uno/gitea_admin/authentik-gate-sync:$TAG \
|
||||
--destination gitea.renner.uno/gitea_admin/authentik-gate-sync:latest
|
||||
|
||||
# kuma-sync: reconciles KumaMonitor resources into Uptime Kuma. Its unit
|
||||
# tests run as a stage of the image build.
|
||||
- name: build-kuma-sync
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
|
||||
directory: ci/images/kuma-sync
|
||||
when:
|
||||
- event: deployment
|
||||
- event: push
|
||||
path: ["ci/images/kuma-sync/**", ".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
|
||||
# YYYY-MM-DD-HHMM: the image-automation ImagePolicy picks the newest.
|
||||
- TAG=$(date +%Y-%m-%d-%H%M)
|
||||
- |
|
||||
/kaniko/executor \
|
||||
--context . \
|
||||
--dockerfile Containerfile \
|
||||
--destination gitea.renner.uno/gitea_admin/kuma-sync:$TAG \
|
||||
--destination gitea.renner.uno/gitea_admin/kuma-sync:latest
|
||||
|
||||
# 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
|
||||
|
||||
@@ -48,13 +48,13 @@
|
||||
# OWNER SETUP — the token + secret above are already provisioned. The one
|
||||
# remaining step, to be run AFTER this merges to master (a cron references a
|
||||
# pipeline that must exist on the target branch), is to register the schedule:
|
||||
# woodpecker repo cron add --repo k.renner/homelab \
|
||||
# woodpecker repo cron add --repo konairius/homelab \
|
||||
# --name ci-claude-refresh --branch master --schedule '@weekly'
|
||||
# Weekly is plenty; the pipelines tolerate being a few days behind a release.
|
||||
#
|
||||
# Triggering:
|
||||
# - cron : the cron NAMED exactly "ci-claude-refresh" (see above).
|
||||
# - deploy : woodpecker-cli pipeline deploy k.renner/homelab <n> ci-claude-refresh
|
||||
# - deploy : woodpecker-cli pipeline deploy konairius/homelab <n> ci-claude-refresh
|
||||
# where <n> is an existing pipeline to promote.
|
||||
# - push : a change to the Containerfile (or this file) ON master forces a
|
||||
# rebuild of the current latest version so Containerfile fixes ship
|
||||
@@ -174,9 +174,9 @@ steps:
|
||||
# Both were validated non-empty by the decide step; re-assert before a
|
||||
# sed that would corrupt every pin if either were blank.
|
||||
[ -n "$$NEW" ] && [ -n "$$CUR" ] || { echo "empty version/current sentinel — aborting"; exit 1; }
|
||||
API="https://gitea.renner.uno/api/v1/repos/k.renner/homelab"
|
||||
API="https://gitea.renner.uno/api/v1/repos/konairius/homelab"
|
||||
BRANCH="ci-image/claude-$$NEW"
|
||||
if git ls-remote --heads "https://gitea.renner.uno/k.renner/homelab.git" "$$BRANCH" | grep -q .; then
|
||||
if git ls-remote --heads "https://gitea.renner.uno/konairius/homelab.git" "$$BRANCH" | grep -q .; then
|
||||
echo "Branch $$BRANCH already exists — a PR for $$NEW is already open. Nothing to do."
|
||||
exit 0
|
||||
fi
|
||||
@@ -197,7 +197,7 @@ steps:
|
||||
git checkout -b "$$BRANCH"
|
||||
git add $$CONSUMERS
|
||||
git commit -m "chore(ci): bump ci-claude image to $$NEW"
|
||||
git push "https://$$PUSH_TOKEN@gitea.renner.uno/k.renner/homelab.git" HEAD:"$$BRANCH"
|
||||
git push "https://$$PUSH_TOKEN@gitea.renner.uno/konairius/homelab.git" HEAD:"$$BRANCH"
|
||||
{
|
||||
printf 'Automated bump of the ci-claude agent image from `%s` to `%s`.\n\n' "$$CUR" "$$NEW"
|
||||
printf 'The refresh pipeline built and pushed `ci-claude:%s` (and `:latest`) from the\n' "$$NEW"
|
||||
|
||||
@@ -35,25 +35,25 @@ when:
|
||||
steps:
|
||||
- name: model-selection-tests
|
||||
depends_on: []
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.302
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.303
|
||||
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.302
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.303
|
||||
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.302
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.303
|
||||
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.302
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.303
|
||||
commands:
|
||||
- sh scripts/ci/test-yaml-citations.sh
|
||||
|
||||
|
||||
+2
-2
@@ -7,7 +7,7 @@
|
||||
# was harmless at daily/weekly cadence — but alert-monitor.yml
|
||||
# fires every 15 minutes, so piggybacking must be opt-in now.
|
||||
# A new cron that SHOULD also run e2e gets its name added here.
|
||||
# - deploy : `woodpecker-cli pipeline deploy k.renner/homelab <n> e2e`, where
|
||||
# - deploy : `woodpecker-cli pipeline deploy konairius/homelab <n> e2e`, where
|
||||
# <n> is an existing pipeline to promote.
|
||||
|
||||
when:
|
||||
@@ -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.302
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.303
|
||||
commands:
|
||||
- sh scripts/ci/test-model-selection.sh
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Builds ci/images/hermes (nousresearch/hermes-agent plus the agent's CLIs) as
|
||||
# gitea.renner.uno/konairius/hermes:build.<unix-ts> and :latest.
|
||||
when:
|
||||
- event: push
|
||||
branch: master
|
||||
path:
|
||||
- "ci/images/hermes/**"
|
||||
- ".woodpecker/hermes-image.yml"
|
||||
- event: manual
|
||||
|
||||
steps:
|
||||
- name: build
|
||||
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).
|
||||
GITEA_TOKEN:
|
||||
from_secret: registry_bot_token
|
||||
commands:
|
||||
- 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="build.$(date +%s)"
|
||||
- |
|
||||
/kaniko/executor \
|
||||
--context . \
|
||||
--dockerfile Containerfile \
|
||||
--destination gitea.renner.uno/konairius/hermes:$TAG \
|
||||
--destination gitea.renner.uno/konairius/hermes:latest
|
||||
@@ -32,9 +32,9 @@
|
||||
#
|
||||
# Triggering:
|
||||
# - cron : a cron NAMED exactly "issue-fix":
|
||||
# woodpecker repo cron add --repo k.renner/homelab \
|
||||
# woodpecker repo cron add --repo konairius/homelab \
|
||||
# --name issue-fix --branch master --schedule '41 * * * *'
|
||||
# - deploy : woodpecker-cli pipeline deploy k.renner/homelab <n> issue-fix
|
||||
# - deploy : woodpecker-cli pipeline deploy konairius/homelab <n> issue-fix
|
||||
# where <n> is an existing pipeline to promote.
|
||||
when:
|
||||
- event: cron
|
||||
@@ -62,7 +62,7 @@ steps:
|
||||
- bash scripts/ci/make-diag-kubeconfig.sh
|
||||
|
||||
- name: issue-fix-agent
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.302
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.303
|
||||
environment:
|
||||
CLAUDE_CODE_OAUTH_TOKEN:
|
||||
from_secret: claude_code_oauth_token
|
||||
@@ -82,7 +82,7 @@ steps:
|
||||
set -eu
|
||||
TRUSTED_AUTHORS="k.renner" # space-separated; the trust anchor (see header)
|
||||
mkdir -p /tmp/alert-fix
|
||||
API="https://gitea.renner.uno/api/v1/repos/k.renner/homelab"
|
||||
API="https://gitea.renner.uno/api/v1/repos/konairius/homelab"
|
||||
curl -sfL "$$API/issues?state=open&type=issues&limit=100" \
|
||||
| jq '[.[] | select(.title | startswith("[alert] "))] | sort_by(.number)' \
|
||||
> /tmp/alert-fix/candidates.json
|
||||
@@ -163,7 +163,7 @@ steps:
|
||||
# run skips this issue, and stop cleanly.
|
||||
[ -f /tmp/alert-fix/issue.json ] || exit 0
|
||||
set -eu
|
||||
API="https://gitea.renner.uno/api/v1/repos/k.renner/homelab"
|
||||
API="https://gitea.renner.uno/api/v1/repos/konairius/homelab"
|
||||
NUM=$$(jq -r '.number' /tmp/alert-fix/issue.json)
|
||||
if git diff --quiet && git diff --cached --quiet \
|
||||
&& [ -z "$$(git ls-files --others --exclude-standard)" ]; then
|
||||
@@ -187,7 +187,7 @@ steps:
|
||||
[ -f /tmp/alert-fix/issue.json ] || exit 0
|
||||
if [ -f /tmp/alert-fix/no-edits ]; then exit 0; fi
|
||||
set -eu
|
||||
API="https://gitea.renner.uno/api/v1/repos/k.renner/homelab"
|
||||
API="https://gitea.renner.uno/api/v1/repos/konairius/homelab"
|
||||
NUM=$$(jq -r '.number' /tmp/alert-fix/issue.json)
|
||||
BRANCH=$$(cat /tmp/alert-fix/branch)
|
||||
TITLE="fix: $$(jq -r '.title' /tmp/alert-fix/issue.json) (#$$NUM)"
|
||||
@@ -196,7 +196,7 @@ steps:
|
||||
printf '\n\nCloses #%s\n' "$$NUM" >> /tmp/alert-fix/summary.md
|
||||
git add -A
|
||||
git commit -m "$$TITLE"
|
||||
git push "https://$$PUSH_TOKEN@gitea.renner.uno/k.renner/homelab.git" HEAD:"$$BRANCH"
|
||||
git push "https://$$PUSH_TOKEN@gitea.renner.uno/konairius/homelab.git" HEAD:"$$BRANCH"
|
||||
jq -n --arg title "$$TITLE" --rawfile body /tmp/alert-fix/summary.md \
|
||||
--arg head "$$BRANCH" \
|
||||
'{title: $$title, body: $$body, head: $$head, base: "master"}' > /tmp/gitea-pr.json
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
# pull requests. This pipeline only starts when triggered by hand and is kept for reference.
|
||||
#
|
||||
# PR review bot — per-repo STUB. All logic lives centrally in
|
||||
# k.renner/homelab: ci/agents/pr-review/run.sh (plus prompt.md,
|
||||
# konairius/homelab: ci/agents/pr-review/run.sh (plus prompt.md,
|
||||
# settings.json and scripts/ci/). This file is deployed byte-identical into every
|
||||
# reviewed repo (k.renner/homelab, shelly-manager, authentik-samba-bridge,
|
||||
# reviewed repo (konairius/homelab, shelly-manager, authentik-samba-bridge,
|
||||
# homeassistant) and holds NO logic, so the copies cannot drift apart: change the
|
||||
# review in homelab and every repo picks it up on its next PR event. Only edit this
|
||||
# stub if the trigger, the image or the secrets change — then change all copies in
|
||||
@@ -43,5 +43,5 @@ steps:
|
||||
from_secret: review_bot_token
|
||||
commands:
|
||||
- rm -rf /tmp/pr-review-central
|
||||
- git clone --quiet --depth 1 https://gitea.renner.uno/k.renner/homelab /tmp/pr-review-central
|
||||
- git clone --quiet --depth 1 https://gitea.renner.uno/konairius/homelab /tmp/pr-review-central
|
||||
- sh /tmp/pr-review-central/ci/agents/pr-review/run.sh
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# - cron : the named `daily-security-scan` cron, matched by NAME.
|
||||
# Crons cannot carry variables — `woodpecker-cli repo cron add`
|
||||
# has no --var — so a variable gate can never match a cron.
|
||||
# - deployment : woodpecker-cli pipeline deploy k.renner/homelab <n> security-scan
|
||||
# - deployment : woodpecker-cli pipeline deploy konairius/homelab <n> security-scan
|
||||
# The target rides in pipeline metadata as
|
||||
# CI_PIPELINE_DEPLOY_TARGET, which the workflow-root filter
|
||||
# does see, unlike manual-run variables on Woodpecker 3.18
|
||||
@@ -18,12 +18,12 @@ when:
|
||||
|
||||
steps:
|
||||
- name: iac-scan
|
||||
image: aquasec/trivy:latest@sha256:af6acf9a6b85dfe389a1941505c0ce9efef52a4719635e1a962f022a3d855daa
|
||||
image: aquasec/trivy:latest@sha256:2416d1f74045b57274df1a340eee6b510cacd73c66a6ccb8da092b117eb7d222
|
||||
commands:
|
||||
- trivy fs --format json -o trivy-iac.json --severity CRITICAL,HIGH --ignore-unfixed .
|
||||
|
||||
- name: image-scans
|
||||
image: aquasec/trivy:latest@sha256:af6acf9a6b85dfe389a1941505c0ce9efef52a4719635e1a962f022a3d855daa
|
||||
image: aquasec/trivy:latest@sha256:2416d1f74045b57274df1a340eee6b510cacd73c66a6ccb8da092b117eb7d222
|
||||
commands:
|
||||
- trivy image --download-db-only
|
||||
- |
|
||||
@@ -70,7 +70,7 @@ steps:
|
||||
# this step is skipped — otherwise we'd fork-bomb PRs.
|
||||
when:
|
||||
- branch: master
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.302
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.303
|
||||
environment:
|
||||
CLAUDE_CODE_OAUTH_TOKEN:
|
||||
from_secret: claude_code_oauth_token
|
||||
@@ -147,7 +147,7 @@ steps:
|
||||
TITLE="security: agent-proposed fixes ($$(date +%Y-%m-%d))"
|
||||
git add -A
|
||||
git commit -m "$$TITLE"
|
||||
git push "https://$$GITEA_TOKEN@gitea.renner.uno/k.renner/homelab.git" HEAD:"$$BRANCH"
|
||||
git push "https://$$GITEA_TOKEN@gitea.renner.uno/konairius/homelab.git" HEAD:"$$BRANCH"
|
||||
jq -n \
|
||||
--arg title "$$TITLE" \
|
||||
--arg body "$$BODY" \
|
||||
@@ -157,5 +157,5 @@ steps:
|
||||
-H "Authorization: token $$GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data @- \
|
||||
https://gitea.renner.uno/api/v1/repos/k.renner/homelab/pulls \
|
||||
https://gitea.renner.uno/api/v1/repos/konairius/homelab/pulls \
|
||||
| jq '{number, html_url, title}'
|
||||
|
||||
@@ -14,9 +14,9 @@
|
||||
# `repo cron add` takes name, branch, schedule and enabled — so the
|
||||
# name filter is the only thing that can select a workflow for a
|
||||
# scheduled run:
|
||||
# woodpecker repo cron add --repo k.renner/homelab \
|
||||
# woodpecker repo cron add --repo konairius/homelab \
|
||||
# --name update-audit --branch master --schedule '@weekly'
|
||||
# - deploy : woodpecker-cli pipeline deploy k.renner/homelab <n> update-audit
|
||||
# - deploy : woodpecker-cli pipeline deploy konairius/homelab <n> update-audit
|
||||
# where <n> is an existing pipeline to promote.
|
||||
#
|
||||
# NOTE: e2e.yml runs on an explicit list of cron names that includes this one,
|
||||
@@ -30,7 +30,7 @@ when:
|
||||
|
||||
steps:
|
||||
- name: update-audit-agent
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.302
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.303
|
||||
environment:
|
||||
CLAUDE_CODE_OAUTH_TOKEN:
|
||||
from_secret: claude_code_oauth_token
|
||||
@@ -111,7 +111,7 @@ steps:
|
||||
set -eu
|
||||
TITLE="Infrastructure update audit"
|
||||
STAMP=$$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
API="https://gitea.renner.uno/api/v1/repos/k.renner/homelab"
|
||||
API="https://gitea.renner.uno/api/v1/repos/konairius/homelab"
|
||||
|
||||
# write <method> <url> <payload-file> -> prints HTTP code, fails non-2xx
|
||||
gitea_write() {
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
# Prerequisites:
|
||||
# - netbird-server play applied (docker, Traefik on netbird_netbird)
|
||||
# - OpenBao homelab/infrastructure/uptime-kuma with keys username, password
|
||||
# - OpenBao homelab/infrastructure/uptime-kuma/smtp with keys server, port, username, token
|
||||
# - homelab/infrastructure/uptime-kuma-metrics (key api_key) is written by the role
|
||||
# - VAULT_TOKEN in the environment (policy: uptime-kuma-vps)
|
||||
# - DNS for kuma.renner.uno pointing at the VPS
|
||||
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
---
|
||||
uptime_kuma_version: "2.5.6"
|
||||
uptime_kuma_host: kuma.renner.uno
|
||||
# Not routed yet; reserved for the public status page.
|
||||
uptime_kuma_base_url: "https://{{ uptime_kuma_host }}"
|
||||
# Custom domain of the status page; not routed by Traefik yet.
|
||||
uptime_kuma_status_host: uptime.renner.uno
|
||||
uptime_kuma_status_slug: renner
|
||||
uptime_kuma_status_title: Renner
|
||||
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
|
||||
|
||||
@@ -1,46 +1,225 @@
|
||||
// Creates the Uptime Kuma admin user on first start; exits 0 if already set up.
|
||||
// Credentials come from KUMA_SETUP_USERNAME / KUMA_SETUP_PASSWORD only.
|
||||
// Bootstraps Uptime Kuma: admin user, general settings, SMTP notification, status page,
|
||||
// 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>".
|
||||
const fs = require("fs");
|
||||
const { io } = require("socket.io-client");
|
||||
|
||||
const username = process.env.KUMA_SETUP_USERNAME;
|
||||
const password = process.env.KUMA_SETUP_PASSWORD;
|
||||
if (!username || !password) {
|
||||
console.error("KUMA_SETUP_USERNAME and KUMA_SETUP_PASSWORD must be set");
|
||||
const KUMA_URL = "http://127.0.0.1:3001";
|
||||
const EMIT_TIMEOUT_MS = 30000;
|
||||
const TOTAL_TIMEOUT_MS = 180000;
|
||||
// Defaults the Kuma settings page applies to unset keys before saving (src/pages/Settings.vue).
|
||||
const SETTINGS_DEFAULTS = {
|
||||
checkUpdate: true,
|
||||
searchEngineIndex: false,
|
||||
entryPage: "dashboard",
|
||||
nscd: true,
|
||||
keepDataPeriodDays: 180,
|
||||
tlsExpiryNotifyDays: [7, 14, 21],
|
||||
domainExpiryNotifyDays: [7, 14, 21],
|
||||
trustProxy: false,
|
||||
};
|
||||
|
||||
const REQUIRED = [
|
||||
"KUMA_SETUP_USERNAME",
|
||||
"KUMA_SETUP_PASSWORD",
|
||||
"KUMA_BASE_URL",
|
||||
"KUMA_SMTP_HOST",
|
||||
"KUMA_SMTP_PORT",
|
||||
"KUMA_SMTP_USERNAME",
|
||||
"KUMA_SMTP_PASSWORD",
|
||||
"KUMA_SMTP_FROM",
|
||||
"KUMA_SMTP_TO",
|
||||
"KUMA_STATUS_SLUG",
|
||||
"KUMA_STATUS_TITLE",
|
||||
"KUMA_STATUS_DOMAIN",
|
||||
];
|
||||
const missing = REQUIRED.filter((name) => !process.env[name]);
|
||||
const rotateMetricsKey = process.env.KUMA_METRICS_ROTATE === "true";
|
||||
if (rotateMetricsKey && !process.env.KUMA_METRICS_KEY_FILE) {
|
||||
missing.push("KUMA_METRICS_KEY_FILE");
|
||||
}
|
||||
if (missing.length > 0) {
|
||||
console.error(`missing environment variables: ${missing.join(", ")}`);
|
||||
process.exit(2);
|
||||
}
|
||||
const env = process.env;
|
||||
|
||||
const socket = io("http://127.0.0.1:3001", { transports: ["websocket"], reconnection: false });
|
||||
const timer = setTimeout(() => {
|
||||
console.error("timeout: no response from Uptime Kuma within 30s");
|
||||
process.exit(1);
|
||||
}, 30000);
|
||||
const socket = io(KUMA_URL, { transports: ["websocket"], reconnection: false });
|
||||
const lists = {};
|
||||
const LIST_EVENTS = ["notificationList", "apiKeyList", "statusPageList"];
|
||||
for (const event of LIST_EVENTS) {
|
||||
socket.on(event, (data) => {
|
||||
lists[event] = data;
|
||||
});
|
||||
}
|
||||
|
||||
let finished = false;
|
||||
function done(code, msg) {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
clearTimeout(timer);
|
||||
(code === 0 ? console.log : console.error)(msg);
|
||||
clearTimeout(watchdog);
|
||||
if (msg) (code === 0 ? console.log : console.error)(msg);
|
||||
socket.close();
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
const watchdog = setTimeout(() => done(1, `timeout: bootstrap did not finish within ${TOTAL_TIMEOUT_MS / 1000}s`), TOTAL_TIMEOUT_MS);
|
||||
socket.on("connect_error", (err) => done(1, `connect_error: ${err.message}`));
|
||||
socket.on("disconnect", (reason) => done(1, `disconnected before setup finished: ${reason}`));
|
||||
socket.on("disconnect", (reason) => done(1, `disconnected before bootstrap finished: ${reason}`));
|
||||
|
||||
// Kuma registers socket handlers only after emitting info.
|
||||
socket.once("info", () => {
|
||||
socket.emit("needSetup", (needSetup) => {
|
||||
if (!needSetup) {
|
||||
done(0, "already set up");
|
||||
return;
|
||||
}
|
||||
socket.emit("setup", username, password, (res) => {
|
||||
if (res && res.ok) {
|
||||
done(0, "admin user created");
|
||||
} else {
|
||||
done(1, `setup failed: ${res && res.msg ? res.msg : JSON.stringify(res)}`);
|
||||
}
|
||||
function call(event, ...args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error(`timeout waiting for ${event}`)), EMIT_TIMEOUT_MS);
|
||||
socket.emit(event, ...args, (res) => {
|
||||
clearTimeout(timer);
|
||||
resolve(res);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function callOk(event, ...args) {
|
||||
const res = await call(event, ...args);
|
||||
if (!res || !res.ok) {
|
||||
throw new Error(`${event} failed: ${res && res.msg ? res.msg : JSON.stringify(res)}`);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
function report(step, changed) {
|
||||
console.log(`${changed ? "changed" : "ok"}: ${step}`);
|
||||
}
|
||||
|
||||
// Kuma registers socket handlers only after emitting info.
|
||||
function waitForInfo() {
|
||||
return new Promise((resolve) => socket.once("info", resolve));
|
||||
}
|
||||
|
||||
async function setupAdmin() {
|
||||
const needSetup = await call("needSetup");
|
||||
if (!needSetup) {
|
||||
console.log("already set up");
|
||||
return;
|
||||
}
|
||||
await callOk("setup", env.KUMA_SETUP_USERNAME, env.KUMA_SETUP_PASSWORD);
|
||||
console.log("admin user created");
|
||||
}
|
||||
|
||||
async function login() {
|
||||
await callOk("login", { username: env.KUMA_SETUP_USERNAME, password: env.KUMA_SETUP_PASSWORD, token: "" });
|
||||
// afterLogin sends the lists before the login ack but swallows send failures.
|
||||
for (const event of LIST_EVENTS) {
|
||||
if (lists[event] === undefined) {
|
||||
throw new Error(`${event} not received after login`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureSettings() {
|
||||
const { data } = await callOk("getSettings");
|
||||
if (data.trustProxy === true && data.primaryBaseURL === env.KUMA_BASE_URL) {
|
||||
report("settings", false);
|
||||
return;
|
||||
}
|
||||
const updated = { ...SETTINGS_DEFAULTS, ...data, trustProxy: true, primaryBaseURL: env.KUMA_BASE_URL };
|
||||
await callOk("setSettings", updated, "");
|
||||
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 fetchPublicGroupList(slug) {
|
||||
const res = await fetch(`${KUMA_URL}/api/status-page/${encodeURIComponent(slug)}`);
|
||||
if (!res.ok) {
|
||||
throw new Error(`GET /api/status-page/${slug} returned ${res.status}`);
|
||||
}
|
||||
const body = await res.json();
|
||||
return (body.publicGroupList || []).map((group) => ({
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
monitorList: (group.monitorList || []).map((monitor) => {
|
||||
const entry = { id: monitor.id };
|
||||
if (monitor.sendUrl !== undefined) entry.sendUrl = monitor.sendUrl;
|
||||
if (monitor.url !== undefined) entry.url = monitor.url;
|
||||
return entry;
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
async function ensureStatusPage() {
|
||||
const slug = env.KUMA_STATUS_SLUG;
|
||||
let changed = false;
|
||||
const pages = Object.values(lists.statusPageList || {});
|
||||
if (!pages.some((page) => page.slug === slug)) {
|
||||
await callOk("addStatusPage", env.KUMA_STATUS_TITLE, slug);
|
||||
changed = true;
|
||||
}
|
||||
const { config } = await callOk("getStatusPage", slug);
|
||||
const domains = config.domainNameList || [];
|
||||
if (config.title !== env.KUMA_STATUS_TITLE || !domains.includes(env.KUMA_STATUS_DOMAIN)) {
|
||||
// saveStatusPage replaces all groups, so the current ones are sent back unchanged.
|
||||
const publicGroupList = await fetchPublicGroupList(slug);
|
||||
const updated = {
|
||||
...config,
|
||||
title: env.KUMA_STATUS_TITLE,
|
||||
domainNameList: domains.includes(env.KUMA_STATUS_DOMAIN) ? domains : [...domains, env.KUMA_STATUS_DOMAIN],
|
||||
};
|
||||
await callOk("saveStatusPage", slug, updated, config.icon, publicGroupList);
|
||||
changed = true;
|
||||
}
|
||||
report("status page", changed);
|
||||
}
|
||||
|
||||
async function rotateMetricsApiKey() {
|
||||
if (!rotateMetricsKey) {
|
||||
report("metrics api key", false);
|
||||
return;
|
||||
}
|
||||
for (const key of (lists.apiKeyList || []).filter((k) => k.name === "prometheus")) {
|
||||
await callOk("deleteAPIKey", key.id);
|
||||
}
|
||||
const res = await callOk("addAPIKey", { name: "prometheus", expires: null, active: 1 });
|
||||
fs.writeFileSync(env.KUMA_METRICS_KEY_FILE, res.key, { mode: 0o600 });
|
||||
report("metrics api key", true);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await waitForInfo();
|
||||
await setupAdmin();
|
||||
await login();
|
||||
await ensureSettings();
|
||||
await ensureNotification();
|
||||
await ensureStatusPage();
|
||||
await rotateMetricsApiKey();
|
||||
}
|
||||
|
||||
main().then(
|
||||
() => done(0),
|
||||
(err) => done(1, err.message)
|
||||
);
|
||||
|
||||
@@ -14,6 +14,33 @@
|
||||
register: _uptime_kuma_secret
|
||||
no_log: true
|
||||
|
||||
- name: Read SMTP credentials from OpenBao
|
||||
community.hashi_vault.vault_kv2_get:
|
||||
url: "{{ uptime_kuma_openbao_url }}"
|
||||
engine_mount_point: "{{ uptime_kuma_openbao_mount }}"
|
||||
path: "{{ uptime_kuma_openbao_smtp_path }}"
|
||||
auth_method: token
|
||||
delegate_to: localhost
|
||||
become: false
|
||||
run_once: true
|
||||
register: _uptime_kuma_smtp
|
||||
no_log: true
|
||||
|
||||
- name: Read metrics API key from OpenBao
|
||||
community.hashi_vault.vault_kv2_get:
|
||||
url: "{{ uptime_kuma_openbao_url }}"
|
||||
engine_mount_point: "{{ uptime_kuma_openbao_mount }}"
|
||||
path: "{{ uptime_kuma_openbao_metrics_path }}"
|
||||
auth_method: token
|
||||
delegate_to: localhost
|
||||
become: false
|
||||
run_once: true
|
||||
register: _uptime_kuma_metrics_secret
|
||||
failed_when:
|
||||
- _uptime_kuma_metrics_secret.failed | default(false)
|
||||
- "'Invalid or missing path' not in (_uptime_kuma_metrics_secret.msg | default(''))"
|
||||
no_log: true
|
||||
|
||||
- name: Ensure Uptime Kuma directories exist
|
||||
ansible.builtin.file:
|
||||
path: "{{ item }}"
|
||||
@@ -22,11 +49,12 @@
|
||||
loop:
|
||||
- "{{ uptime_kuma_dir }}"
|
||||
- "{{ uptime_kuma_dir }}/data"
|
||||
- "{{ uptime_kuma_dir }}/setup"
|
||||
|
||||
- name: Deploy kuma-setup.js
|
||||
ansible.builtin.copy:
|
||||
src: kuma-setup.js
|
||||
dest: "{{ uptime_kuma_dir }}/kuma-setup.js"
|
||||
dest: "{{ uptime_kuma_dir }}/setup/kuma-setup.js"
|
||||
mode: "0644"
|
||||
|
||||
- name: Check for setup marker
|
||||
@@ -54,6 +82,11 @@
|
||||
chdir: "{{ uptime_kuma_dir }}"
|
||||
changed_when: false
|
||||
|
||||
- name: Remove the script from its former single-file mount path
|
||||
ansible.builtin.file:
|
||||
path: "{{ uptime_kuma_dir }}/kuma-setup.js"
|
||||
state: absent
|
||||
|
||||
- name: Wait for Uptime Kuma on loopback
|
||||
ansible.builtin.uri:
|
||||
url: http://127.0.0.1:3001
|
||||
@@ -63,15 +96,80 @@
|
||||
retries: 30
|
||||
delay: 5
|
||||
|
||||
- name: Create the admin user if Uptime Kuma is not set up
|
||||
ansible.builtin.command: docker compose exec -T -e KUMA_SETUP_USERNAME -e KUMA_SETUP_PASSWORD uptime-kuma node /app/kuma-setup.js
|
||||
- name: Check the stored metrics API key against Uptime Kuma
|
||||
ansible.builtin.uri:
|
||||
url: http://127.0.0.1:3001/metrics
|
||||
headers:
|
||||
Authorization: "Basic {{ (':' ~ _uptime_kuma_metrics_key) | b64encode }}"
|
||||
status_code: [200, 401]
|
||||
vars:
|
||||
_uptime_kuma_metrics_key: "{{ (_uptime_kuma_metrics_secret.secret | default({})).api_key | default('') }}"
|
||||
when: _uptime_kuma_metrics_key | length > 0
|
||||
register: _uptime_kuma_metrics_check
|
||||
no_log: true
|
||||
|
||||
- name: Remove a leftover metrics API key handoff file
|
||||
ansible.builtin.file:
|
||||
path: "{{ uptime_kuma_dir }}/data/.metrics-api-key"
|
||||
state: absent
|
||||
|
||||
- name: Bootstrap Uptime Kuma
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- docker
|
||||
- compose
|
||||
- exec
|
||||
- -T
|
||||
- -e
|
||||
- KUMA_SETUP_USERNAME
|
||||
- -e
|
||||
- KUMA_SETUP_PASSWORD
|
||||
- -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_STATUS_SLUG
|
||||
- -e
|
||||
- KUMA_STATUS_TITLE
|
||||
- -e
|
||||
- KUMA_STATUS_DOMAIN
|
||||
- -e
|
||||
- KUMA_METRICS_ROTATE
|
||||
- -e
|
||||
- KUMA_METRICS_KEY_FILE
|
||||
- uptime-kuma
|
||||
- node
|
||||
- /app/setup/kuma-setup.js
|
||||
args:
|
||||
chdir: "{{ uptime_kuma_dir }}"
|
||||
environment:
|
||||
KUMA_SETUP_USERNAME: "{{ _uptime_kuma_secret.secret.username }}"
|
||||
KUMA_SETUP_PASSWORD: "{{ _uptime_kuma_secret.secret.password }}"
|
||||
KUMA_BASE_URL: "{{ uptime_kuma_base_url }}"
|
||||
KUMA_SMTP_HOST: "{{ _uptime_kuma_smtp.secret.server }}"
|
||||
KUMA_SMTP_PORT: "{{ _uptime_kuma_smtp.secret.port }}"
|
||||
KUMA_SMTP_USERNAME: "{{ _uptime_kuma_smtp.secret.username }}"
|
||||
KUMA_SMTP_PASSWORD: "{{ _uptime_kuma_smtp.secret.token }}"
|
||||
KUMA_SMTP_FROM: "{{ uptime_kuma_smtp_from }}"
|
||||
KUMA_SMTP_TO: "{{ uptime_kuma_smtp_to }}"
|
||||
KUMA_STATUS_SLUG: "{{ uptime_kuma_status_slug }}"
|
||||
KUMA_STATUS_TITLE: "{{ uptime_kuma_status_title }}"
|
||||
KUMA_STATUS_DOMAIN: "{{ uptime_kuma_status_host }}"
|
||||
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
|
||||
changed_when: "'admin user created' in _uptime_kuma_setup.stdout"
|
||||
changed_when: "'admin user created' in _uptime_kuma_setup.stdout or 'changed:' in _uptime_kuma_setup.stdout"
|
||||
failed_when: false
|
||||
no_log: true
|
||||
|
||||
@@ -80,6 +178,33 @@
|
||||
msg: "kuma-setup.js failed: rc={{ _uptime_kuma_setup.rc | default('undefined') }} stdout={{ _uptime_kuma_setup.stdout | default('') }} stderr={{ _uptime_kuma_setup.stderr | default('') }}"
|
||||
when: (_uptime_kuma_setup.rc | default(1)) != 0
|
||||
|
||||
- name: Store the new metrics API key in OpenBao
|
||||
when: "'changed: metrics api key' in _uptime_kuma_setup.stdout"
|
||||
block:
|
||||
- name: Read the new metrics API key
|
||||
ansible.builtin.slurp:
|
||||
src: "{{ uptime_kuma_dir }}/data/.metrics-api-key"
|
||||
register: _uptime_kuma_new_metrics_key
|
||||
no_log: true
|
||||
|
||||
- name: Write the metrics API key to OpenBao
|
||||
community.hashi_vault.vault_kv2_write:
|
||||
url: "{{ uptime_kuma_openbao_url }}"
|
||||
engine_mount_point: "{{ uptime_kuma_openbao_mount }}"
|
||||
path: "{{ uptime_kuma_openbao_metrics_path }}"
|
||||
auth_method: token
|
||||
data:
|
||||
api_key: "{{ _uptime_kuma_new_metrics_key.content | b64decode }}"
|
||||
delegate_to: localhost
|
||||
become: false
|
||||
run_once: true
|
||||
no_log: true
|
||||
always:
|
||||
- name: Remove the metrics API key handoff file
|
||||
ansible.builtin.file:
|
||||
path: "{{ uptime_kuma_dir }}/data/.metrics-api-key"
|
||||
state: absent
|
||||
|
||||
- name: Record setup completion
|
||||
ansible.builtin.copy:
|
||||
content: ""
|
||||
|
||||
@@ -9,7 +9,8 @@ services:
|
||||
- "127.0.0.1:3001:3001"
|
||||
volumes:
|
||||
- {{ uptime_kuma_dir }}/data:/app/data
|
||||
- {{ uptime_kuma_dir }}/kuma-setup.js:/app/kuma-setup.js:ro
|
||||
# Directory mount: a single-file bind mount keeps the inode from container start.
|
||||
- {{ uptime_kuma_dir }}/setup:/app/setup:ro
|
||||
labels:
|
||||
- "traefik.enable={{ uptime_kuma_route_enabled | lower }}"
|
||||
- "traefik.http.routers.uptime-kuma.rule=Host(`{{ uptime_kuma_host }}`)"
|
||||
@@ -18,8 +19,12 @@ services:
|
||||
- "traefik.http.routers.uptime-kuma.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.routers.uptime-kuma.service=uptime-kuma"
|
||||
- "traefik.http.services.uptime-kuma.loadbalancer.server.port=3001"
|
||||
- "traefik.docker.network={{ uptime_kuma_traefik_network }}"
|
||||
networks:
|
||||
- traefik
|
||||
kuma:
|
||||
gw_priority: 100
|
||||
traefik:
|
||||
gw_priority: 0
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
@@ -27,6 +32,8 @@ services:
|
||||
max-file: "2"
|
||||
|
||||
networks:
|
||||
kuma:
|
||||
enable_ipv6: true
|
||||
traefik:
|
||||
external: true
|
||||
name: {{ uptime_kuma_traefik_network }}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/bin/sh
|
||||
# PR review bot — the ONE implementation, run by every reviewed repo's
|
||||
# .woodpecker/pr-review.yml stub (k.renner/homelab, shelly-manager,
|
||||
# .woodpecker/pr-review.yml stub (konairius/homelab, shelly-manager,
|
||||
# authentik-samba-bridge, homeassistant). Edit it here only; the stubs hold no
|
||||
# logic. Design notes (event-driven trigger, dedup marker, tokens, model
|
||||
# selection, posture) are in the stub and in prompt.md.
|
||||
@@ -129,7 +129,7 @@ while IFS= read -r ITEM; do
|
||||
# # Cross-repo context for the review agent: shallow, UNAUTHENTICATED clones of
|
||||
# # the other fleet repos at ./.pr-review/context/<name>.
|
||||
# CTX="$D/context"; mkdir -p "$CTX"
|
||||
# for REPO in k.renner/homelab k.renner/homeassistant konairius/shelly-manager k.renner/authentik-samba-bridge; do
|
||||
# for REPO in konairius/homelab konairius/homeassistant konairius/shelly-manager konairius/authentik-samba-bridge; do
|
||||
# [ "$REPO" = "$SLUG" ] && continue
|
||||
# GIT_TERMINAL_PROMPT=0 git clone --quiet --depth 1 "https://gitea.renner.uno/$REPO" "$CTX/${REPO#*/}" 2>/dev/null \
|
||||
# || echo "No public context clone of $REPO (skipped)."
|
||||
|
||||
@@ -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.14-slim@sha256:a2b82f3c48559aa0a8446d9af49826b6e2b2016f4cd2afabfe6013ec53729170 AS test
|
||||
FROM python:3.15-slim@sha256:2da676046630d7bf14c6764ae1143dc7fa0377a53f94bf5a642f0b214aca5bce AS test
|
||||
WORKDIR /src
|
||||
COPY controller.py test_controller.py ./
|
||||
RUN python3 -B -m unittest -v
|
||||
|
||||
FROM python:3.14-slim@sha256:a2b82f3c48559aa0a8446d9af49826b6e2b2016f4cd2afabfe6013ec53729170
|
||||
FROM python:3.15-slim@sha256:2da676046630d7bf14c6764ae1143dc7fa0377a53f94bf5a642f0b214aca5bce
|
||||
|
||||
RUN useradd --system --no-create-home --shell /usr/sbin/nologin controller
|
||||
COPY --from=test /src/controller.py /opt/controller.py
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1
|
||||
FROM node:24-alpine@sha256:44fce696f38ba8c987c4db89954051000bbd05ffd8d8659ba15ed4a8602e937f
|
||||
|
||||
# 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
|
||||
|
||||
@@ -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.14-slim@sha256:a2b82f3c48559aa0a8446d9af49826b6e2b2016f4cd2afabfe6013ec53729170 AS build
|
||||
FROM python:3.15-slim@sha256:2da676046630d7bf14c6764ae1143dc7fa0377a53f94bf5a642f0b214aca5bce AS build
|
||||
|
||||
# Pins live here, in the artifact, rather than in Ansible variables.
|
||||
#
|
||||
@@ -56,14 +56,14 @@ RUN git clone --quiet https://github.com/MyHomeMyData/E3onCAN.git /opt/e3oncan \
|
||||
&& rm -rf /opt/e3oncan/.git
|
||||
|
||||
|
||||
FROM python:3.14-slim@sha256:a2b82f3c48559aa0a8446d9af49826b6e2b2016f4cd2afabfe6013ec53729170
|
||||
FROM python:3.15-slim@sha256:2da676046630d7bf14c6764ae1143dc7fa0377a53f94bf5a642f0b214aca5bce
|
||||
|
||||
ARG OPEN3E_REF=6553c10453a6bae1210908f38a5af2481a46a3e5
|
||||
ARG E3ONCAN_REF=461dcbdf97e8d72d2edb9929c63b39382af6c3a1
|
||||
|
||||
LABEL org.opencontainers.image.title="heating-can" \
|
||||
org.opencontainers.image.description="open3e + E3onCAN, Viessmann E3 CAN bus readers" \
|
||||
org.opencontainers.image.source="https://gitea.renner.uno/k.renner/homelab" \
|
||||
org.opencontainers.image.source="https://gitea.renner.uno/konairius/homelab" \
|
||||
uno.renner.open3e.ref="${OPEN3E_REF}" \
|
||||
uno.renner.e3oncan.ref="${E3ONCAN_REF}"
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
FROM nousresearch/hermes-agent:v0.22.2@sha256:36099926da316742423544e055d4734777105ffe6341873af5459b957d0c2919
|
||||
|
||||
USER root
|
||||
|
||||
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.13.10@sha256:3116f64c97ddffb53bd3c5bf87c8614d99f04df4326177dbbb7702b5087f0e4d /talosctl /usr/local/bin/talosctl
|
||||
COPY --from=alpine/kubectl:1.36.3@sha256:dae261106d5555008acede256b7102c8a6decde7209198434f5999f051460f57 /usr/local/bin/kubectl /usr/local/bin/kubectl
|
||||
COPY --from=ghcr.io/fluxcd/flux-cli:v2.9.5@sha256:704d5529535570a495249b643159c93a9aa88ffca07813c974a54b7ba6edbd86 /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 \
|
||||
&& kubectl version --client \
|
||||
&& flux version --client \
|
||||
&& bao version \
|
||||
&& tea --version \
|
||||
&& jq --version
|
||||
@@ -0,0 +1 @@
|
||||
bin/
|
||||
@@ -0,0 +1 @@
|
||||
/bin/
|
||||
@@ -0,0 +1,15 @@
|
||||
# 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:820153096fda4de6ac1b7194d12e416b7dd8f4e9a33d17e0345b111b945162d8 AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
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:e2e927ec666bae08560abb3c55d0659eceabb657f56b6782ab500a9fc7f555e3
|
||||
COPY --from=build /out/kuma-sync /kuma-sync
|
||||
USER 65532
|
||||
ENTRYPOINT ["/kuma-sync"]
|
||||
@@ -0,0 +1,29 @@
|
||||
CONTROLLER_GEN_VERSION := v0.23.0
|
||||
ENVTEST_VERSION := v0.25.2
|
||||
ENVTEST_K8S_VERSION := 1.37.0
|
||||
|
||||
BIN := $(CURDIR)/bin
|
||||
CONTROLLER_GEN := $(BIN)/controller-gen
|
||||
SETUP_ENVTEST := $(BIN)/setup-envtest
|
||||
|
||||
.PHONY: generate test test-envtest test-integration
|
||||
|
||||
$(CONTROLLER_GEN):
|
||||
GOBIN=$(BIN) go install sigs.k8s.io/controller-tools/cmd/controller-gen@$(CONTROLLER_GEN_VERSION)
|
||||
|
||||
$(SETUP_ENVTEST):
|
||||
GOBIN=$(BIN) go install sigs.k8s.io/controller-runtime/tools/setup-envtest@$(ENVTEST_VERSION)
|
||||
|
||||
generate: $(CONTROLLER_GEN)
|
||||
$(CONTROLLER_GEN) object paths=./api/...
|
||||
$(CONTROLLER_GEN) crd paths=./api/... output:crd:artifacts:config=config/crd
|
||||
$(CONTROLLER_GEN) rbac:roleName=kuma-sync paths=./internal/... output:rbac:artifacts:config=config/rbac
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
test-integration: $(SETUP_ENVTEST)
|
||||
KUBEBUILDER_ASSETS="$$($(SETUP_ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(BIN)/k8s -p path)" go test -tags integration ./...
|
||||
|
||||
test-envtest:$(SETUP_ENVTEST)
|
||||
KUBEBUILDER_ASSETS="$$($(SETUP_ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(BIN)/k8s -p path)" go test -tags envtest ./...
|
||||
@@ -0,0 +1,15 @@
|
||||
// +kubebuilder:object:generate=true
|
||||
// +groupName=uptime.renner.uno
|
||||
|
||||
package v1alpha1
|
||||
|
||||
import (
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"sigs.k8s.io/controller-runtime/pkg/scheme"
|
||||
)
|
||||
|
||||
var (
|
||||
GroupVersion = schema.GroupVersion{Group: "uptime.renner.uno", Version: "v1alpha1"}
|
||||
SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion}
|
||||
AddToScheme = SchemeBuilder.AddToScheme
|
||||
)
|
||||
@@ -0,0 +1,132 @@
|
||||
package v1alpha1
|
||||
|
||||
import (
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
const ConditionReady = "Ready"
|
||||
|
||||
// LocalRef references a KumaMonitor in the same namespace.
|
||||
type LocalRef struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// HTTPSpec configures http, keyword and json-query monitors.
|
||||
type HTTPSpec struct {
|
||||
// +kubebuilder:validation:MinLength=1
|
||||
URL string `json:"url"`
|
||||
// +kubebuilder:default=GET
|
||||
Method string `json:"method,omitempty"`
|
||||
// +kubebuilder:default={"200-299"}
|
||||
AcceptedStatusCodes []string `json:"acceptedStatusCodes,omitempty"`
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
Body string `json:"body,omitempty"`
|
||||
MaxRedirects *int32 `json:"maxRedirects,omitempty"`
|
||||
IgnoreTLS bool `json:"ignoreTLS,omitempty"`
|
||||
ExpiryNotification bool `json:"expiryNotification,omitempty"`
|
||||
}
|
||||
|
||||
type KeywordSpec struct {
|
||||
// +kubebuilder:validation:MinLength=1
|
||||
Keyword string `json:"keyword"`
|
||||
Invert bool `json:"invert,omitempty"`
|
||||
}
|
||||
|
||||
type JSONQuerySpec struct {
|
||||
// JSONata expression.
|
||||
// +kubebuilder:validation:MinLength=1
|
||||
Expression string `json:"expression"`
|
||||
// +kubebuilder:validation:Enum="==";"!=";"<";"<=";">";">=";contains
|
||||
Operator string `json:"operator"`
|
||||
Expected string `json:"expected"`
|
||||
}
|
||||
|
||||
type DNSSpec struct {
|
||||
// +kubebuilder:validation:MinLength=1
|
||||
Hostname string `json:"hostname"`
|
||||
// +kubebuilder:validation:Enum=A;AAAA;CNAME;MX;NS;PTR;SOA;SRV;TXT;CAA
|
||||
ResolveType string `json:"resolveType"`
|
||||
// +kubebuilder:default="1.1.1.1"
|
||||
ResolveServer string `json:"resolveServer,omitempty"`
|
||||
// +kubebuilder:validation:Minimum=1
|
||||
// +kubebuilder:validation:Maximum=65535
|
||||
// +kubebuilder:default=53
|
||||
Port int32 `json:"port,omitempty"`
|
||||
}
|
||||
|
||||
type PortSpec struct {
|
||||
// +kubebuilder:validation:MinLength=1
|
||||
Hostname string `json:"hostname"`
|
||||
// +kubebuilder:validation:Minimum=1
|
||||
// +kubebuilder:validation:Maximum=65535
|
||||
Port int32 `json:"port"`
|
||||
}
|
||||
|
||||
// +kubebuilder:validation:XValidation:rule="(self.type in ['http','keyword','json-query']) == has(self.http)",message="http is required for types http, keyword and json-query, and forbidden otherwise"
|
||||
// +kubebuilder:validation:XValidation:rule="(self.type == 'keyword') == has(self.keyword)",message="keyword is required for type keyword and forbidden otherwise"
|
||||
// +kubebuilder:validation:XValidation:rule="(self.type == 'json-query') == has(self.jsonQuery)",message="jsonQuery is required for type json-query and forbidden otherwise"
|
||||
// +kubebuilder:validation:XValidation:rule="(self.type == 'dns') == has(self.dns)",message="dns is required for type dns and forbidden otherwise"
|
||||
// +kubebuilder:validation:XValidation:rule="(self.type == 'port') == has(self.port)",message="port is required for type port and forbidden otherwise"
|
||||
// +kubebuilder:validation:XValidation:rule="self.type != 'group' || !(has(self.http) || has(self.keyword) || has(self.jsonQuery) || has(self.dns) || has(self.port))",message="type group takes no type blocks"
|
||||
type KumaMonitorSpec struct {
|
||||
// +kubebuilder:validation:Enum=group;http;keyword;json-query;dns;port
|
||||
Type string `json:"type"`
|
||||
// +kubebuilder:validation:MinLength=1
|
||||
DisplayName string `json:"displayName"`
|
||||
ParentRef *LocalRef `json:"parentRef,omitempty"`
|
||||
|
||||
// Seconds.
|
||||
// +kubebuilder:validation:Minimum=20
|
||||
// +kubebuilder:default=60
|
||||
Interval int32 `json:"interval,omitempty"`
|
||||
// Seconds.
|
||||
// +kubebuilder:validation:Minimum=20
|
||||
// +kubebuilder:default=60
|
||||
RetryInterval int32 `json:"retryInterval,omitempty"`
|
||||
// +kubebuilder:validation:Minimum=0
|
||||
// +kubebuilder:default=0
|
||||
MaxRetries int32 `json:"maxRetries,omitempty"`
|
||||
// Seconds.
|
||||
// +kubebuilder:validation:Minimum=1
|
||||
Timeout *int32 `json:"timeout,omitempty"`
|
||||
Paused bool `json:"paused,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
|
||||
HTTP *HTTPSpec `json:"http,omitempty"`
|
||||
Keyword *KeywordSpec `json:"keyword,omitempty"`
|
||||
JSONQuery *JSONQuerySpec `json:"jsonQuery,omitempty"`
|
||||
DNS *DNSSpec `json:"dns,omitempty"`
|
||||
Port *PortSpec `json:"port,omitempty"`
|
||||
}
|
||||
|
||||
type KumaMonitorStatus struct {
|
||||
MonitorID int64 `json:"monitorID,omitempty"`
|
||||
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
|
||||
// +listType=map
|
||||
// +listMapKey=type
|
||||
Conditions []metav1.Condition `json:"conditions,omitempty"`
|
||||
}
|
||||
|
||||
// +kubebuilder:object:root=true
|
||||
// +kubebuilder:subresource:status
|
||||
// +kubebuilder:resource:path=kumamonitors,scope=Namespaced
|
||||
// +kubebuilder:printcolumn:name="Type",type=string,JSONPath=`.spec.type`
|
||||
// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status`
|
||||
type KumaMonitor struct {
|
||||
metav1.TypeMeta `json:",inline"`
|
||||
metav1.ObjectMeta `json:"metadata,omitempty"`
|
||||
|
||||
Spec KumaMonitorSpec `json:"spec"`
|
||||
Status KumaMonitorStatus `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
// +kubebuilder:object:root=true
|
||||
type KumaMonitorList struct {
|
||||
metav1.TypeMeta `json:",inline"`
|
||||
metav1.ListMeta `json:"metadata,omitempty"`
|
||||
Items []KumaMonitor `json:"items"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
SchemeBuilder.Register(&KumaMonitor{}, &KumaMonitorList{})
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
//go:build envtest
|
||||
|
||||
package v1alpha1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/envtest"
|
||||
)
|
||||
|
||||
var k8s client.Client
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
env := &envtest.Environment{
|
||||
CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd")},
|
||||
ErrorIfCRDPathMissing: true,
|
||||
}
|
||||
cfg, err := env.Start()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
scheme := runtime.NewScheme()
|
||||
if err := AddToScheme(scheme); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
k8s, err = client.New(cfg, client.Options{Scheme: scheme})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
code := m.Run()
|
||||
if err := env.Stop(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if code != 0 {
|
||||
panic("tests failed")
|
||||
}
|
||||
}
|
||||
|
||||
func monitor(name string, spec KumaMonitorSpec) *KumaMonitor {
|
||||
return &KumaMonitor{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default"},
|
||||
Spec: spec,
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidKeyword(t *testing.T) {
|
||||
m := monitor("valid-keyword", KumaMonitorSpec{
|
||||
Type: "keyword",
|
||||
DisplayName: "kw",
|
||||
HTTP: &HTTPSpec{URL: "https://example.com"},
|
||||
Keyword: &KeywordSpec{Keyword: "ok"},
|
||||
})
|
||||
if err := k8s.Create(context.Background(), m); err != nil {
|
||||
t.Fatalf("expected accept, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeywordWithoutBlock(t *testing.T) {
|
||||
m := monitor("kw-no-block", KumaMonitorSpec{
|
||||
Type: "keyword",
|
||||
DisplayName: "kw",
|
||||
HTTP: &HTTPSpec{URL: "https://example.com"},
|
||||
})
|
||||
expectInvalid(t, m, "keyword is required for type keyword")
|
||||
}
|
||||
|
||||
func expectInvalid(t *testing.T, m *KumaMonitor, wants ...string) {
|
||||
t.Helper()
|
||||
err := k8s.Create(context.Background(), m)
|
||||
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 TestGroupWithHTTP(t *testing.T) {
|
||||
m := monitor("group-http", KumaMonitorSpec{
|
||||
Type: "group",
|
||||
DisplayName: "g",
|
||||
HTTP: &HTTPSpec{URL: "https://example.com"},
|
||||
})
|
||||
expectInvalid(t, m, "type group takes no type blocks")
|
||||
}
|
||||
|
||||
func TestDNSWithHTTP(t *testing.T) {
|
||||
m := monitor("dns-http", KumaMonitorSpec{
|
||||
Type: "dns",
|
||||
DisplayName: "d",
|
||||
HTTP: &HTTPSpec{URL: "https://example.com"},
|
||||
DNS: &DNSSpec{Hostname: "example.com", ResolveType: "A"},
|
||||
})
|
||||
expectInvalid(t, m, "http is required for types http, keyword and json-query")
|
||||
}
|
||||
|
||||
func TestPushTypeRejected(t *testing.T) {
|
||||
m := monitor("push", KumaMonitorSpec{Type: "push", DisplayName: "p"})
|
||||
expectInvalid(t, m, "spec.type", "Unsupported value")
|
||||
}
|
||||
|
||||
func TestEmptyURLRejected(t *testing.T) {
|
||||
m := monitor("empty-url", KumaMonitorSpec{
|
||||
Type: "http",
|
||||
DisplayName: "h",
|
||||
HTTP: &HTTPSpec{URL: ""},
|
||||
})
|
||||
expectInvalid(t, m, "spec.http.url")
|
||||
}
|
||||
|
||||
func TestNegativeMaxRetriesRejected(t *testing.T) {
|
||||
m := monitor("neg-retries", KumaMonitorSpec{
|
||||
Type: "http",
|
||||
DisplayName: "h",
|
||||
MaxRetries: -1,
|
||||
HTTP: &HTTPSpec{URL: "https://example.com"},
|
||||
})
|
||||
expectInvalid(t, m, "spec.maxRetries")
|
||||
}
|
||||
|
||||
func TestJSONQueryBadOperator(t *testing.T) {
|
||||
m := monitor("jq-bad-op", KumaMonitorSpec{
|
||||
Type: "json-query",
|
||||
DisplayName: "jq",
|
||||
HTTP: &HTTPSpec{URL: "https://example.com"},
|
||||
JSONQuery: &JSONQuerySpec{Expression: "a", Operator: "=~", Expected: "b"},
|
||||
})
|
||||
expectInvalid(t, m, "spec.jsonQuery.operator", "Unsupported value")
|
||||
}
|
||||
|
||||
func TestIntervalBelowMin(t *testing.T) {
|
||||
m := monitor("interval-low", KumaMonitorSpec{
|
||||
Type: "http",
|
||||
DisplayName: "h",
|
||||
Interval: 5,
|
||||
HTTP: &HTTPSpec{URL: "https://example.com"},
|
||||
})
|
||||
expectInvalid(t, m, "spec.interval")
|
||||
}
|
||||
|
||||
func TestDefaults(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
m := monitor("defaults", KumaMonitorSpec{
|
||||
Type: "http",
|
||||
DisplayName: "h",
|
||||
HTTP: &HTTPSpec{URL: "https://example.com"},
|
||||
})
|
||||
if err := k8s.Create(ctx, m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := &KumaMonitor{}
|
||||
if err := k8s.Get(ctx, client.ObjectKeyFromObject(m), got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Spec.Interval != 60 {
|
||||
t.Fatalf("interval = %v, want 60", got.Spec.Interval)
|
||||
}
|
||||
codes := got.Spec.HTTP.AcceptedStatusCodes
|
||||
if len(codes) != 1 || codes[0] != "200-299" {
|
||||
t.Fatalf("acceptedStatusCodes = %v", codes)
|
||||
}
|
||||
}
|
||||
|
||||
// The controller ignores updates that leave metadata.generation unchanged, so
|
||||
// deleting a resource that has a finalizer must bump it.
|
||||
func TestDeletionBumpsGeneration(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
m := monitor("deletion-gen", KumaMonitorSpec{
|
||||
Type: "http", DisplayName: "h", HTTP: &HTTPSpec{URL: "https://example.com"},
|
||||
})
|
||||
m.Finalizers = []string{"test/finalizer"}
|
||||
if err := k8s.Create(ctx, m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := m.Generation
|
||||
if err := k8s.Delete(ctx, m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := &KumaMonitor{}
|
||||
if err := k8s.Get(ctx, client.ObjectKeyFromObject(m), got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.DeletionTimestamp.IsZero() {
|
||||
t.Fatal("expected deletionTimestamp to be set")
|
||||
}
|
||||
if got.Generation <= before {
|
||||
t.Fatalf("generation %d did not increase past %d on deletion", got.Generation, before)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
//go:build !ignore_autogenerated
|
||||
|
||||
// Code generated by controller-gen. DO NOT EDIT.
|
||||
|
||||
package v1alpha1
|
||||
|
||||
import (
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
runtime "k8s.io/apimachinery/pkg/runtime"
|
||||
)
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *DNSSpec) DeepCopyInto(out *DNSSpec) {
|
||||
*out = *in
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSSpec.
|
||||
func (in *DNSSpec) DeepCopy() *DNSSpec {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(DNSSpec)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *HTTPSpec) DeepCopyInto(out *HTTPSpec) {
|
||||
*out = *in
|
||||
if in.AcceptedStatusCodes != nil {
|
||||
in, out := &in.AcceptedStatusCodes, &out.AcceptedStatusCodes
|
||||
*out = make([]string, len(*in))
|
||||
copy(*out, *in)
|
||||
}
|
||||
if in.Headers != nil {
|
||||
in, out := &in.Headers, &out.Headers
|
||||
*out = make(map[string]string, len(*in))
|
||||
for key, val := range *in {
|
||||
(*out)[key] = val
|
||||
}
|
||||
}
|
||||
if in.MaxRedirects != nil {
|
||||
in, out := &in.MaxRedirects, &out.MaxRedirects
|
||||
*out = new(int32)
|
||||
**out = **in
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HTTPSpec.
|
||||
func (in *HTTPSpec) DeepCopy() *HTTPSpec {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(HTTPSpec)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *JSONQuerySpec) DeepCopyInto(out *JSONQuerySpec) {
|
||||
*out = *in
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new JSONQuerySpec.
|
||||
func (in *JSONQuerySpec) DeepCopy() *JSONQuerySpec {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(JSONQuerySpec)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *KeywordSpec) DeepCopyInto(out *KeywordSpec) {
|
||||
*out = *in
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KeywordSpec.
|
||||
func (in *KeywordSpec) DeepCopy() *KeywordSpec {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(KeywordSpec)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *KumaMonitor) DeepCopyInto(out *KumaMonitor) {
|
||||
*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 KumaMonitor.
|
||||
func (in *KumaMonitor) DeepCopy() *KumaMonitor {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(KumaMonitor)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
|
||||
func (in *KumaMonitor) 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 *KumaMonitorList) DeepCopyInto(out *KumaMonitorList) {
|
||||
*out = *in
|
||||
out.TypeMeta = in.TypeMeta
|
||||
in.ListMeta.DeepCopyInto(&out.ListMeta)
|
||||
if in.Items != nil {
|
||||
in, out := &in.Items, &out.Items
|
||||
*out = make([]KumaMonitor, len(*in))
|
||||
for i := range *in {
|
||||
(*in)[i].DeepCopyInto(&(*out)[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KumaMonitorList.
|
||||
func (in *KumaMonitorList) DeepCopy() *KumaMonitorList {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(KumaMonitorList)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
|
||||
func (in *KumaMonitorList) 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 *KumaMonitorSpec) DeepCopyInto(out *KumaMonitorSpec) {
|
||||
*out = *in
|
||||
if in.ParentRef != nil {
|
||||
in, out := &in.ParentRef, &out.ParentRef
|
||||
*out = new(LocalRef)
|
||||
**out = **in
|
||||
}
|
||||
if in.Timeout != nil {
|
||||
in, out := &in.Timeout, &out.Timeout
|
||||
*out = new(int32)
|
||||
**out = **in
|
||||
}
|
||||
if in.HTTP != nil {
|
||||
in, out := &in.HTTP, &out.HTTP
|
||||
*out = new(HTTPSpec)
|
||||
(*in).DeepCopyInto(*out)
|
||||
}
|
||||
if in.Keyword != nil {
|
||||
in, out := &in.Keyword, &out.Keyword
|
||||
*out = new(KeywordSpec)
|
||||
**out = **in
|
||||
}
|
||||
if in.JSONQuery != nil {
|
||||
in, out := &in.JSONQuery, &out.JSONQuery
|
||||
*out = new(JSONQuerySpec)
|
||||
**out = **in
|
||||
}
|
||||
if in.DNS != nil {
|
||||
in, out := &in.DNS, &out.DNS
|
||||
*out = new(DNSSpec)
|
||||
**out = **in
|
||||
}
|
||||
if in.Port != nil {
|
||||
in, out := &in.Port, &out.Port
|
||||
*out = new(PortSpec)
|
||||
**out = **in
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KumaMonitorSpec.
|
||||
func (in *KumaMonitorSpec) DeepCopy() *KumaMonitorSpec {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(KumaMonitorSpec)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *KumaMonitorStatus) DeepCopyInto(out *KumaMonitorStatus) {
|
||||
*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 KumaMonitorStatus.
|
||||
func (in *KumaMonitorStatus) DeepCopy() *KumaMonitorStatus {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(KumaMonitorStatus)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *LocalRef) DeepCopyInto(out *LocalRef) {
|
||||
*out = *in
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LocalRef.
|
||||
func (in *LocalRef) DeepCopy() *LocalRef {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(LocalRef)
|
||||
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
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PortSpec.
|
||||
func (in *PortSpec) DeepCopy() *PortSpec {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(PortSpec)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type config struct {
|
||||
URL string
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
// loadConfig reads the Kuma connection settings from the environment. It
|
||||
// names every missing variable.
|
||||
func loadConfig(getenv func(string) string) (config, error) {
|
||||
var cfg config
|
||||
var missing []error
|
||||
for _, v := range []struct {
|
||||
name string
|
||||
dst *string
|
||||
}{
|
||||
{"KUMA_URL", &cfg.URL},
|
||||
{"KUMA_USERNAME", &cfg.Username},
|
||||
{"KUMA_PASSWORD", &cfg.Password},
|
||||
} {
|
||||
if *v.dst = getenv(v.name); *v.dst == "" {
|
||||
missing = append(missing, fmt.Errorf("missing required environment variable %s", v.name))
|
||||
}
|
||||
}
|
||||
return cfg, errors.Join(missing...)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadConfig(t *testing.T) {
|
||||
env := func(m map[string]string) func(string) string {
|
||||
return func(k string) string { return m[k] }
|
||||
}
|
||||
full := map[string]string{"KUMA_URL": "http://kuma", "KUMA_USERNAME": "u", "KUMA_PASSWORD": "p"}
|
||||
|
||||
cfg, err := loadConfig(env(full))
|
||||
if err != nil || cfg != (config{URL: "http://kuma", Username: "u", Password: "p"}) {
|
||||
t.Fatalf("cfg=%+v err=%v", cfg, err)
|
||||
}
|
||||
|
||||
for _, name := range []string{"KUMA_URL", "KUMA_USERNAME", "KUMA_PASSWORD"} {
|
||||
m := map[string]string{}
|
||||
for k, v := range full {
|
||||
if k != name {
|
||||
m[k] = v
|
||||
}
|
||||
}
|
||||
_, err := loadConfig(env(m))
|
||||
if err == nil || !strings.Contains(err.Error(), name) {
|
||||
t.Errorf("missing %s: err = %v, want it to name the variable", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
_, err = loadConfig(env(nil))
|
||||
for _, name := range []string{"KUMA_URL", "KUMA_USERNAME", "KUMA_PASSWORD"} {
|
||||
if err == nil || !strings.Contains(err.Error(), name) {
|
||||
t.Errorf("all missing: err = %v, want %s named", err, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Command kuma-sync reconciles KumaMonitor resources into Uptime Kuma.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
ctrl "sigs.k8s.io/controller-runtime"
|
||||
"sigs.k8s.io/controller-runtime/pkg/log/zap"
|
||||
|
||||
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/app"
|
||||
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/kumaclient"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "kuma-sync:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
cfg, err := loadConfig(os.Getenv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctrl.SetLogger(zap.New())
|
||||
|
||||
// The Kuma session lives as long as this context, so it is the signal
|
||||
// context of the process.
|
||||
ctx := ctrl.SetupSignalHandler()
|
||||
|
||||
kuma, err := kumaclient.NewBreml(ctx, cfg.URL, cfg.Username, cfg.Password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to Kuma: %w", err)
|
||||
}
|
||||
if c, ok := kuma.(io.Closer); ok {
|
||||
defer c.Close()
|
||||
}
|
||||
|
||||
mgr, err := app.NewManager(app.Options{
|
||||
Config: ctrl.GetConfigOrDie(),
|
||||
Kuma: kuma,
|
||||
MetricsAddr: ":8080",
|
||||
ProbeAddr: ":8081",
|
||||
GCInterval: 5 * time.Minute,
|
||||
OrphanGrace: time.Hour,
|
||||
Resync: 5 * time.Minute,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return mgr.Start(ctx)
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
---
|
||||
apiVersion: apiextensions.k8s.io/v1
|
||||
kind: CustomResourceDefinition
|
||||
metadata:
|
||||
annotations:
|
||||
controller-gen.kubebuilder.io/version: v0.23.0
|
||||
name: kumamonitors.uptime.renner.uno
|
||||
spec:
|
||||
group: uptime.renner.uno
|
||||
names:
|
||||
kind: KumaMonitor
|
||||
listKind: KumaMonitorList
|
||||
plural: kumamonitors
|
||||
singular: kumamonitor
|
||||
scope: Namespaced
|
||||
versions:
|
||||
- additionalPrinterColumns:
|
||||
- jsonPath: .spec.type
|
||||
name: Type
|
||||
type: string
|
||||
- jsonPath: .status.conditions[?(@.type=="Ready")].status
|
||||
name: Ready
|
||||
type: string
|
||||
name: v1alpha1
|
||||
schema:
|
||||
openAPIV3Schema:
|
||||
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:
|
||||
description:
|
||||
type: string
|
||||
displayName:
|
||||
minLength: 1
|
||||
type: string
|
||||
dns:
|
||||
properties:
|
||||
hostname:
|
||||
minLength: 1
|
||||
type: string
|
||||
port:
|
||||
default: 53
|
||||
format: int32
|
||||
maximum: 65535
|
||||
minimum: 1
|
||||
type: integer
|
||||
resolveServer:
|
||||
default: 1.1.1.1
|
||||
type: string
|
||||
resolveType:
|
||||
enum:
|
||||
- A
|
||||
- AAAA
|
||||
- CNAME
|
||||
- MX
|
||||
- NS
|
||||
- PTR
|
||||
- SOA
|
||||
- SRV
|
||||
- TXT
|
||||
- CAA
|
||||
type: string
|
||||
required:
|
||||
- hostname
|
||||
- resolveType
|
||||
type: object
|
||||
http:
|
||||
description: HTTPSpec configures http, keyword and json-query monitors.
|
||||
properties:
|
||||
acceptedStatusCodes:
|
||||
default:
|
||||
- 200-299
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
body:
|
||||
type: string
|
||||
expiryNotification:
|
||||
type: boolean
|
||||
headers:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
ignoreTLS:
|
||||
type: boolean
|
||||
maxRedirects:
|
||||
format: int32
|
||||
type: integer
|
||||
method:
|
||||
default: GET
|
||||
type: string
|
||||
url:
|
||||
minLength: 1
|
||||
type: string
|
||||
required:
|
||||
- url
|
||||
type: object
|
||||
interval:
|
||||
default: 60
|
||||
description: Seconds.
|
||||
format: int32
|
||||
minimum: 20
|
||||
type: integer
|
||||
jsonQuery:
|
||||
properties:
|
||||
expected:
|
||||
type: string
|
||||
expression:
|
||||
description: JSONata expression.
|
||||
minLength: 1
|
||||
type: string
|
||||
operator:
|
||||
enum:
|
||||
- ==
|
||||
- '!='
|
||||
- <
|
||||
- <=
|
||||
- '>'
|
||||
- '>='
|
||||
- contains
|
||||
type: string
|
||||
required:
|
||||
- expected
|
||||
- expression
|
||||
- operator
|
||||
type: object
|
||||
keyword:
|
||||
properties:
|
||||
invert:
|
||||
type: boolean
|
||||
keyword:
|
||||
minLength: 1
|
||||
type: string
|
||||
required:
|
||||
- keyword
|
||||
type: object
|
||||
maxRetries:
|
||||
default: 0
|
||||
format: int32
|
||||
minimum: 0
|
||||
type: integer
|
||||
parentRef:
|
||||
description: LocalRef references a KumaMonitor in the same namespace.
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
type: object
|
||||
paused:
|
||||
type: boolean
|
||||
port:
|
||||
properties:
|
||||
hostname:
|
||||
minLength: 1
|
||||
type: string
|
||||
port:
|
||||
format: int32
|
||||
maximum: 65535
|
||||
minimum: 1
|
||||
type: integer
|
||||
required:
|
||||
- hostname
|
||||
- port
|
||||
type: object
|
||||
retryInterval:
|
||||
default: 60
|
||||
description: Seconds.
|
||||
format: int32
|
||||
minimum: 20
|
||||
type: integer
|
||||
timeout:
|
||||
description: Seconds.
|
||||
format: int32
|
||||
minimum: 1
|
||||
type: integer
|
||||
type:
|
||||
enum:
|
||||
- group
|
||||
- http
|
||||
- keyword
|
||||
- json-query
|
||||
- dns
|
||||
- port
|
||||
type: string
|
||||
required:
|
||||
- displayName
|
||||
- type
|
||||
type: object
|
||||
x-kubernetes-validations:
|
||||
- message: http is required for types http, keyword and json-query, and
|
||||
forbidden otherwise
|
||||
rule: (self.type in ['http','keyword','json-query']) == has(self.http)
|
||||
- message: keyword is required for type keyword and forbidden otherwise
|
||||
rule: (self.type == 'keyword') == has(self.keyword)
|
||||
- message: jsonQuery is required for type json-query and forbidden otherwise
|
||||
rule: (self.type == 'json-query') == has(self.jsonQuery)
|
||||
- message: dns is required for type dns and forbidden otherwise
|
||||
rule: (self.type == 'dns') == has(self.dns)
|
||||
- message: port is required for type port and forbidden otherwise
|
||||
rule: (self.type == 'port') == has(self.port)
|
||||
- message: type group takes no type blocks
|
||||
rule: self.type != 'group' || !(has(self.http) || has(self.keyword)
|
||||
|| has(self.jsonQuery) || has(self.dns) || has(self.port))
|
||||
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
|
||||
monitorID:
|
||||
format: int64
|
||||
type: integer
|
||||
observedGeneration:
|
||||
format: int64
|
||||
type: integer
|
||||
type: object
|
||||
required:
|
||||
- spec
|
||||
type: object
|
||||
served: true
|
||||
storage: true
|
||||
subresources:
|
||||
status: {}
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: kuma-sync
|
||||
rules:
|
||||
- apiGroups:
|
||||
- ""
|
||||
resources:
|
||||
- events
|
||||
verbs:
|
||||
- create
|
||||
- patch
|
||||
- apiGroups:
|
||||
- ""
|
||||
resources:
|
||||
- namespaces
|
||||
verbs:
|
||||
- get
|
||||
- apiGroups:
|
||||
- uptime.renner.uno
|
||||
resources:
|
||||
- kumamonitors
|
||||
verbs:
|
||||
- get
|
||||
- list
|
||||
- patch
|
||||
- update
|
||||
- watch
|
||||
- apiGroups:
|
||||
- uptime.renner.uno
|
||||
resources:
|
||||
- kumamonitors/finalizers
|
||||
verbs:
|
||||
- update
|
||||
- apiGroups:
|
||||
- uptime.renner.uno
|
||||
resources:
|
||||
- kumamonitors/status
|
||||
verbs:
|
||||
- get
|
||||
- patch
|
||||
- update
|
||||
@@ -0,0 +1,123 @@
|
||||
module gitea.renner.uno/konairius/homelab/kuma-sync
|
||||
|
||||
go 1.27
|
||||
|
||||
require (
|
||||
github.com/breml/go-uptime-kuma-client v0.6.2
|
||||
github.com/testcontainers/testcontainers-go v0.44.0
|
||||
k8s.io/api v0.37.1
|
||||
k8s.io/apimachinery v0.37.1
|
||||
k8s.io/client-go v0.37.1
|
||||
k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3
|
||||
sigs.k8s.io/controller-runtime v0.25.2
|
||||
)
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/containerd/errdefs v1.0.0 // indirect
|
||||
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||
github.com/containerd/log v0.1.0 // indirect
|
||||
github.com/containerd/platforms v0.2.1 // indirect
|
||||
github.com/cpuguy83/dockercfg v0.3.2 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/docker/go-connections v0.7.0 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/ebitengine/purego v0.10.1 // indirect
|
||||
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
|
||||
github.com/evanphx/json-patch/v5 v5.9.11 // indirect
|
||||
github.com/felixge/httpsnoop v1.1.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.10.1 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.1 // indirect
|
||||
github.com/go-logr/logr v1.4.4 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-logr/zapr v1.3.0 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/go-openapi/jsonpointer v1.0.0 // indirect
|
||||
github.com/go-openapi/jsonreference v1.0.0 // indirect
|
||||
github.com/go-openapi/swag v0.27.1 // indirect
|
||||
github.com/go-openapi/swag/cmdutils v0.27.1 // indirect
|
||||
github.com/go-openapi/swag/conv v0.27.1 // indirect
|
||||
github.com/go-openapi/swag/fileutils v0.27.1 // indirect
|
||||
github.com/go-openapi/swag/jsonutils v0.27.1 // indirect
|
||||
github.com/go-openapi/swag/loading v0.27.1 // indirect
|
||||
github.com/go-openapi/swag/mangling v0.27.1 // indirect
|
||||
github.com/go-openapi/swag/netutils v0.27.1 // indirect
|
||||
github.com/go-openapi/swag/pools v0.27.1 // indirect
|
||||
github.com/go-openapi/swag/stringutils v0.27.1 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.27.1 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.27.1 // indirect
|
||||
github.com/google/gnostic-models v0.7.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.19.2 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
|
||||
github.com/magiconair/properties v1.8.10 // indirect
|
||||
github.com/maldikhan/go.socket.io v0.1.1 // indirect
|
||||
github.com/maniartech/signals v1.3.1 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/go-archive v0.2.0 // indirect
|
||||
github.com/moby/moby/api v1.55.0 // indirect
|
||||
github.com/moby/moby/client v0.5.0 // indirect
|
||||
github.com/moby/patternmatcher v0.6.1 // indirect
|
||||
github.com/moby/sys/sequential v0.7.0 // indirect
|
||||
github.com/moby/sys/user v0.4.0 // indirect
|
||||
github.com/moby/sys/userns v0.1.0 // indirect
|
||||
github.com/moby/term v0.5.2 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||
github.com/pquerna/otp v1.5.0 // indirect
|
||||
github.com/prometheus/client_golang v1.24.0 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.70.0 // indirect
|
||||
github.com/prometheus/procfs v0.21.1 // indirect
|
||||
github.com/shirou/gopsutil/v4 v4.26.6 // indirect
|
||||
github.com/sirupsen/logrus v1.10.1 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/stretchr/testify v1.12.1 // indirect
|
||||
github.com/tklauser/go-sysconf v0.4.0 // indirect
|
||||
github.com/tklauser/numcpus v0.12.0 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 // indirect
|
||||
go.opentelemetry.io/otel v1.45.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.45.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.45.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.uber.org/zap v1.28.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
golang.org/x/crypto v0.55.0 // indirect
|
||||
golang.org/x/net v0.58.0 // indirect
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/term v0.45.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect
|
||||
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
|
||||
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
|
||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||
k8s.io/apiextensions-apiserver v0.37.1 // indirect
|
||||
k8s.io/klog/v2 v2.140.0 // indirect
|
||||
k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad // indirect
|
||||
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
|
||||
sigs.k8s.io/randfill v1.0.0 // indirect
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.4.2 // indirect
|
||||
sigs.k8s.io/yaml v1.6.0 // indirect
|
||||
)
|
||||
|
||||
replace github.com/maldikhan/go.socket.io => github.com/breml/go.socket.io v0.0.0-20260516193936-e70410c8cd31
|
||||
@@ -0,0 +1,312 @@
|
||||
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
|
||||
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw=
|
||||
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/breml/go-uptime-kuma-client v0.6.2 h1:C2Z56OFLNCHHeEkQ6T8B7Nuqkf8SYtPy3Nb1YhFMCUA=
|
||||
github.com/breml/go-uptime-kuma-client v0.6.2/go.mod h1:LQkdwVs3TzTQZ92WvPIGgHoAC6rUEJkn+8ULkc51uJ8=
|
||||
github.com/breml/go.socket.io v0.0.0-20260516193936-e70410c8cd31 h1:l5fVxSzbJHJ1jcLxmcOLVY0uxZFfB1yuztPJVtMfLRg=
|
||||
github.com/breml/go.socket.io v0.0.0-20260516193936-e70410c8cd31/go.mod h1:H1EoWDJvqfV2WryM8F9og6ZkH7NsZDlHr0BRkKb58tY=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/containerd/continuity v0.4.5 h1:ZRoN1sXq9u7V6QoHMcVWGhOwDFqZ4B9i5H6un1Wh0x4=
|
||||
github.com/containerd/continuity v0.4.5/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE=
|
||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
||||
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
|
||||
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
|
||||
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
|
||||
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/docker/cli v27.4.1+incompatible h1:VzPiUlRJ/xh+otB75gva3r05isHMo5wXDfPRi5/b4hI=
|
||||
github.com/docker/cli v27.4.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
||||
github.com/docker/docker v27.1.1+incompatible h1:hO/M4MtV36kzKldqnA37IWhebRA+LnqqcqDja6kVaKY=
|
||||
github.com/docker/docker v27.1.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
|
||||
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
|
||||
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
|
||||
github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
|
||||
github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k=
|
||||
github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ=
|
||||
github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU=
|
||||
github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM=
|
||||
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
|
||||
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
|
||||
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||
github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ=
|
||||
github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
|
||||
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ=
|
||||
github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s=
|
||||
github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y=
|
||||
github.com/go-openapi/jsonreference v1.0.0 h1:jlmTr6torcd1YgDQvSfNmRtKzYDO4FGBkrAdlAVWnpY=
|
||||
github.com/go-openapi/jsonreference v1.0.0/go.mod h1:jtwdyGbJk0Xhe5Y+rwtglQP6Sb1WZST4rT32LWB+sv0=
|
||||
github.com/go-openapi/swag v0.27.1 h1:VotvOLWW8q/EAxB0YdsBBGC8XYyeL1YwBj2ungAGPNg=
|
||||
github.com/go-openapi/swag v0.27.1/go.mod h1:GTkJPwHfhJp6MWr4/rCh64HVI3Ofu+tcsbfjfHmTxpE=
|
||||
github.com/go-openapi/swag/cmdutils v0.27.1 h1:I7sYqaWVl5mq0NEmNQkAmFDyNin9ufvMX/p2zwtQaOE=
|
||||
github.com/go-openapi/swag/cmdutils v0.27.1/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM=
|
||||
github.com/go-openapi/swag/conv v0.27.1 h1:8wi9ZG+olmY1wXphl93EWniPtbSPkXM/feH7FgjsvrU=
|
||||
github.com/go-openapi/swag/conv v0.27.1/go.mod h1:QbqMivkpKhC3g1B1GGGOJ6ANewI3S62dbzYu3Duowqs=
|
||||
github.com/go-openapi/swag/fileutils v0.27.1 h1:QQqBSoi5mW4XpU85nS0mLcA+zAE6vLzrb0QkmLKf9oM=
|
||||
github.com/go-openapi/swag/fileutils v0.27.1/go.mod h1:VvJFZLTZS0AI854gEQz5tk7dBESdLjiNUMSZ/th2ry8=
|
||||
github.com/go-openapi/swag/jsonutils v0.27.1 h1:SVgK3i4USzCU5mibOOS/l4ea2h9UQXy7J7RNLTjuXjU=
|
||||
github.com/go-openapi/swag/jsonutils v0.27.1/go.mod h1:tdlEpZqdcQ17uj6J4YdK9vd8It5qWMwjWXOs0tjpRlk=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.1 h1:mJu3COL9WEaZVp/Kf2PRMi7tPszPEJfSr/OO75ynCs8=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.1/go.mod h1:mofwUWx70wvskwESqRJ//k/9kURmCgyJl5m5Ppoh5kY=
|
||||
github.com/go-openapi/swag/loading v0.27.1 h1:/DxUgDXKbBX4bcn7r9uEXfJyzN5XpiJmZplzQTjrRCY=
|
||||
github.com/go-openapi/swag/loading v0.27.1/go.mod h1:jvGh3iA2+zyUUycB5fgJWzeHnhrpvGnJJM0RVE9ZShE=
|
||||
github.com/go-openapi/swag/mangling v0.27.1 h1:yC9D0HyUE8gbP+BfmGx9+AA89ikwZTMjESK3OnnoaqA=
|
||||
github.com/go-openapi/swag/mangling v0.27.1/go.mod h1:jtBE2+V+3pILxOR7Vgce+Cwp6A2PgZbvVqfNntbVs0w=
|
||||
github.com/go-openapi/swag/netutils v0.27.1 h1:mICMFoS82F5TZ4Zy3cqmcQk+BFeCp3Uyq3Np7GI0/qU=
|
||||
github.com/go-openapi/swag/netutils v0.27.1/go.mod h1:J+WYyFMLtvtCGqa6jLv+YNUmIKI3ZRQRrvfNDMoQoEQ=
|
||||
github.com/go-openapi/swag/pools v0.27.1 h1:9LeadcMyb2GJCbXX5hVQDbZ2Lq9TL4dCs/nx1j5DO0E=
|
||||
github.com/go-openapi/swag/pools v0.27.1/go.mod h1:kVQefhSK5RWuRe7BXsL8htgBPAMpN7HDGpGEknqugeE=
|
||||
github.com/go-openapi/swag/stringutils v0.27.1 h1:ZXePZ0r2p1qSjo8tD3Un4vFj8+FqlCkczxDrJIhYUp8=
|
||||
github.com/go-openapi/swag/stringutils v0.27.1/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM=
|
||||
github.com/go-openapi/swag/typeutils v0.27.1 h1:KSTdFlfnse4r6dP9IrEnwMldjE+zs71UeEB3//PtVXc=
|
||||
github.com/go-openapi/swag/typeutils v0.27.1/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ=
|
||||
github.com/go-openapi/swag/yamlutils v0.27.1 h1:ftxv6xvXb1E3zohUc+okZ9nSqNb9StQX/FXnKZ98sQA=
|
||||
github.com/go-openapi/swag/yamlutils v0.27.1/go.mod h1:bnxFIB1qewGRiZHypXGZ3fNgf13/0HfRgnS/iZBDrOo=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.6.0 h1:gGHwAJ0R/5jU8BEGDbfRNR3hL68dAVi84WuOApp29B0=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.6.0/go.mod h1:tY+St1SGq4NFl0QIqdTY4aEdbChAHxhyB77XQi9iJCo=
|
||||
github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug=
|
||||
github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
|
||||
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
|
||||
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
|
||||
github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=
|
||||
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
|
||||
github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak=
|
||||
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
||||
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/maniartech/signals v1.3.1 h1:pT3dK6x5Un+B6L3ZLAKygEe+L49TClPreyT08vOoHXY=
|
||||
github.com/maniartech/signals v1.3.1/go.mod h1:AbE8Yy9ZjKCWNU/VhQ+0Ea9KOaTWHp6aOfdLBe5m1iM=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
|
||||
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
|
||||
github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc=
|
||||
github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
|
||||
github.com/moby/moby/client v0.5.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc=
|
||||
github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s=
|
||||
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
|
||||
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
||||
github.com/moby/sys/sequential v0.7.0 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31zj/8=
|
||||
github.com/moby/sys/sequential v0.7.0/go.mod h1:NfSTAp6V3fw4tmkD62PEcOKeZKquXT8VKCkf7aVR79o=
|
||||
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
|
||||
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
|
||||
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
|
||||
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
||||
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
|
||||
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/onsi/ginkgo/v2 v2.27.4 h1:fcEcQW/A++6aZAZQNUmNjvA9PSOzefMJBerHJ4t8v8Y=
|
||||
github.com/onsi/ginkgo/v2 v2.27.4/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
|
||||
github.com/onsi/gomega v1.39.0 h1:y2ROC3hKFmQZJNFeGAMeHZKkjBL65mIZcvrLQBF9k6Q=
|
||||
github.com/onsi/gomega v1.39.0/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||
github.com/opencontainers/runc v1.2.3 h1:fxE7amCzfZflJO2lHXf4y/y8M1BoAqp+FVmG19oYB80=
|
||||
github.com/opencontainers/runc v1.2.3/go.mod h1:nSxcWUydXrsBZVYNSkTjoQ/N6rcyTtn+1SD5D4+kRIM=
|
||||
github.com/ory/dockertest/v3 v3.12.0 h1:3oV9d0sDzlSQfHtIaB5k6ghUCVMVLpAY8hwrqoCyRCw=
|
||||
github.com/ory/dockertest/v3 v3.12.0/go.mod h1:aKNDTva3cp8dwOWwb9cWuX84aH5akkxXRvO7KCwWVjE=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||
github.com/prometheus/client_golang v1.24.0 h1:5XStIklKuAtJSNpdD3s8XJj/Yv78IQmE1kbNk87JrAI=
|
||||
github.com/prometheus/client_golang v1.24.0/go.mod h1:QcsNdotprC2nS4BTM2ucbcqxd2CeXTEa9jW7zHO9iDE=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.70.0 h1:bcpru3tWPVnxGnETLgOV5jbp/JRXgYEyv65CuBLAMMI=
|
||||
github.com/prometheus/common v0.70.0/go.mod h1:S/SFasQmgGiYH6C81LKCtYa8QACgthGg5zxL2udV7SY=
|
||||
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
|
||||
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
|
||||
github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs=
|
||||
github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
||||
github.com/sirupsen/logrus v1.10.1 h1:xi4336Zh11WpU14fXR6I67V3yaTPQYwRx2WEtHbRg4Q=
|
||||
github.com/sirupsen/logrus v1.10.1/go.mod h1:vsQHnG7xzNsxk3NrwboUiWPnIC3dmbjcGPykD7+tiHk=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
|
||||
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||
github.com/testcontainers/testcontainers-go v0.44.0 h1:/Fwh6HY1mIikhnm9e7HwoxGycx0lzRAE0f5VQpjFxzI=
|
||||
github.com/testcontainers/testcontainers-go v0.44.0/go.mod h1:IcnwQrYTO86xHXu5bvMaBH7ATlbS3Qn1M1QWW3c66rE=
|
||||
github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU=
|
||||
github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI=
|
||||
github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4=
|
||||
github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo=
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
|
||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
|
||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
|
||||
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
|
||||
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 h1:LMuyCAyfalSjDyjdC65nK6N0zoTT63+E/u95X0JovZI=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0/go.mod h1:085m8qbm4hgc8rZWGDEa4vmyyo2c3nPxUslYUKUIU04=
|
||||
go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU=
|
||||
go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ=
|
||||
go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M=
|
||||
go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s=
|
||||
go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw=
|
||||
go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA=
|
||||
go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag=
|
||||
go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
|
||||
go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
|
||||
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
|
||||
golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
|
||||
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
|
||||
gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw=
|
||||
gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY=
|
||||
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI=
|
||||
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo=
|
||||
gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
|
||||
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
|
||||
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||
k8s.io/api v0.37.1 h1:l6N77U7tjwB5L056bgrBTJIEdevac/naBZ3iSvDNfpM=
|
||||
k8s.io/api v0.37.1/go.mod h1:zSlbB1YpJ1YQlFVQy20UYll81UJSJJUMLhkhvg6Z78M=
|
||||
k8s.io/apiextensions-apiserver v0.37.1 h1:7fIQG8eThDSTVYBWg/DOpI8v5wYfKCo0N8/TDMjj+zY=
|
||||
k8s.io/apiextensions-apiserver v0.37.1/go.mod h1:1Q3ujWwHgnhBoGsdmGORflwK1sDFmuaqH1OsMDWk2Gs=
|
||||
k8s.io/apimachinery v0.37.1 h1:hGCYyvKHCwtwMitj2vU4vYx0Z16N9GyZk9BBnz0wDAE=
|
||||
k8s.io/apimachinery v0.37.1/go.mod h1:jF84AyUi/IRIXRot5f+lm6MpxoWI+F1XgjaMmwCdTFw=
|
||||
k8s.io/client-go v0.37.1 h1:QTv/5ha4jAHtW9qxxVBkQVFBRDb4jHfFopQqqMdc+wM=
|
||||
k8s.io/client-go v0.37.1/go.mod h1:dnAPtTnCNY38Ho04D2KdY1F4IKausa9UbqaAZKl60SY=
|
||||
k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc=
|
||||
k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0=
|
||||
k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad h1:oXImqH8mQNk7PmvzKhmN3ddJoY6OnyM225MXwGHPm0A=
|
||||
k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad/go.mod h1:0/mqHCVhlumdJ3BhCfnjSZQE037nAhNodh1/hK0T8/I=
|
||||
k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 h1:jVkFFVfXdXP74B/zbO3hM3hpSFD0xvhQ5U686DPurkE=
|
||||
k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3/go.mod h1:M2s5JB1lIYP3jzZdorPLHXIPJzt9vv2muW5a6L9DtNM=
|
||||
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
|
||||
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
|
||||
sigs.k8s.io/controller-runtime v0.25.2 h1:bEkK3PVOIVK9X8QWLGVhJgmFc++47vfT6wakSzAOLsQ=
|
||||
sigs.k8s.io/controller-runtime v0.25.2/go.mod h1:4QqLdT6z/L6Olj8JJCtvztid4/fnIiYsfaTFScegctc=
|
||||
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
|
||||
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
|
||||
sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
|
||||
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.4.2 h1:qdOxHwrl2Kaag1aQEarlYcOA9vSyGCp3CIki3aW8c4Q=
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.4.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
|
||||
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
|
||||
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
|
||||
@@ -0,0 +1,103 @@
|
||||
// Package app wires the controller-runtime manager shared by the binary and
|
||||
// the end-to-end tests.
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
|
||||
"k8s.io/client-go/rest"
|
||||
"k8s.io/utils/ptr"
|
||||
ctrl "sigs.k8s.io/controller-runtime"
|
||||
"sigs.k8s.io/controller-runtime/pkg/config"
|
||||
"sigs.k8s.io/controller-runtime/pkg/healthz"
|
||||
"sigs.k8s.io/controller-runtime/pkg/manager"
|
||||
metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
|
||||
|
||||
v1 "gitea.renner.uno/konairius/homelab/kuma-sync/api/v1alpha1"
|
||||
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/controller"
|
||||
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/kumaclient"
|
||||
)
|
||||
|
||||
// Options configures NewManager.
|
||||
type Options struct {
|
||||
Config *rest.Config
|
||||
Kuma kumaclient.Client
|
||||
|
||||
// MetricsAddr and ProbeAddr are bind addresses; "0" and "" disable them.
|
||||
MetricsAddr string
|
||||
ProbeAddr string
|
||||
|
||||
// GCInterval is the orphan collection period; 0 means 5 minutes.
|
||||
GCInterval time.Duration
|
||||
// OrphanGrace is how long a monitor must be seen without its resource
|
||||
// before the GC deletes it. Production uses 1h; 0 only requires two runs.
|
||||
OrphanGrace time.Duration
|
||||
// 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
|
||||
|
||||
// SkipNameValidation allows several managers in one process, as in tests
|
||||
// that restart the manager.
|
||||
SkipNameValidation bool
|
||||
}
|
||||
|
||||
// NewManager builds the manager with the reconciler and the orphan GC.
|
||||
// There is no leader election: a single replica runs.
|
||||
func NewManager(o Options) (manager.Manager, error) {
|
||||
scheme := runtime.NewScheme()
|
||||
// Core types: the GC reads Namespaces.
|
||||
if err := clientgoscheme.AddToScheme(scheme); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := v1.AddToScheme(scheme); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mopts := ctrl.Options{
|
||||
Scheme: scheme,
|
||||
Metrics: metricsserver.Options{BindAddress: o.MetricsAddr},
|
||||
HealthProbeBindAddress: o.ProbeAddr,
|
||||
LeaderElection: false,
|
||||
}
|
||||
if o.SkipNameValidation {
|
||||
mopts.Controller = config.Controller{SkipNameValidation: ptr.To(true)}
|
||||
}
|
||||
mgr, err := ctrl.NewManager(o.Config, mopts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create manager: %w", err)
|
||||
}
|
||||
|
||||
if err := (&controller.Reconciler{Client: mgr.GetClient(), Kuma: o.Kuma, Resync: o.Resync}).SetupWithManager(mgr); err != nil {
|
||||
return nil, fmt.Errorf("set up reconciler: %w", err)
|
||||
}
|
||||
if err := mgr.Add(&controller.GC{
|
||||
Client: mgr.GetClient(),
|
||||
Kuma: o.Kuma,
|
||||
Interval: o.GCInterval,
|
||||
OrphanGrace: o.OrphanGrace,
|
||||
Cache: mgr.GetCache(),
|
||||
APIReader: mgr.GetAPIReader(),
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("add GC: %w", err)
|
||||
}
|
||||
|
||||
if err := mgr.AddHealthzCheck("ping", healthz.Ping); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := mgr.AddReadyzCheck("cache-synced", func(req *http.Request) error {
|
||||
ctx, cancel := context.WithTimeout(req.Context(), time.Second)
|
||||
defer cancel()
|
||||
if !mgr.GetCache().WaitForCacheSync(ctx) {
|
||||
return errors.New("cache not synced")
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return mgr, nil
|
||||
}
|
||||
@@ -0,0 +1,514 @@
|
||||
//go:build integration
|
||||
|
||||
package controller_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
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/testcontainers/testcontainers-go"
|
||||
"github.com/testcontainers/testcontainers-go/wait"
|
||||
"k8s.io/apimachinery/pkg/api/meta"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"k8s.io/client-go/util/retry"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/envtest"
|
||||
|
||||
v1 "gitea.renner.uno/konairius/homelab/kuma-sync/api/v1alpha1"
|
||||
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/app"
|
||||
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/kumaclient"
|
||||
)
|
||||
|
||||
const (
|
||||
user = "admin"
|
||||
pass = "Sup3r-Secret-Passw0rd!"
|
||||
|
||||
gcInterval = 2 * time.Second
|
||||
resync = 2 * time.Second
|
||||
)
|
||||
|
||||
// e2e is a real API server (envtest, CRD installed) next to a real Kuma.
|
||||
type e2e struct {
|
||||
t *testing.T
|
||||
k8s client.Client
|
||||
url string
|
||||
raw *kuma.Client // library session used to change Kuma behind the controller's back
|
||||
census kumaclient.Client // separate session used only to count monitors
|
||||
}
|
||||
|
||||
func startKuma(t *testing.T) string {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
|
||||
ContainerRequest: testcontainers.ContainerRequest{
|
||||
Image: "louislam/uptime-kuma:2.5.6-slim",
|
||||
ExposedPorts: []string{"3001/tcp"},
|
||||
Env: map[string]string{"UPTIME_KUMA_DB_TYPE": "sqlite"},
|
||||
WaitingFor: wait.ForHTTP("/api/entry-page").WithPort("3001/tcp").
|
||||
WithStatusCodeMatcher(func(s int) bool { return s == 200 }).
|
||||
WithStartupTimeout(3 * time.Minute),
|
||||
},
|
||||
Started: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("start kuma: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = ctr.Terminate(context.Background()) })
|
||||
url, err := ctr.PortEndpoint(ctx, "3001/tcp", "http")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
func newE2E(t *testing.T) (*e2e, *envtest.Environment) {
|
||||
t.Helper()
|
||||
env := &envtest.Environment{
|
||||
CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd")},
|
||||
ErrorIfCRDPathMissing: true,
|
||||
}
|
||||
cfg, err := env.Start()
|
||||
if err != nil {
|
||||
t.Fatalf("start envtest: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = env.Stop() })
|
||||
|
||||
scheme := runtime.NewScheme()
|
||||
if err := v1.AddToScheme(scheme); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
k8s, err := client.New(cfg, client.Options{Scheme: scheme})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
url := startKuma(t)
|
||||
// The first login creates the admin account.
|
||||
raw, err := kuma.New(t.Context(), url, user, pass, kuma.WithAutosetup(), kuma.WithConnectTimeout(2*time.Minute))
|
||||
if err != nil {
|
||||
t.Fatalf("kuma setup: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = raw.Disconnect() })
|
||||
census, err := kumaclient.NewBreml(t.Context(), url, user, pass)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c, ok := census.(io.Closer); ok {
|
||||
t.Cleanup(func() { _ = c.Close() })
|
||||
}
|
||||
return &e2e{t: t, k8s: k8s, url: url, raw: raw, census: census}, env
|
||||
}
|
||||
|
||||
// manager is one run of the controller, built from scratch like a pod start.
|
||||
type running struct {
|
||||
cancel context.CancelFunc
|
||||
done chan error
|
||||
kuma io.Closer
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (f *e2e) start(env *envtest.Environment) *running {
|
||||
f.t.Helper()
|
||||
ctx, cancel := context.WithCancel(f.t.Context())
|
||||
kc, err := kumaclient.NewBreml(ctx, f.url, user, pass)
|
||||
if err != nil {
|
||||
cancel()
|
||||
f.t.Fatalf("new kuma client: %v", err)
|
||||
}
|
||||
mgr, err := app.NewManager(app.Options{
|
||||
Config: env.Config,
|
||||
Kuma: kc,
|
||||
MetricsAddr: "0",
|
||||
GCInterval: gcInterval,
|
||||
OrphanGrace: 3 * time.Second,
|
||||
Resync: resync,
|
||||
SkipNameValidation: true,
|
||||
})
|
||||
if err != nil {
|
||||
cancel()
|
||||
f.t.Fatalf("new manager: %v", err)
|
||||
}
|
||||
r := &running{cancel: cancel, done: make(chan error, 1)}
|
||||
if c, ok := kc.(io.Closer); ok {
|
||||
r.kuma = c
|
||||
}
|
||||
go func() { r.done <- mgr.Start(ctx) }()
|
||||
// Runs before the envtest and Kuma teardown, also after an early Fatal.
|
||||
f.t.Cleanup(func() { f.stop(r) })
|
||||
return r
|
||||
}
|
||||
|
||||
// stop cancels the manager and waits for it to return. It is idempotent.
|
||||
func (f *e2e) stop(r *running) {
|
||||
f.t.Helper()
|
||||
r.once.Do(func() {
|
||||
r.cancel()
|
||||
select {
|
||||
case err := <-r.done:
|
||||
if err != nil {
|
||||
f.t.Errorf("manager returned %v", err)
|
||||
}
|
||||
case <-time.After(30 * time.Second):
|
||||
f.t.Errorf("manager did not stop")
|
||||
}
|
||||
if r.kuma != nil {
|
||||
_ = r.kuma.Close()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func eventually(t *testing.T, timeout time.Duration, what string, cond func() (bool, string)) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
ok, detail := cond()
|
||||
if ok {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("timed out after %v waiting for %s: %s", timeout, what, detail)
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *e2e) owned() []kumaclient.Monitor {
|
||||
f.t.Helper()
|
||||
list, err := f.census.ListOwned(f.t.Context())
|
||||
if err != nil {
|
||||
f.t.Fatalf("census list: %v", err)
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
func byKey(list []kumaclient.Monitor) map[string]kumaclient.Monitor {
|
||||
out := map[string]kumaclient.Monitor{}
|
||||
for _, m := range list {
|
||||
out[m.OwnerKey] = m
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func group(name, parent string) *v1.KumaMonitor {
|
||||
km := &v1.KumaMonitor{
|
||||
ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: name},
|
||||
Spec: v1.KumaMonitorSpec{Type: "group", DisplayName: name},
|
||||
}
|
||||
if parent != "" {
|
||||
km.Spec.ParentRef = &v1.LocalRef{Name: parent}
|
||||
}
|
||||
return km
|
||||
}
|
||||
|
||||
func dns(name, parent string) *v1.KumaMonitor {
|
||||
km := &v1.KumaMonitor{
|
||||
ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: name},
|
||||
Spec: v1.KumaMonitorSpec{
|
||||
Type: "dns", DisplayName: name,
|
||||
DNS: &v1.DNSSpec{Hostname: "example.com", ResolveType: "A"},
|
||||
},
|
||||
}
|
||||
if parent != "" {
|
||||
km.Spec.ParentRef = &v1.LocalRef{Name: parent}
|
||||
}
|
||||
return km
|
||||
}
|
||||
|
||||
func (f *e2e) apply(objs ...*v1.KumaMonitor) {
|
||||
f.t.Helper()
|
||||
for _, o := range objs {
|
||||
if err := f.k8s.Create(f.t.Context(), o); err != nil {
|
||||
f.t.Fatalf("create %s: %v", o.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestE2EGroupNesting(t *testing.T) {
|
||||
f, env := newE2E(t)
|
||||
f.start(env)
|
||||
f.apply(group("outer", ""), group("inner", "outer"), dns("probe", "inner"))
|
||||
|
||||
eventually(t, 60*time.Second, "outer > inner > probe in Kuma", func() (bool, string) {
|
||||
m := byKey(f.owned())
|
||||
outer, inner, probe := m["default/outer"], m["default/inner"], m["default/probe"]
|
||||
if outer.ID == 0 || inner.ID == 0 || probe.ID == 0 {
|
||||
return false, "not all three monitors exist yet"
|
||||
}
|
||||
if outer.Parent != nil {
|
||||
return false, "outer must have no parent"
|
||||
}
|
||||
if inner.Parent == nil || *inner.Parent != outer.ID {
|
||||
return false, "inner is not under outer"
|
||||
}
|
||||
if probe.Parent == nil || *probe.Parent != inner.ID {
|
||||
return false, "probe is not under inner"
|
||||
}
|
||||
return true, ""
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2ERestartNoDuplicates(t *testing.T) {
|
||||
f, env := newE2E(t)
|
||||
r := f.start(env)
|
||||
f.apply(group("g", ""), dns("probe", "g"))
|
||||
eventually(t, 30*time.Second, "both monitors in Kuma", func() (bool, string) {
|
||||
return len(f.owned()) == 2, "monitors missing"
|
||||
})
|
||||
before := byKey(f.owned())
|
||||
f.stop(r)
|
||||
|
||||
f.start(env)
|
||||
|
||||
// A spec change after the restart must land on the same Kuma monitor: the
|
||||
// new manager reconciled and adopted it instead of creating another.
|
||||
key := types.NamespacedName{Namespace: "default", Name: "g"}
|
||||
if err := retry.RetryOnConflict(retry.DefaultRetry, func() error {
|
||||
km := &v1.KumaMonitor{}
|
||||
if err := f.k8s.Get(t.Context(), key, km); err != nil {
|
||||
return err
|
||||
}
|
||||
km.Spec.DisplayName = "g renamed"
|
||||
return f.k8s.Update(t.Context(), km)
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
eventually(t, 30*time.Second, "rename reflected on the original monitor", func() (bool, string) {
|
||||
m := byKey(f.owned())["default/g"]
|
||||
if m.ID != before["default/g"].ID {
|
||||
return false, fmt.Sprintf("default/g has ID %d, want %d", m.ID, before["default/g"].ID)
|
||||
}
|
||||
return m.Name == "g renamed", "name is " + m.Name
|
||||
})
|
||||
|
||||
// Two GC intervals and then some, checked continuously rather than once.
|
||||
deadline := time.Now().Add(2*gcInterval + 3*time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
after := byKey(f.owned())
|
||||
if len(after) != 2 {
|
||||
t.Fatalf("monitor count changed after restart: %d", len(after))
|
||||
}
|
||||
for k, m := range before {
|
||||
if after[k].ID != m.ID {
|
||||
t.Fatalf("%s changed ID %d -> %d", k, m.ID, after[k].ID)
|
||||
}
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
// Both resources must still be reconciled by the new manager.
|
||||
for _, name := range []string{"g", "probe"} {
|
||||
km := &v1.KumaMonitor{}
|
||||
if err := f.k8s.Get(t.Context(), types.NamespacedName{Namespace: "default", Name: name}, km); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if km.Status.MonitorID != before["default/"+name].ID {
|
||||
t.Errorf("%s status.monitorID = %d, want %d", name, km.Status.MonitorID, before["default/"+name].ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteWhileDownIsCollected(t *testing.T) {
|
||||
f, env := newE2E(t)
|
||||
r := f.start(env)
|
||||
f.apply(dns("doomed", ""), dns("keeper", ""))
|
||||
eventually(t, 30*time.Second, "both monitors in Kuma", func() (bool, string) {
|
||||
return len(f.owned()) == 2, "monitors missing"
|
||||
})
|
||||
f.stop(r)
|
||||
|
||||
// With the controller down, drop the finalizer and delete the resource.
|
||||
doomed := &v1.KumaMonitor{}
|
||||
key := types.NamespacedName{Namespace: "default", Name: "doomed"}
|
||||
if err := f.k8s.Get(t.Context(), key, doomed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
doomed.Finalizers = nil
|
||||
if err := f.k8s.Update(t.Context(), doomed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.k8s.Delete(t.Context(), doomed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f.start(env)
|
||||
eventually(t, 30*time.Second, "orphan collected by GC", func() (bool, string) {
|
||||
m := byKey(f.owned())
|
||||
_, doomedLeft := m["default/doomed"]
|
||||
_, keeperLeft := m["default/keeper"]
|
||||
if doomedLeft {
|
||||
return false, "orphan still in Kuma"
|
||||
}
|
||||
if !keeperLeft {
|
||||
t.Fatal("GC deleted the monitor of a live resource")
|
||||
}
|
||||
return true, ""
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2EDriftRepaired(t *testing.T) {
|
||||
f, env := newE2E(t)
|
||||
f.start(env)
|
||||
f.apply(dns("probe", ""))
|
||||
|
||||
var id int64
|
||||
eventually(t, 30*time.Second, "monitor in Kuma", func() (bool, string) {
|
||||
m, ok := byKey(f.owned())["default/probe"]
|
||||
id = m.ID
|
||||
return ok, "monitor missing"
|
||||
})
|
||||
want := int64(byKey(f.owned())["default/probe"].Interval)
|
||||
|
||||
var live monitor.DNS
|
||||
if err := f.raw.GetMonitorAs(t.Context(), id, &live); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
live.Interval = want + 60
|
||||
if err := f.raw.UpdateMonitor(t.Context(), &live); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// The controller may repair the drift at any moment, so observe both
|
||||
// states in order instead of asserting either on its own.
|
||||
sawDrift := false
|
||||
eventually(t, 30*time.Second, "drift seen, then repaired by a resync", func() (bool, string) {
|
||||
var cur monitor.DNS
|
||||
if err := f.raw.GetMonitorAs(t.Context(), id, &cur); err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
switch {
|
||||
case !sawDrift && cur.Interval == want+60:
|
||||
sawDrift = true
|
||||
case !sawDrift:
|
||||
t.Fatalf("interval is %d: the drift was repaired before it was ever observed", cur.Interval)
|
||||
case cur.Interval == want:
|
||||
return true, ""
|
||||
}
|
||||
return false, fmt.Sprintf("interval is %d, want %d (drift seen: %v)", cur.Interval, want, sawDrift)
|
||||
})
|
||||
}
|
||||
|
||||
func (f *e2e) webhook(name string, isDefault bool) int64 {
|
||||
f.t.Helper()
|
||||
id, err := f.raw.CreateNotification(f.t.Context(), notification.Webhook{
|
||||
Base: notification.Base{Name: name, IsActive: true, IsDefault: isDefault},
|
||||
WebhookDetails: notification.WebhookDetails{
|
||||
WebhookURL: "http://127.0.0.1:9/" + name, WebhookContentType: "json",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
f.t.Fatalf("create notification %s: %v", name, err)
|
||||
}
|
||||
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) {
|
||||
f, env := newE2E(t)
|
||||
r := f.start(env)
|
||||
f.apply(dns("probe", ""))
|
||||
var id int64
|
||||
eventually(t, 30*time.Second, "monitor in Kuma", func() (bool, string) {
|
||||
m, ok := byKey(f.owned())["default/probe"]
|
||||
id = m.ID
|
||||
return ok, "monitor missing"
|
||||
})
|
||||
eventually(t, 30*time.Second, "status.monitorID recorded", func() (bool, string) {
|
||||
km := &v1.KumaMonitor{}
|
||||
if err := f.k8s.Get(t.Context(), types.NamespacedName{Namespace: "default", Name: "probe"}, km); err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
return km.Status.MonitorID == id, "status.monitorID not set"
|
||||
})
|
||||
f.stop(r)
|
||||
|
||||
tags, err := f.raw.GetTags(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var tagID int64
|
||||
for _, tg := range tags {
|
||||
if tg.Name == "kuma-sync" && (tagID == 0 || tg.ID < tagID) {
|
||||
tagID = tg.ID
|
||||
}
|
||||
}
|
||||
if err := f.raw.DeleteMonitorTagWithValue(t.Context(), tagID, id, "default/probe"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f.start(env)
|
||||
eventually(t, 30*time.Second, "Ready=False/NotOwned", func() (bool, string) {
|
||||
km := &v1.KumaMonitor{}
|
||||
if err := f.k8s.Get(t.Context(), types.NamespacedName{Namespace: "default", Name: "probe"}, km); err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
c := meta.FindStatusCondition(km.Status.Conditions, v1.ConditionReady)
|
||||
if c == nil {
|
||||
return false, "no Ready condition"
|
||||
}
|
||||
return c.Status == metav1.ConditionFalse && c.Reason == "NotOwned", "Ready is " + string(c.Status) + "/" + c.Reason
|
||||
})
|
||||
|
||||
// No twin appears, and the GC leaves the untagged monitor alone.
|
||||
deadline := time.Now().Add(2*gcInterval + 3*time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
all, err := f.raw.GetMonitors(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(all) != 1 || all[0].ID != id {
|
||||
t.Fatalf("Kuma holds %d monitors, want only the untagged original %d", len(all), id)
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/log"
|
||||
"sigs.k8s.io/controller-runtime/pkg/manager"
|
||||
|
||||
v1 "gitea.renner.uno/konairius/homelab/kuma-sync/api/v1alpha1"
|
||||
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/kumaclient"
|
||||
)
|
||||
|
||||
// +kubebuilder:rbac:groups="",resources=namespaces,verbs=get
|
||||
|
||||
// CacheSyncer is satisfied by the manager's cache.
|
||||
type CacheSyncer interface {
|
||||
WaitForCacheSync(ctx context.Context) bool
|
||||
}
|
||||
|
||||
// errListFailed marks a run that stopped before deleting anything because a
|
||||
// list failed.
|
||||
var errListFailed = errors.New("list failed, nothing deleted")
|
||||
|
||||
// minOrphanRuns is how many consecutive runs must see a monitor orphaned
|
||||
// before it is deleted, whatever the grace period.
|
||||
const minOrphanRuns = 2
|
||||
|
||||
// GC deletes owned Kuma monitors that no KumaMonitor accounts for, and
|
||||
// duplicates of one owner key beyond the lowest ID.
|
||||
//
|
||||
// A run deletes nothing unless both lists succeeded completely: any error from
|
||||
// ListOwned (conflicts and partial results included) or from listing
|
||||
// KumaMonitors aborts it. Start waits for Cache to sync first, so an empty
|
||||
// cache at startup never looks like "every resource was deleted".
|
||||
//
|
||||
// An orphan, confirmed absent on the API server, is deleted only after it was
|
||||
// seen orphaned in at least two consecutive runs spanning OrphanGrace; this
|
||||
// keeps a cluster rebuild, where the resources are applied long after the
|
||||
// controller starts, from wiping their monitors. The first-seen times live in
|
||||
// memory, so a restart starts the grace period over. Orphans in a namespace
|
||||
// that does not exist are never deleted. Duplicate extras are deleted at once.
|
||||
type GC struct {
|
||||
Client client.Client
|
||||
Kuma kumaclient.Client
|
||||
Interval time.Duration
|
||||
// OrphanGrace is how long a monitor must have been seen orphaned.
|
||||
OrphanGrace time.Duration
|
||||
// Cache is waited on before the first run; nil skips the wait.
|
||||
Cache CacheSyncer
|
||||
// APIReader reads straight from the API server. Every orphan is confirmed
|
||||
// against it before it is deleted.
|
||||
APIReader client.Reader
|
||||
// Now is the clock; nil means time.Now.
|
||||
Now func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
pending map[int64]orphanSeen
|
||||
}
|
||||
|
||||
type orphanSeen struct {
|
||||
since time.Time
|
||||
runs int
|
||||
}
|
||||
|
||||
var (
|
||||
_ manager.Runnable = (*GC)(nil)
|
||||
_ manager.LeaderElectionRunnable = (*GC)(nil)
|
||||
)
|
||||
|
||||
// NeedLeaderElection is false: there is a single replica, no election.
|
||||
func (g *GC) NeedLeaderElection() bool { return false }
|
||||
|
||||
func (g *GC) now() time.Time {
|
||||
if g.Now != nil {
|
||||
return g.Now()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
func (g *GC) Start(ctx context.Context) error {
|
||||
logger := log.FromContext(ctx).WithName("gc")
|
||||
if g.Cache != nil && !g.Cache.WaitForCacheSync(ctx) {
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
return errors.New("gc: cache did not sync")
|
||||
}
|
||||
interval := g.Interval
|
||||
if interval <= 0 {
|
||||
interval = 5 * time.Minute
|
||||
}
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
if err := g.RunOnce(ctx); err != nil {
|
||||
if errors.Is(err, errListFailed) {
|
||||
logger.Error(err, "gc run aborted")
|
||||
} else {
|
||||
logger.Error(err, "gc run finished with errors")
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// existsInAPI asks the API server, not the cache, whether the resource behind
|
||||
// an owner key exists. A malformed key is an error, never "absent".
|
||||
func (g *GC) existsInAPI(ctx context.Context, ownerKey string) (bool, error) {
|
||||
ns, name, ok := strings.Cut(ownerKey, "/")
|
||||
if !ok || ns == "" || name == "" || strings.Contains(name, "/") {
|
||||
return false, fmt.Errorf("malformed owner key %q", ownerKey)
|
||||
}
|
||||
err := g.APIReader.Get(ctx, types.NamespacedName{Namespace: ns, Name: name}, &v1.KumaMonitor{})
|
||||
switch {
|
||||
case err == nil:
|
||||
return true, nil
|
||||
case apierrors.IsNotFound(err):
|
||||
return false, nil
|
||||
default:
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
// namespaceExists reports whether the namespace of an owner key exists.
|
||||
func (g *GC) namespaceExists(ctx context.Context, ownerKey string) (bool, error) {
|
||||
ns, _, _ := strings.Cut(ownerKey, "/")
|
||||
err := g.APIReader.Get(ctx, types.NamespacedName{Name: ns}, &corev1.Namespace{})
|
||||
switch {
|
||||
case err == nil:
|
||||
return true, nil
|
||||
case apierrors.IsNotFound(err):
|
||||
return false, nil
|
||||
default:
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
// RunOnce performs one collection pass.
|
||||
func (g *GC) RunOnce(ctx context.Context) error {
|
||||
logger := log.FromContext(ctx).WithName("gc")
|
||||
if g.APIReader == nil {
|
||||
return errors.New("gc: APIReader is required to confirm orphans")
|
||||
}
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if g.pending == nil {
|
||||
g.pending = map[int64]orphanSeen{}
|
||||
}
|
||||
|
||||
// Kuma list must precede the cache list.
|
||||
owned, err := g.Kuma.ListOwned(ctx)
|
||||
if err != nil {
|
||||
clear(g.pending)
|
||||
return fmt.Errorf("%w: list owned monitors: %w", errListFailed, err)
|
||||
}
|
||||
var resources v1.KumaMonitorList
|
||||
if err := g.Client.List(ctx, &resources); err != nil {
|
||||
clear(g.pending)
|
||||
return fmt.Errorf("%w: list KumaMonitors: %w", errListFailed, err)
|
||||
}
|
||||
live := make(map[string]bool, len(resources.Items))
|
||||
for i := range resources.Items {
|
||||
live[OwnerKey(&resources.Items[i])] = true
|
||||
}
|
||||
|
||||
now := g.now()
|
||||
slices.SortFunc(owned, func(a, b kumaclient.Monitor) int { return int(a.ID - b.ID) })
|
||||
seen := map[string]bool{}
|
||||
candidates := map[int64]bool{}
|
||||
var errs []error
|
||||
del := func(m kumaclient.Monitor, reason string) bool {
|
||||
logger.Info("deleting monitor", "monitorID", m.ID, "ownerKey", m.OwnerKey, "reason", reason)
|
||||
if err := g.Kuma.Delete(ctx, m.ID); err != nil && !errors.Is(err, kumaclient.ErrNotFound) {
|
||||
errs = append(errs, fmt.Errorf("delete monitor %d (%s): %w", m.ID, m.OwnerKey, err))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
for _, m := range owned {
|
||||
keep := live[m.OwnerKey] && !seen[m.OwnerKey]
|
||||
seen[m.OwnerKey] = true
|
||||
if keep {
|
||||
continue
|
||||
}
|
||||
if live[m.OwnerKey] {
|
||||
del(m, "duplicate of a lower monitor ID")
|
||||
continue
|
||||
}
|
||||
|
||||
exists, err := g.existsInAPI(ctx, m.OwnerKey)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("confirm orphan monitor %d (%s): %w", m.ID, m.OwnerKey, err))
|
||||
continue
|
||||
}
|
||||
if exists {
|
||||
logger.Info("KumaMonitor exists on the API server, not an orphan", "monitorID", m.ID, "ownerKey", m.OwnerKey)
|
||||
continue
|
||||
}
|
||||
nsOK, err := g.namespaceExists(ctx, m.OwnerKey)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("check namespace of monitor %d (%s): %w", m.ID, m.OwnerKey, err))
|
||||
continue
|
||||
}
|
||||
if !nsOK {
|
||||
logger.Info("namespace does not exist, leaving the monitor alone", "monitorID", m.ID, "ownerKey", m.OwnerKey)
|
||||
continue
|
||||
}
|
||||
|
||||
candidates[m.ID] = true
|
||||
st, ok := g.pending[m.ID]
|
||||
if !ok {
|
||||
st.since = now
|
||||
}
|
||||
st.runs++
|
||||
g.pending[m.ID] = st
|
||||
if st.runs < minOrphanRuns || now.Sub(st.since) < g.OrphanGrace {
|
||||
logger.Info("orphan candidate, waiting out the grace period", "monitorID", m.ID, "ownerKey", m.OwnerKey,
|
||||
"runs", st.runs, "orphanFor", now.Sub(st.since).String(), "grace", g.OrphanGrace.String())
|
||||
continue
|
||||
}
|
||||
if del(m, "no matching KumaMonitor") {
|
||||
delete(g.pending, m.ID)
|
||||
}
|
||||
}
|
||||
// Whatever stopped being an orphan, or vanished, loses its history.
|
||||
for id := range g.pending {
|
||||
if !candidates[id] {
|
||||
delete(g.pending, id)
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client/interceptor"
|
||||
|
||||
v1 "gitea.renner.uno/konairius/homelab/kuma-sync/api/v1alpha1"
|
||||
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/kumaclient"
|
||||
)
|
||||
|
||||
type gcEnv struct {
|
||||
gc *GC
|
||||
kuma *kumaclient.Fake
|
||||
cnt *counting
|
||||
}
|
||||
|
||||
func newGCEnv(t *testing.T, funcs *interceptor.Funcs, objs ...client.Object) *gcEnv {
|
||||
t.Helper()
|
||||
s := gcScheme(t)
|
||||
b := fake.NewClientBuilder().WithScheme(s).WithObjects(append(objs, namespace("default"))...)
|
||||
if funcs != nil {
|
||||
b = b.WithInterceptorFuncs(*funcs)
|
||||
}
|
||||
kf := kumaclient.NewFake()
|
||||
cnt := &counting{Client: kf}
|
||||
c := b.Build()
|
||||
return &gcEnv{gc: &GC{Client: c, APIReader: c, Kuma: cnt, Interval: time.Hour}, kuma: kf, cnt: cnt}
|
||||
}
|
||||
|
||||
func gcScheme(t *testing.T) *runtime.Scheme {
|
||||
t.Helper()
|
||||
s := runtime.NewScheme()
|
||||
if err := v1.AddToScheme(s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := corev1.AddToScheme(s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func namespace(name string) *corev1.Namespace {
|
||||
return &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: name}}
|
||||
}
|
||||
|
||||
// apiServerWith is a fake API server that always has the default namespace.
|
||||
func apiServerWith(t *testing.T, objs ...client.Object) client.Reader {
|
||||
t.Helper()
|
||||
return fake.NewClientBuilder().WithScheme(gcScheme(t)).WithObjects(append(objs, namespace("default"))...).Build()
|
||||
}
|
||||
|
||||
// runs calls RunOnce n times and returns the last error.
|
||||
func (e *gcEnv) runs(t *testing.T, n int) error {
|
||||
t.Helper()
|
||||
var err error
|
||||
for range n {
|
||||
err = e.gc.RunOnce(t.Context())
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func TestGCConfirmsWithAPIReaderBeforeOrphanDelete(t *testing.T) {
|
||||
e := newGCEnv(t, nil) // stale cache: knows no resources
|
||||
id := owned(t, e, "default/a")
|
||||
e.gc.APIReader = apiServerWith(t, httpMonitor("a"))
|
||||
if err := e.gc.RunOnce(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e.kuma.Get(id) == nil || e.cnt.deletes != 0 {
|
||||
t.Error("monitor of a resource that exists on the API server was deleted")
|
||||
}
|
||||
|
||||
// Truly absent on the API server: deleted.
|
||||
e.gc.APIReader = apiServerWith(t)
|
||||
if err := e.runs(t, 2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e.kuma.Get(id) != nil {
|
||||
t.Error("confirmed orphan survived")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGCSkipsOnAPIReaderError(t *testing.T) {
|
||||
e := newGCEnv(t, nil)
|
||||
id := owned(t, e, "default/gone")
|
||||
s := runtime.NewScheme()
|
||||
if err := v1.AddToScheme(s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.gc.APIReader = fake.NewClientBuilder().WithScheme(s).WithInterceptorFuncs(interceptor.Funcs{
|
||||
Get: func(context.Context, client.WithWatch, client.ObjectKey, client.Object, ...client.GetOption) error {
|
||||
return errors.New("apiserver unavailable")
|
||||
},
|
||||
}).Build()
|
||||
if err := e.gc.RunOnce(t.Context()); err == nil {
|
||||
t.Fatal("expected the API reader error to be recorded")
|
||||
}
|
||||
if e.kuma.Get(id) == nil || e.cnt.deletes != 0 {
|
||||
t.Error("deleted without confirmation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGCMalformedOwnerKeyNeverDeleted(t *testing.T) {
|
||||
e := newGCEnv(t, nil)
|
||||
var ids []int64
|
||||
for _, key := range []string{"noslash", "/name", "ns/", "a/b/c"} {
|
||||
ids = append(ids, owned(t, e, key))
|
||||
}
|
||||
if err := e.gc.RunOnce(t.Context()); err == nil {
|
||||
t.Fatal("expected errors for malformed keys")
|
||||
}
|
||||
for _, id := range ids {
|
||||
if e.kuma.Get(id) == nil {
|
||||
t.Errorf("monitor %d with a malformed key was deleted", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func owned(t *testing.T, e *gcEnv, key string) int64 {
|
||||
t.Helper()
|
||||
id, err := e.kuma.Create(t.Context(), kumaclient.Monitor{Type: "group", Name: key, OwnerKey: key})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func TestGCDeletesOrphans(t *testing.T) {
|
||||
e := newGCEnv(t, nil, httpMonitor("live"))
|
||||
live := owned(t, e, "default/live")
|
||||
gone := owned(t, e, "default/gone")
|
||||
if err := e.runs(t, 2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e.kuma.Get(gone) != nil {
|
||||
t.Error("orphan survived")
|
||||
}
|
||||
if e.kuma.Get(live) == nil {
|
||||
t.Error("live monitor deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGCIgnoresUntagged(t *testing.T) {
|
||||
e := newGCEnv(t, nil)
|
||||
foreign := e.kuma.AddForeign(kumaclient.Monitor{Name: "foreign"})
|
||||
if err := e.gc.RunOnce(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e.kuma.Get(foreign) == nil || e.cnt.deletes != 0 {
|
||||
t.Errorf("foreign monitor touched: deletes=%d", e.cnt.deletes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGCKeepsLiveResources(t *testing.T) {
|
||||
e := newGCEnv(t, nil, httpMonitor("a"), httpMonitor("b"))
|
||||
owned(t, e, "default/a")
|
||||
owned(t, e, "default/b")
|
||||
if err := e.gc.RunOnce(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e.cnt.deletes != 0 {
|
||||
t.Errorf("deletes = %d", e.cnt.deletes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGCKeepsResourcesBeingDeleted(t *testing.T) {
|
||||
e := newGCEnv(t, nil, deleting(httpMonitor("a")))
|
||||
owned(t, e, "default/a")
|
||||
if err := e.gc.RunOnce(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e.cnt.deletes != 0 {
|
||||
t.Errorf("the finalizer path owns this deletion, deletes = %d", e.cnt.deletes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGCAbortsOnListError(t *testing.T) {
|
||||
e := newGCEnv(t, &interceptor.Funcs{
|
||||
List: func(context.Context, client.WithWatch, client.ObjectList, ...client.ListOption) error {
|
||||
return errors.New("cache not ready")
|
||||
},
|
||||
})
|
||||
owned(t, e, "default/gone")
|
||||
if err := e.gc.RunOnce(t.Context()); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if e.cnt.deletes != 0 {
|
||||
t.Errorf("deletes = %d, want 0", e.cnt.deletes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGCAbortsOnKumaListError(t *testing.T) {
|
||||
e := newGCEnv(t, nil)
|
||||
owned(t, e, "default/gone")
|
||||
e.kuma.SetErrs(errors.New("boom"), nil, nil, nil)
|
||||
if err := e.gc.RunOnce(t.Context()); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if e.cnt.deletes != 0 {
|
||||
t.Errorf("deletes = %d, want 0", e.cnt.deletes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGCNoDeletesOnPartialList(t *testing.T) {
|
||||
e := newGCEnv(t, nil)
|
||||
owned(t, e, "default/gone")
|
||||
e.kuma.SetListPartial(errors.Join(&kumaclient.ConflictError{MonitorID: 9, Keys: []string{"default/x", "default/y"}}))
|
||||
if err := e.gc.RunOnce(t.Context()); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if e.cnt.deletes != 0 {
|
||||
t.Errorf("deletes = %d, want 0", e.cnt.deletes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGCDeletesDuplicateExtras(t *testing.T) {
|
||||
e := newGCEnv(t, nil, httpMonitor("a"))
|
||||
first := owned(t, e, "default/a")
|
||||
second := owned(t, e, "default/a")
|
||||
third := owned(t, e, "default/a")
|
||||
if err := e.gc.RunOnce(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e.kuma.Get(first) == nil {
|
||||
t.Error("lowest ID must stay")
|
||||
}
|
||||
if e.kuma.Get(second) != nil || e.kuma.Get(third) != nil {
|
||||
t.Error("extras must be deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGCDeleteNotFoundIsSuccess(t *testing.T) {
|
||||
e := newGCEnv(t, nil)
|
||||
owned(t, e, "default/gone")
|
||||
e.kuma.SetErrs(nil, nil, nil, fmt.Errorf("delete: %w", kumaclient.ErrNotFound))
|
||||
if err := e.runs(t, 2); err != nil {
|
||||
t.Fatalf("ErrNotFound must count as success: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// waiter is a CacheSyncer whose result the test controls.
|
||||
type waiter struct {
|
||||
release chan struct{}
|
||||
result bool
|
||||
started atomic.Bool
|
||||
}
|
||||
|
||||
func (w *waiter) WaitForCacheSync(ctx context.Context) bool {
|
||||
w.started.Store(true)
|
||||
select {
|
||||
case <-w.release:
|
||||
return w.result
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func TestGCWaitsForCacheSync(t *testing.T) {
|
||||
e := newGCEnv(t, nil)
|
||||
owned(t, e, "default/gone") // would look like an orphan against an empty cache
|
||||
w := &waiter{release: make(chan struct{}), result: true}
|
||||
e.gc.Cache = w
|
||||
e.gc.Interval = 20 * time.Millisecond
|
||||
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- e.gc.Start(ctx) }()
|
||||
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for !w.started.Load() && time.Now().Before(deadline) {
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
if e.kuma.Get(1) == nil {
|
||||
t.Fatal("GC ran before the cache synced")
|
||||
}
|
||||
|
||||
close(w.release)
|
||||
deadline = time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) && e.kuma.Get(1) != nil {
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
if e.kuma.Get(1) != nil {
|
||||
t.Error("GC did not run after the cache synced")
|
||||
}
|
||||
cancel()
|
||||
if err := <-done; err != nil {
|
||||
t.Errorf("Start returned %v on shutdown", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGCOrphanGraceDelaysDelete(t *testing.T) {
|
||||
e := newGCEnv(t, nil)
|
||||
id := owned(t, e, "default/gone")
|
||||
e.gc.OrphanGrace = time.Hour
|
||||
clock := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
e.gc.Now = func() time.Time { return clock }
|
||||
|
||||
for _, step := range []struct {
|
||||
at time.Duration
|
||||
deleted bool
|
||||
}{
|
||||
{0, false}, // first sighting
|
||||
{30 * time.Minute, false}, // second run, grace not over
|
||||
{59 * time.Minute, false}, // still inside the grace period
|
||||
{60 * time.Minute, true}, // third run, a full hour after the first sighting
|
||||
} {
|
||||
clock = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC).Add(step.at)
|
||||
if err := e.gc.RunOnce(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gone := e.kuma.Get(id) == nil; gone != step.deleted {
|
||||
t.Fatalf("at +%v: deleted = %v, want %v", step.at, gone, step.deleted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGCOrphanGraceResetsWhenResourceReturns(t *testing.T) {
|
||||
e := newGCEnv(t, nil)
|
||||
id := owned(t, e, "default/a")
|
||||
e.gc.OrphanGrace = time.Hour
|
||||
base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
var clock time.Time
|
||||
e.gc.Now = func() time.Time { return clock }
|
||||
run := func(at time.Duration) {
|
||||
t.Helper()
|
||||
clock = base.Add(at)
|
||||
if err := e.gc.RunOnce(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
run(0) // orphan seen
|
||||
km := httpMonitor("a")
|
||||
if err := e.gc.Client.Create(t.Context(), km); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run(40 * time.Minute) // resource is back: history dropped
|
||||
if err := e.gc.Client.Delete(t.Context(), km); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run(61 * time.Minute) // orphan again, first sighting of the new streak
|
||||
run(90 * time.Minute) // only 29 minutes into the new streak
|
||||
if e.kuma.Get(id) == nil {
|
||||
t.Fatal("deleted although the grace period restarted when the resource returned")
|
||||
}
|
||||
run(122 * time.Minute) // 61 minutes into the new streak
|
||||
if e.kuma.Get(id) != nil {
|
||||
t.Error("orphan survived a full grace period")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGCSkipsMissingNamespace(t *testing.T) {
|
||||
e := newGCEnv(t, nil)
|
||||
id := owned(t, e, "ghost/x")
|
||||
if err := e.runs(t, 3); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e.kuma.Get(id) == nil || e.cnt.deletes != 0 {
|
||||
t.Fatal("monitor of a missing namespace was deleted")
|
||||
}
|
||||
if err := e.gc.Client.Create(t.Context(), namespace("ghost")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := e.runs(t, 2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e.kuma.Get(id) != nil {
|
||||
t.Error("orphan in an existing namespace survived")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGCStartFailsWhenCacheNeverSyncs(t *testing.T) {
|
||||
e := newGCEnv(t, nil)
|
||||
owned(t, e, "default/gone")
|
||||
released := make(chan struct{})
|
||||
close(released)
|
||||
e.gc.Cache = &waiter{release: released, result: false}
|
||||
if err := e.gc.Start(t.Context()); err == nil {
|
||||
t.Fatal("expected an error when the cache does not sync")
|
||||
}
|
||||
if e.cnt.deletes != 0 {
|
||||
t.Error("GC deleted without a synced cache")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
// Package controller reconciles KumaMonitor resources into Uptime Kuma.
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
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 (
|
||||
Finalizer = "uptime.renner.uno/kuma-sync"
|
||||
|
||||
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
|
||||
maxParentDepth = 10
|
||||
)
|
||||
|
||||
// +kubebuilder:rbac:groups=uptime.renner.uno,resources=kumamonitors,verbs=get;list;watch;update;patch
|
||||
// +kubebuilder:rbac:groups=uptime.renner.uno,resources=kumamonitors/status,verbs=get;update;patch
|
||||
// +kubebuilder:rbac:groups=uptime.renner.uno,resources=kumamonitors/finalizers,verbs=update
|
||||
// +kubebuilder:rbac:groups="",resources=events,verbs=create;patch
|
||||
|
||||
// Reconciler syncs KumaMonitor resources to Kuma.
|
||||
type Reconciler struct {
|
||||
Client client.Client
|
||||
Kuma kumaclient.Client
|
||||
// Resync is how often a healthy resource is reconciled again, which repairs
|
||||
// drift made in Kuma. 0 means 5 minutes.
|
||||
Resync time.Duration
|
||||
}
|
||||
|
||||
func (r *Reconciler) resync() time.Duration {
|
||||
if r.Resync > 0 {
|
||||
return r.Resync
|
||||
}
|
||||
return resyncPeriod
|
||||
}
|
||||
|
||||
// SetupWithManager ignores status-only updates, which keeps failure backoff
|
||||
// intact. Deletion still reconciles: setting deletionTimestamp bumps
|
||||
// metadata.generation.
|
||||
func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error {
|
||||
return ctrl.NewControllerManagedBy(mgr).
|
||||
For(&v1.KumaMonitor{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})).
|
||||
Named("kumamonitor").
|
||||
Complete(r)
|
||||
}
|
||||
|
||||
// failure is a Ready=False outcome. The message is fixed per reason; the cause
|
||||
// goes to the log and the returned error only.
|
||||
type failure struct {
|
||||
reason string
|
||||
message string
|
||||
err error
|
||||
}
|
||||
|
||||
const (
|
||||
msgKumaUnavailable = "Kuma request failed"
|
||||
msgListIncomplete = "monitor list incomplete: owner conflict on another monitor"
|
||||
msgOwnerTagMissing = "kuma-sync tag missing in Kuma"
|
||||
msgOwnerConflict = "monitor carries conflicting kuma-sync tag values"
|
||||
msgPossibleOrphan = "create outcome unknown, an untagged monitor may exist in Kuma"
|
||||
msgDuplicateOwner = "several monitors carry this owner key; syncing the lowest ID"
|
||||
msgParentPending = "parent has no monitor ID yet"
|
||||
msgParentNotGroup = "parent must be of type group"
|
||||
msgParentCycle = "parentRef forms a cycle"
|
||||
msgParentTooDeep = "parentRef chain is too deep"
|
||||
msgSynced = "monitor in sync"
|
||||
msgNotOwned = "previous monitor exists without our ownership tag; restore the tag or delete it in Kuma"
|
||||
)
|
||||
|
||||
func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
|
||||
km := &v1.KumaMonitor{}
|
||||
if err := r.Client.Get(ctx, req.NamespacedName, km); err != nil {
|
||||
return ctrl.Result{}, client.IgnoreNotFound(err)
|
||||
}
|
||||
if !km.DeletionTimestamp.IsZero() {
|
||||
return r.reconcileDelete(ctx, km)
|
||||
}
|
||||
if controllerutil.AddFinalizer(km, Finalizer) {
|
||||
if err := r.Client.Update(ctx, km); err != nil {
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
}
|
||||
|
||||
desired := Desired(km)
|
||||
if km.Spec.ParentRef != nil {
|
||||
p, err := r.resolveParent(ctx, km)
|
||||
if err != nil {
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
if p.reason != "" {
|
||||
if err := r.setReady(ctx, km, metav1.ConditionFalse, p.reason, p.message, nil); err != nil {
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
return ctrl.Result{RequeueAfter: p.requeue}, nil
|
||||
}
|
||||
desired.Parent = &p.id
|
||||
}
|
||||
|
||||
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 {
|
||||
// The monitor this resource synced before may have lost its tag; a
|
||||
// second one would then sit beside it. An ID now carrying another
|
||||
// owner's key was reused by Kuma, so ours is gone.
|
||||
if prev := km.Status.MonitorID; prev != 0 {
|
||||
found, key, err := r.Kuma.Probe(ctx, prev)
|
||||
switch {
|
||||
case err != nil:
|
||||
return r.fail(ctx, km, &failure{ReasonKumaUnavailable, msgKumaUnavailable, err})
|
||||
case found && key == "":
|
||||
log.FromContext(ctx).Error(nil, "previous monitor exists without our ownership tag, not creating another",
|
||||
"ownerKey", desired.OwnerKey, "monitorID", prev, "tagValue", key)
|
||||
err := r.setReady(ctx, km, metav1.ConditionFalse, ReasonNotOwned, msgNotOwned, nil)
|
||||
return ctrl.Result{RequeueAfter: r.resync()}, err
|
||||
case found && key == desired.OwnerKey:
|
||||
return r.fail(ctx, km, &failure{ReasonKumaUnavailable, msgKumaUnavailable,
|
||||
fmt.Errorf("monitor %d carries our key but is missing from the owned list", prev)})
|
||||
}
|
||||
}
|
||||
var err error
|
||||
id, err = r.Kuma.Create(ctx, desired)
|
||||
if err != nil {
|
||||
if errors.Is(err, kumaclient.ErrPossibleOrphan) {
|
||||
log.FromContext(ctx).Error(err, "create outcome unknown, an untagged monitor may exist in Kuma",
|
||||
"ownerKey", desired.OwnerKey)
|
||||
return r.fail(ctx, km, &failure{ReasonPossibleOrphan, msgPossibleOrphan, err})
|
||||
}
|
||||
return r.fail(ctx, km, &failure{ReasonKumaUnavailable, msgKumaUnavailable, err})
|
||||
}
|
||||
} else {
|
||||
id = res.mon.ID
|
||||
desired.ID = id
|
||||
if !reflect.DeepEqual(desired.Normalized(), *res.mon) {
|
||||
if err := r.Kuma.Update(ctx, desired); err != nil {
|
||||
return r.fail(ctx, km, &failure{ReasonKumaUnavailable, msgKumaUnavailable, err})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(res.ids) > 1 {
|
||||
log.FromContext(ctx).Error(nil, "several Kuma monitors carry the same owner key, syncing the lowest ID",
|
||||
"ownerKey", desired.OwnerKey, "monitorIDs", res.ids)
|
||||
err := r.setReady(ctx, km, metav1.ConditionFalse, ReasonDuplicateOwner, msgDuplicateOwner, &id)
|
||||
return ctrl.Result{RequeueAfter: r.resync()}, err
|
||||
}
|
||||
if err := r.setReady(ctx, km, metav1.ConditionTrue, ReasonSynced, msgSynced, &id); err != nil {
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
return ctrl.Result{RequeueAfter: r.resync()}, nil
|
||||
}
|
||||
|
||||
func (r *Reconciler) reconcileDelete(ctx context.Context, km *v1.KumaMonitor) (ctrl.Result, error) {
|
||||
if !controllerutil.ContainsFinalizer(km, Finalizer) {
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
res, fail := r.lookup(ctx, OwnerKey(km))
|
||||
if fail != nil {
|
||||
return r.fail(ctx, km, fail)
|
||||
}
|
||||
// Remove every monitor carrying the key, duplicates included.
|
||||
for _, id := range res.ids {
|
||||
if err := r.Kuma.Delete(ctx, id); err != nil && !errors.Is(err, kumaclient.ErrNotFound) {
|
||||
return r.fail(ctx, km, &failure{ReasonKumaUnavailable, msgKumaUnavailable, err})
|
||||
}
|
||||
}
|
||||
controllerutil.RemoveFinalizer(km, Finalizer)
|
||||
return ctrl.Result{}, r.Client.Update(ctx, km)
|
||||
}
|
||||
|
||||
type lookupResult struct {
|
||||
mon *kumaclient.Monitor // lowest ID carrying the key
|
||||
ids []int64 // all IDs carrying the key, ascending
|
||||
}
|
||||
|
||||
// lookup finds the monitors owned by key. A failed or incomplete list is a
|
||||
// failure, never "absent": the one exception is a conflict on other monitors
|
||||
// while the partial list already holds this key, which may be adopted but
|
||||
// never leads to a create.
|
||||
func (r *Reconciler) lookup(ctx context.Context, key string) (lookupResult, *failure) {
|
||||
list, err := r.Kuma.ListOwned(ctx)
|
||||
var res lookupResult
|
||||
for i := range list {
|
||||
if list[i].OwnerKey != key {
|
||||
continue
|
||||
}
|
||||
res.ids = append(res.ids, list[i].ID)
|
||||
if res.mon == nil || list[i].ID < res.mon.ID {
|
||||
res.mon = &list[i]
|
||||
}
|
||||
}
|
||||
slices.Sort(res.ids)
|
||||
if err == nil {
|
||||
return res, nil
|
||||
}
|
||||
if errors.Is(err, kumaclient.ErrOwnerTagMissing) {
|
||||
return res, &failure{ReasonOwnerTagMissing, msgOwnerTagMissing, err}
|
||||
}
|
||||
conflicts, only := kumaclient.Conflicts(err)
|
||||
for _, c := range conflicts {
|
||||
if slices.Contains(c.Keys, key) {
|
||||
return res, &failure{ReasonOwnerConflict, msgOwnerConflict, err}
|
||||
}
|
||||
}
|
||||
if !only {
|
||||
return res, &failure{ReasonKumaUnavailable, msgKumaUnavailable, err}
|
||||
}
|
||||
if res.mon != nil {
|
||||
log.FromContext(ctx).Error(err, "owner conflict on another monitor, updating the monitor already found",
|
||||
"ownerKey", key)
|
||||
return res, nil
|
||||
}
|
||||
return res, &failure{ReasonKumaUnavailable, msgListIncomplete, err}
|
||||
}
|
||||
|
||||
type parentOutcome struct {
|
||||
id int64
|
||||
reason string // empty: resolved
|
||||
message string
|
||||
requeue time.Duration
|
||||
}
|
||||
|
||||
// resolveParent checks the whole parentRef chain: every ancestor must exist
|
||||
// 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
|
||||
}
|
||||
pending := parentOutcome{reason: ReasonParentPending, message: msgParentPending, requeue: parentRetry}
|
||||
|
||||
visited := map[string]bool{km.Name: true}
|
||||
cur := km
|
||||
var directID int64
|
||||
for depth := 0; cur.Spec.ParentRef != nil; depth++ {
|
||||
if depth >= maxParentDepth {
|
||||
return invalid(msgParentTooDeep)
|
||||
}
|
||||
name := cur.Spec.ParentRef.Name
|
||||
if visited[name] {
|
||||
return invalid(msgParentCycle)
|
||||
}
|
||||
visited[name] = true
|
||||
parent := &v1.KumaMonitor{}
|
||||
err := r.Client.Get(ctx, types.NamespacedName{Namespace: km.Namespace, Name: name}, parent)
|
||||
if apierrors.IsNotFound(err) {
|
||||
return pending, nil
|
||||
}
|
||||
if err != nil {
|
||||
return parentOutcome{}, err
|
||||
}
|
||||
if parent.Spec.Type != "group" {
|
||||
return invalid(msgParentNotGroup)
|
||||
}
|
||||
if depth == 0 {
|
||||
directID = parent.Status.MonitorID
|
||||
}
|
||||
cur = parent
|
||||
}
|
||||
if directID == 0 {
|
||||
return pending, nil
|
||||
}
|
||||
return parentOutcome{id: directID}, nil
|
||||
}
|
||||
|
||||
// fail records a Ready=False outcome and returns its cause so the request is
|
||||
// requeued with backoff.
|
||||
func (r *Reconciler) fail(ctx context.Context, km *v1.KumaMonitor, f *failure) (ctrl.Result, error) {
|
||||
log.FromContext(ctx).Info("reconcile failed", "reason", f.reason, "error", f.err.Error())
|
||||
if serr := r.setReady(ctx, km, 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 monitor ID when given, through
|
||||
// the status subresource. It writes nothing if the status is unchanged.
|
||||
func (r *Reconciler) setReady(
|
||||
ctx context.Context, km *v1.KumaMonitor, status metav1.ConditionStatus, reason, msg string, monitorID *int64,
|
||||
) error {
|
||||
orig := km.Status.DeepCopy()
|
||||
if monitorID != nil {
|
||||
km.Status.MonitorID = *monitorID
|
||||
km.Status.ObservedGeneration = km.Generation
|
||||
}
|
||||
meta.SetStatusCondition(&km.Status.Conditions, metav1.Condition{
|
||||
Type: v1.ConditionReady,
|
||||
Status: status,
|
||||
Reason: reason,
|
||||
Message: msg,
|
||||
ObservedGeneration: km.Generation,
|
||||
})
|
||||
if reflect.DeepEqual(*orig, km.Status) {
|
||||
return nil
|
||||
}
|
||||
return r.Client.Status().Update(ctx, km)
|
||||
}
|
||||
@@ -0,0 +1,692 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
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/runtime"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
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"
|
||||
)
|
||||
|
||||
// counting records calls on top of the Kuma fake.
|
||||
type counting struct {
|
||||
kumaclient.Client
|
||||
creates, updates, deletes int
|
||||
}
|
||||
|
||||
func (c *counting) Create(ctx context.Context, m kumaclient.Monitor) (int64, error) {
|
||||
c.creates++
|
||||
return c.Client.Create(ctx, m)
|
||||
}
|
||||
|
||||
func (c *counting) Update(ctx context.Context, m kumaclient.Monitor) error {
|
||||
c.updates++
|
||||
return c.Client.Update(ctx, m)
|
||||
}
|
||||
|
||||
func (c *counting) Delete(ctx context.Context, id int64) error {
|
||||
c.deletes++
|
||||
return c.Client.Delete(ctx, id)
|
||||
}
|
||||
|
||||
type env struct {
|
||||
r *Reconciler
|
||||
k8s client.Client
|
||||
kuma *kumaclient.Fake
|
||||
cnt *counting
|
||||
}
|
||||
|
||||
func newEnv(t *testing.T, objs ...client.Object) *env {
|
||||
t.Helper()
|
||||
s := runtime.NewScheme()
|
||||
if err := v1.AddToScheme(s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
k8s := fake.NewClientBuilder().WithScheme(s).WithObjects(objs...).WithStatusSubresource(&v1.KumaMonitor{}).Build()
|
||||
kf := kumaclient.NewFake()
|
||||
cnt := &counting{Client: kf}
|
||||
return &env{r: &Reconciler{Client: k8s, Kuma: cnt}, k8s: k8s, kuma: kf, cnt: cnt}
|
||||
}
|
||||
|
||||
func httpMonitor(name string) *v1.KumaMonitor {
|
||||
return &v1.KumaMonitor{
|
||||
ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: name},
|
||||
Spec: v1.KumaMonitorSpec{
|
||||
Type: "http", DisplayName: name, Interval: 60, RetryInterval: 60,
|
||||
HTTP: &v1.HTTPSpec{URL: "https://example.com"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (e *env) reconcile(t *testing.T, name string) (ctrl.Result, error) {
|
||||
t.Helper()
|
||||
return e.r.Reconcile(t.Context(), ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "default", Name: name}})
|
||||
}
|
||||
|
||||
func (e *env) get(t *testing.T, name string) *v1.KumaMonitor {
|
||||
t.Helper()
|
||||
km := &v1.KumaMonitor{}
|
||||
if err := e.k8s.Get(t.Context(), types.NamespacedName{Namespace: "default", Name: name}, km); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return km
|
||||
}
|
||||
|
||||
func ready(t *testing.T, km *v1.KumaMonitor) *metav1.Condition {
|
||||
t.Helper()
|
||||
c := meta.FindStatusCondition(km.Status.Conditions, v1.ConditionReady)
|
||||
if c == nil {
|
||||
t.Fatal("no Ready condition")
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func TestReconcileCreates(t *testing.T) {
|
||||
e := newEnv(t, httpMonitor("a"))
|
||||
res, err := e.reconcile(t, "a")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.RequeueAfter != 5*time.Minute {
|
||||
t.Errorf("RequeueAfter = %v", res.RequeueAfter)
|
||||
}
|
||||
if e.cnt.creates != 1 {
|
||||
t.Fatalf("creates = %d", e.cnt.creates)
|
||||
}
|
||||
km := e.get(t, "a")
|
||||
if km.Status.MonitorID == 0 || km.Status.ObservedGeneration != km.Generation {
|
||||
t.Errorf("status = %+v", km.Status)
|
||||
}
|
||||
if c := ready(t, km); c.Status != metav1.ConditionTrue {
|
||||
t.Errorf("Ready = %+v", c)
|
||||
}
|
||||
if len(km.Finalizers) != 1 {
|
||||
t.Errorf("finalizers = %v", km.Finalizers)
|
||||
}
|
||||
if got := e.kuma.Get(km.Status.MonitorID); got == nil || got.OwnerKey != "default/a" {
|
||||
t.Errorf("kuma monitor = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileAdoptsExisting(t *testing.T) {
|
||||
km := httpMonitor("a")
|
||||
e := newEnv(t, km)
|
||||
existing := Desired(km)
|
||||
id, err := e.kuma.Create(t.Context(), existing)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := e.reconcile(t, "a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e.cnt.creates != 0 || e.cnt.updates != 0 {
|
||||
t.Errorf("creates=%d updates=%d, want 0 and 0", e.cnt.creates, e.cnt.updates)
|
||||
}
|
||||
if got := e.get(t, "a").Status.MonitorID; got != id {
|
||||
t.Errorf("monitorID = %d, want %d", got, id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileUpdatesDrift(t *testing.T) {
|
||||
km := httpMonitor("a")
|
||||
e := newEnv(t, km)
|
||||
drifted := Desired(km)
|
||||
drifted.Interval = 30
|
||||
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); got.Interval != 60 {
|
||||
t.Errorf("interval = %d", got.Interval)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileNoopWhenEqual(t *testing.T) {
|
||||
km := httpMonitor("a")
|
||||
e := newEnv(t, km)
|
||||
e.kuma.Create(t.Context(), Desired(km))
|
||||
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", e.cnt.updates, e.cnt.creates)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileNormalizedNoSpuriousUpdate(t *testing.T) {
|
||||
km := httpMonitor("a")
|
||||
km.Spec.HTTP.AcceptedStatusCodes = nil
|
||||
km.Spec.Timeout = nil
|
||||
e := newEnv(t, km)
|
||||
for range 2 {
|
||||
if _, err := e.reconcile(t, "a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if e.cnt.creates != 1 || e.cnt.updates != 0 {
|
||||
t.Fatalf("creates=%d updates=%d, want 1 and 0", e.cnt.creates, e.cnt.updates)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileWaitsForParent(t *testing.T) {
|
||||
group := &v1.KumaMonitor{
|
||||
ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "grp"},
|
||||
Spec: v1.KumaMonitorSpec{Type: "group", DisplayName: "grp", Interval: 60, RetryInterval: 60},
|
||||
}
|
||||
child := httpMonitor("child")
|
||||
child.Spec.ParentRef = &v1.LocalRef{Name: "grp"}
|
||||
e := newEnv(t, group, child)
|
||||
|
||||
for _, step := range []string{"parent without monitorID", "missing parent"} {
|
||||
if step == "missing parent" {
|
||||
if err := e.k8s.Delete(t.Context(), group); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
res, err := e.reconcile(t, "child")
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", step, err)
|
||||
}
|
||||
if res.RequeueAfter != 10*time.Second {
|
||||
t.Errorf("%s: RequeueAfter = %v", step, res.RequeueAfter)
|
||||
}
|
||||
if c := ready(t, e.get(t, "child")); c.Status != metav1.ConditionFalse || c.Reason != ReasonParentPending {
|
||||
t.Errorf("%s: Ready = %+v", step, c)
|
||||
}
|
||||
}
|
||||
if e.cnt.creates != 0 {
|
||||
t.Errorf("creates = %d", e.cnt.creates)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileResolvesParent(t *testing.T) {
|
||||
group := &v1.KumaMonitor{
|
||||
ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "grp"},
|
||||
Spec: v1.KumaMonitorSpec{Type: "group", DisplayName: "grp", Interval: 60, RetryInterval: 60},
|
||||
Status: v1.KumaMonitorStatus{MonitorID: 42},
|
||||
}
|
||||
child := httpMonitor("child")
|
||||
child.Spec.ParentRef = &v1.LocalRef{Name: "grp"}
|
||||
e := newEnv(t, group, child)
|
||||
if _, err := e.reconcile(t, "child"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := e.kuma.Get(e.get(t, "child").Status.MonitorID)
|
||||
if got == nil || got.Parent == nil || *got.Parent != 42 {
|
||||
t.Fatalf("parent = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func deleting(km *v1.KumaMonitor) *v1.KumaMonitor {
|
||||
now := metav1.Now()
|
||||
km.DeletionTimestamp = &now
|
||||
km.Finalizers = []string{Finalizer}
|
||||
return km
|
||||
}
|
||||
|
||||
func TestReconcileDeleteRemovesMonitorAndFinalizer(t *testing.T) {
|
||||
km := deleting(httpMonitor("a"))
|
||||
e := newEnv(t, km)
|
||||
id, _ := e.kuma.Create(t.Context(), Desired(km))
|
||||
if _, err := e.reconcile(t, "a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e.kuma.Get(id) != nil {
|
||||
t.Error("kuma monitor not deleted")
|
||||
}
|
||||
err := e.k8s.Get(t.Context(), client.ObjectKeyFromObject(km), &v1.KumaMonitor{})
|
||||
if !apierrors.IsNotFound(err) {
|
||||
t.Errorf("resource should be gone after finalizer removal, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileDeleteToleratesMissingMonitor(t *testing.T) {
|
||||
km := deleting(httpMonitor("a"))
|
||||
e := newEnv(t, km)
|
||||
if _, err := e.reconcile(t, "a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e.cnt.deletes != 0 {
|
||||
t.Errorf("deletes = %d", e.cnt.deletes)
|
||||
}
|
||||
err := e.k8s.Get(t.Context(), client.ObjectKeyFromObject(km), &v1.KumaMonitor{})
|
||||
if !apierrors.IsNotFound(err) {
|
||||
t.Errorf("finalizer not removed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileDeleteToleratesNotFoundFromKuma(t *testing.T) {
|
||||
km := deleting(httpMonitor("a"))
|
||||
e := newEnv(t, km)
|
||||
e.kuma.Create(t.Context(), Desired(km))
|
||||
e.kuma.SetErrs(nil, nil, nil, fmt.Errorf("delete 1: %w", kumaclient.ErrNotFound))
|
||||
if _, err := e.reconcile(t, "a"); err != nil {
|
||||
t.Fatalf("ErrNotFound from Delete must count as success: %v", err)
|
||||
}
|
||||
if err := e.k8s.Get(t.Context(), client.ObjectKeyFromObject(km), &v1.KumaMonitor{}); !apierrors.IsNotFound(err) {
|
||||
t.Errorf("finalizer not removed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileDeleteNotOwnedIsError(t *testing.T) {
|
||||
km := deleting(httpMonitor("a"))
|
||||
e := newEnv(t, km)
|
||||
e.kuma.Create(t.Context(), Desired(km))
|
||||
e.kuma.SetErrs(nil, nil, nil, fmt.Errorf("delete 1: %w", kumaclient.ErrNotOwned))
|
||||
if _, err := e.reconcile(t, "a"); !errors.Is(err, kumaclient.ErrNotOwned) {
|
||||
t.Fatalf("want ErrNotOwned, got %v", err)
|
||||
}
|
||||
if err := e.k8s.Get(t.Context(), client.ObjectKeyFromObject(km), &v1.KumaMonitor{}); err != nil {
|
||||
t.Errorf("finalizer must stay: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileListErrorNoCreate(t *testing.T) {
|
||||
e := newEnv(t, httpMonitor("a"))
|
||||
e.kuma.SetErrs(errors.New("boom"), nil, nil, nil)
|
||||
if _, err := e.reconcile(t, "a"); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if e.cnt.creates != 0 || e.cnt.updates != 0 {
|
||||
t.Errorf("creates=%d updates=%d", e.cnt.creates, e.cnt.updates)
|
||||
}
|
||||
if c := ready(t, e.get(t, "a")); c.Status != metav1.ConditionFalse || c.Reason != ReasonKumaUnavailable {
|
||||
t.Errorf("Ready = %+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
if _, err := e.reconcile(t, "a"); !errors.Is(err, kumaclient.ErrOwnerTagMissing) {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if e.cnt.creates != 0 {
|
||||
t.Errorf("creates = %d", e.cnt.creates)
|
||||
}
|
||||
if c := ready(t, e.get(t, "a")); c.Reason != ReasonOwnerTagMissing {
|
||||
t.Errorf("Ready = %+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileOwnerConflict(t *testing.T) {
|
||||
e := newEnv(t, httpMonitor("a"))
|
||||
// Three keys, ours last: a first-two-values message would miss it.
|
||||
e.kuma.SetListPartial(errors.Join(&kumaclient.ConflictError{MonitorID: 3, Keys: []string{"default/other", "default/x", "default/a"}}))
|
||||
if _, err := e.reconcile(t, "a"); !errors.Is(err, kumaclient.ErrOwnerConflict) {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if e.cnt.creates != 0 || e.cnt.updates != 0 {
|
||||
t.Errorf("creates=%d updates=%d", e.cnt.creates, e.cnt.updates)
|
||||
}
|
||||
if c := ready(t, e.get(t, "a")); c.Reason != ReasonOwnerConflict {
|
||||
t.Errorf("Ready = %+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func otherConflict() error {
|
||||
return errors.Join(&kumaclient.ConflictError{MonitorID: 3, Keys: []string{"default/x", "default/y"}})
|
||||
}
|
||||
|
||||
func TestReconcileConflictOnOtherMonitorNeverCreates(t *testing.T) {
|
||||
e := newEnv(t, httpMonitor("a"))
|
||||
e.kuma.SetListPartial(otherConflict())
|
||||
if _, err := e.reconcile(t, "a"); !errors.Is(err, kumaclient.ErrOwnerConflict) {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if e.cnt.creates != 0 {
|
||||
t.Errorf("creates = %d, want 0", e.cnt.creates)
|
||||
}
|
||||
c := ready(t, e.get(t, "a"))
|
||||
if c.Status != metav1.ConditionFalse || c.Reason != ReasonKumaUnavailable ||
|
||||
c.Message != "monitor list incomplete: owner conflict on another monitor" {
|
||||
t.Errorf("Ready = %+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileConflictOnOtherMonitorAdoptsFoundByID(t *testing.T) {
|
||||
km := httpMonitor("a")
|
||||
e := newEnv(t, km)
|
||||
drifted := Desired(km)
|
||||
drifted.Interval = 30
|
||||
id, _ := e.kuma.Create(t.Context(), drifted)
|
||||
e.kuma.SetListPartial(otherConflict())
|
||||
if _, err := e.reconcile(t, "a"); 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)
|
||||
}
|
||||
if got := e.get(t, "a").Status.MonitorID; got != id {
|
||||
t.Errorf("monitorID = %d, want %d", got, id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileFailureMessageIsStable(t *testing.T) {
|
||||
e := newEnv(t, httpMonitor("a"))
|
||||
e.kuma.SetErrs(errors.New("boom 1"), nil, nil, nil)
|
||||
e.reconcile(t, "a")
|
||||
first := e.get(t, "a")
|
||||
e.kuma.SetErrs(errors.New("boom 2"), nil, nil, nil)
|
||||
e.reconcile(t, "a")
|
||||
second := e.get(t, "a")
|
||||
if ready(t, first).Message != ready(t, second).Message || first.ResourceVersion != second.ResourceVersion {
|
||||
t.Errorf("failure rewrote status: %q vs %q (rv %s vs %s)",
|
||||
ready(t, first).Message, ready(t, second).Message, first.ResourceVersion, second.ResourceVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileFinalizerSurvivesFailedCreate(t *testing.T) {
|
||||
e := newEnv(t, httpMonitor("a"))
|
||||
e.kuma.SetErrs(nil, errors.New("boom"), nil, nil)
|
||||
if _, err := e.reconcile(t, "a"); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if got := e.get(t, "a").Finalizers; len(got) != 1 || got[0] != Finalizer {
|
||||
t.Errorf("finalizers = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func withPreviousMonitor(id int64) *v1.KumaMonitor {
|
||||
km := httpMonitor("a")
|
||||
km.Status.MonitorID = id
|
||||
return km
|
||||
}
|
||||
|
||||
func TestReconcileRefusesCreateBesideUntaggedPrevious(t *testing.T) {
|
||||
e := newEnv(t, withPreviousMonitor(1))
|
||||
e.kuma.AddForeign(kumaclient.Monitor{ID: 1, Name: "was ours"})
|
||||
res, err := e.reconcile(t, "a")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e.cnt.creates != 0 {
|
||||
t.Fatalf("creates = %d, want 0", e.cnt.creates)
|
||||
}
|
||||
if res.RequeueAfter != 5*time.Minute {
|
||||
t.Errorf("RequeueAfter = %v", res.RequeueAfter)
|
||||
}
|
||||
c := ready(t, e.get(t, "a"))
|
||||
if c.Status != metav1.ConditionFalse || c.Reason != ReasonNotOwned ||
|
||||
c.Message != "previous monitor exists without our ownership tag; restore the tag or delete it in Kuma" {
|
||||
t.Errorf("Ready = %+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileCreatesWhenPreviousIDReusedByOtherOwner(t *testing.T) {
|
||||
e := newEnv(t, withPreviousMonitor(1))
|
||||
if _, err := e.kuma.Create(t.Context(), kumaclient.Monitor{Name: "x", OwnerKey: "default/other"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := e.reconcile(t, "a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e.cnt.creates != 1 {
|
||||
t.Errorf("creates = %d, want 1", e.cnt.creates)
|
||||
}
|
||||
if c := ready(t, e.get(t, "a")); c.Reason != ReasonSynced {
|
||||
t.Errorf("Ready = %+v", c)
|
||||
}
|
||||
if m := e.kuma.Get(1); m == nil || m.OwnerKey != "default/other" {
|
||||
t.Errorf("other owner's monitor 1 = %+v, want untouched", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileCreatesWhenPreviousMonitorIsGone(t *testing.T) {
|
||||
e := newEnv(t, withPreviousMonitor(77))
|
||||
if _, err := e.reconcile(t, "a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e.cnt.creates != 1 {
|
||||
t.Errorf("creates = %d, want 1", e.cnt.creates)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileProbeErrorNeverCreates(t *testing.T) {
|
||||
e := newEnv(t, withPreviousMonitor(1))
|
||||
e.kuma.SetProbeErr(errors.New("boom"))
|
||||
if _, err := e.reconcile(t, "a"); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if e.cnt.creates != 0 {
|
||||
t.Errorf("creates = %d, want 0", e.cnt.creates)
|
||||
}
|
||||
if c := ready(t, e.get(t, "a")); c.Reason != ReasonKumaUnavailable {
|
||||
t.Errorf("Ready = %+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileDuplicateOwner(t *testing.T) {
|
||||
km := httpMonitor("a")
|
||||
e := newEnv(t, km)
|
||||
first, _ := e.kuma.Create(t.Context(), Desired(km))
|
||||
second, _ := e.kuma.Create(t.Context(), Desired(km))
|
||||
if _, err := e.reconcile(t, "a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e.cnt.creates != 0 || e.cnt.deletes != 0 {
|
||||
t.Errorf("creates=%d deletes=%d, want 0 and 0", e.cnt.creates, e.cnt.deletes)
|
||||
}
|
||||
got := e.get(t, "a")
|
||||
if got.Status.MonitorID != first || first > second {
|
||||
t.Errorf("monitorID = %d, want lowest %d", got.Status.MonitorID, first)
|
||||
}
|
||||
if c := ready(t, got); c.Status != metav1.ConditionFalse || c.Reason != ReasonDuplicateOwner {
|
||||
t.Errorf("Ready = %+v", c)
|
||||
}
|
||||
if e.kuma.Get(second) == nil {
|
||||
t.Error("extra monitor must not be deleted by the reconciler")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileDeleteRemovesAllDuplicates(t *testing.T) {
|
||||
km := deleting(httpMonitor("a"))
|
||||
e := newEnv(t, km)
|
||||
first, _ := e.kuma.Create(t.Context(), Desired(km))
|
||||
second, _ := e.kuma.Create(t.Context(), Desired(km))
|
||||
if _, err := e.reconcile(t, "a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e.kuma.Get(first) != nil || e.kuma.Get(second) != nil {
|
||||
t.Error("all monitors carrying the key must be deleted")
|
||||
}
|
||||
if err := e.k8s.Get(t.Context(), client.ObjectKeyFromObject(km), &v1.KumaMonitor{}); !apierrors.IsNotFound(err) {
|
||||
t.Errorf("finalizer not removed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileDeleteFailureIsVisibleInCondition(t *testing.T) {
|
||||
km := deleting(httpMonitor("a"))
|
||||
e := newEnv(t, km)
|
||||
e.kuma.SetErrs(kumaclient.ErrOwnerTagMissing, nil, nil, nil)
|
||||
if _, err := e.reconcile(t, "a"); !errors.Is(err, kumaclient.ErrOwnerTagMissing) {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
got := e.get(t, "a")
|
||||
if len(got.Finalizers) != 1 {
|
||||
t.Errorf("finalizer must stay: %v", got.Finalizers)
|
||||
}
|
||||
if c := ready(t, got); c.Reason != ReasonOwnerTagMissing {
|
||||
t.Errorf("Ready = %+v", c)
|
||||
}
|
||||
|
||||
// Other delete failures: incomplete list without our key.
|
||||
e2 := newEnv(t, deleting(httpMonitor("a")))
|
||||
e2.kuma.SetListPartial(otherConflict())
|
||||
if _, err := e2.reconcile(t, "a"); err == nil {
|
||||
t.Fatal("deleting on an incomplete list must fail")
|
||||
}
|
||||
if err := e2.k8s.Get(t.Context(), client.ObjectKeyFromObject(km), &v1.KumaMonitor{}); err != nil {
|
||||
t.Errorf("finalizer must stay: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func group(name, parent string) *v1.KumaMonitor {
|
||||
g := &v1.KumaMonitor{
|
||||
ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: name},
|
||||
Spec: v1.KumaMonitorSpec{Type: "group", DisplayName: name, Interval: 60, RetryInterval: 60},
|
||||
Status: v1.KumaMonitorStatus{MonitorID: 100},
|
||||
}
|
||||
if parent != "" {
|
||||
g.Spec.ParentRef = &v1.LocalRef{Name: parent}
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
func TestReconcileInvalidParent(t *testing.T) {
|
||||
notGroup := httpMonitor("web")
|
||||
notGroup.Status.MonitorID = 5
|
||||
|
||||
selfRef := httpMonitor("self")
|
||||
selfRef.Spec.ParentRef = &v1.LocalRef{Name: "self"}
|
||||
|
||||
cycleA, cycleB := group("ga", "gb"), group("gb", "ga")
|
||||
underCycle := httpMonitor("underCycle")
|
||||
underCycle.Spec.ParentRef = &v1.LocalRef{Name: "ga"}
|
||||
|
||||
// A chain of 12 groups is deeper than allowed.
|
||||
var chain []client.Object
|
||||
for i := range 12 {
|
||||
parent := ""
|
||||
if i < 11 {
|
||||
parent = fmt.Sprintf("c%d", i+1)
|
||||
}
|
||||
chain = append(chain, group(fmt.Sprintf("c%d", i), parent))
|
||||
}
|
||||
deep := httpMonitor("deep")
|
||||
deep.Spec.ParentRef = &v1.LocalRef{Name: "c0"}
|
||||
|
||||
underWeb := httpMonitor("underWeb")
|
||||
underWeb.Spec.ParentRef = &v1.LocalRef{Name: "web"}
|
||||
|
||||
objs := append([]client.Object{notGroup, selfRef, cycleA, cycleB, underCycle, deep, underWeb}, chain...)
|
||||
e := newEnv(t, objs...)
|
||||
for _, name := range []string{"underWeb", "self", "underCycle", "deep"} {
|
||||
res, err := e.reconcile(t, name)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", name, err)
|
||||
}
|
||||
if res.RequeueAfter != 5*time.Minute {
|
||||
t.Errorf("%s: RequeueAfter = %v, want 5m", name, res.RequeueAfter)
|
||||
}
|
||||
if c := ready(t, e.get(t, name)); c.Status != metav1.ConditionFalse || c.Reason != ReasonInvalidParent {
|
||||
t.Errorf("%s: Ready = %+v", name, c)
|
||||
}
|
||||
}
|
||||
if e.cnt.creates != 0 {
|
||||
t.Errorf("creates = %d", e.cnt.creates)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileNestedGroupsResolve(t *testing.T) {
|
||||
child := httpMonitor("child")
|
||||
child.Spec.ParentRef = &v1.LocalRef{Name: "inner"}
|
||||
e := newEnv(t, group("outer", ""), group("inner", "outer"), child)
|
||||
if _, err := e.reconcile(t, "child"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e.cnt.creates != 1 {
|
||||
t.Errorf("creates = %d", e.cnt.creates)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcilePossibleOrphanStatus(t *testing.T) {
|
||||
e := newEnv(t, httpMonitor("a"))
|
||||
e.kuma.SetErrs(nil, fmt.Errorf("create: %w", kumaclient.ErrPossibleOrphan), nil, nil)
|
||||
if _, err := e.reconcile(t, "a"); !errors.Is(err, kumaclient.ErrPossibleOrphan) {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
c := ready(t, e.get(t, "a"))
|
||||
if c.Status != metav1.ConditionFalse || c.Reason != ReasonPossibleOrphan {
|
||||
t.Errorf("Ready = %+v", c)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"slices"
|
||||
|
||||
v1 "gitea.renner.uno/konairius/homelab/kuma-sync/api/v1alpha1"
|
||||
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/kumaclient"
|
||||
)
|
||||
|
||||
// defaultMaxRedirects is Kuma's default for a monitor with no explicit value.
|
||||
const defaultMaxRedirects = 10
|
||||
|
||||
// OwnerKey is the value of the kuma-sync tag for a resource.
|
||||
func OwnerKey(km *v1.KumaMonitor) string {
|
||||
return km.Namespace + "/" + km.Name
|
||||
}
|
||||
|
||||
// Desired maps a spec to the monitor Kuma should hold. Parent is resolved by
|
||||
// the reconciler.
|
||||
func Desired(km *v1.KumaMonitor) kumaclient.Monitor {
|
||||
s := km.Spec
|
||||
m := kumaclient.Monitor{
|
||||
Type: s.Type,
|
||||
Name: s.DisplayName,
|
||||
Description: s.Description,
|
||||
Active: !s.Paused,
|
||||
Interval: s.Interval,
|
||||
RetryInterval: s.RetryInterval,
|
||||
MaxRetries: s.MaxRetries,
|
||||
OwnerKey: OwnerKey(km),
|
||||
}
|
||||
if s.Timeout != nil {
|
||||
m.Timeout = *s.Timeout
|
||||
}
|
||||
if h := s.HTTP; h != nil {
|
||||
m.URL = h.URL
|
||||
m.Method = h.Method
|
||||
m.AcceptedStatusCodes = slices.Clone(h.AcceptedStatusCodes)
|
||||
m.Headers = maps.Clone(h.Headers)
|
||||
m.Body = h.Body
|
||||
m.MaxRedirects = defaultMaxRedirects
|
||||
if h.MaxRedirects != nil {
|
||||
m.MaxRedirects = *h.MaxRedirects
|
||||
}
|
||||
m.IgnoreTLS = h.IgnoreTLS
|
||||
m.ExpiryNotification = h.ExpiryNotification
|
||||
}
|
||||
if k := s.Keyword; k != nil {
|
||||
m.Keyword = k.Keyword
|
||||
m.InvertKeyword = k.Invert
|
||||
}
|
||||
if q := s.JSONQuery; q != nil {
|
||||
m.JSONPath = q.Expression
|
||||
m.JSONPathOperator = q.Operator
|
||||
m.ExpectedValue = q.Expected
|
||||
}
|
||||
if d := s.DNS; d != nil {
|
||||
m.Hostname = d.Hostname
|
||||
m.DNSResolveType = d.ResolveType
|
||||
m.DNSResolveServer = d.ResolveServer
|
||||
m.Port = d.Port
|
||||
}
|
||||
if p := s.Port; p != nil {
|
||||
m.Hostname = p.Hostname
|
||||
m.Port = p.Port
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
|
||||
v1 "gitea.renner.uno/konairius/homelab/kuma-sync/api/v1alpha1"
|
||||
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/kumaclient"
|
||||
)
|
||||
|
||||
func TestMappingAllTypes(t *testing.T) {
|
||||
i32 := func(v int32) *int32 { return &v }
|
||||
base := func(typ string) v1.KumaMonitorSpec {
|
||||
return v1.KumaMonitorSpec{
|
||||
Type: typ, DisplayName: "dn", Description: "desc",
|
||||
Interval: 120, RetryInterval: 30, MaxRetries: 2,
|
||||
}
|
||||
}
|
||||
httpSpec := &v1.HTTPSpec{
|
||||
URL: "https://e.com", Method: "POST", AcceptedStatusCodes: []string{"200"},
|
||||
Headers: map[string]string{"a": "b"}, Body: "x", MaxRedirects: i32(3),
|
||||
IgnoreTLS: true, ExpiryNotification: true,
|
||||
}
|
||||
mk := func(s v1.KumaMonitorSpec) *v1.KumaMonitor {
|
||||
return &v1.KumaMonitor{ObjectMeta: metav1.ObjectMeta{Namespace: "ns", Name: "n"}, Spec: s}
|
||||
}
|
||||
common := func(typ string) kumaclient.Monitor {
|
||||
return kumaclient.Monitor{
|
||||
Type: typ, Name: "dn", Description: "desc", Active: true,
|
||||
Interval: 120, RetryInterval: 30, MaxRetries: 2, OwnerKey: "ns/n",
|
||||
}
|
||||
}
|
||||
withHTTP := func(m kumaclient.Monitor) kumaclient.Monitor {
|
||||
m.URL, m.Method, m.AcceptedStatusCodes = "https://e.com", "POST", []string{"200"}
|
||||
m.Headers, m.Body, m.MaxRedirects = map[string]string{"a": "b"}, "x", 3
|
||||
m.IgnoreTLS, m.ExpiryNotification, m.Timeout = true, true, 15
|
||||
return m
|
||||
}
|
||||
|
||||
group := base("group")
|
||||
httpS := base("http")
|
||||
httpS.HTTP, httpS.Timeout = httpSpec, i32(15)
|
||||
kw := base("keyword")
|
||||
kw.HTTP, kw.Timeout = httpSpec, i32(15)
|
||||
kw.Keyword = &v1.KeywordSpec{Keyword: "ok", Invert: true}
|
||||
jq := base("json-query")
|
||||
jq.HTTP, jq.Timeout = httpSpec, i32(15)
|
||||
jq.JSONQuery = &v1.JSONQuerySpec{Expression: "a.b", Operator: "contains", Expected: "z"}
|
||||
dns := base("dns")
|
||||
dns.DNS = &v1.DNSSpec{Hostname: "h.com", ResolveType: "AAAA", ResolveServer: "9.9.9.9", Port: 5353}
|
||||
port := base("port")
|
||||
port.Port = &v1.PortSpec{Hostname: "p.com", Port: 443}
|
||||
paused := base("group")
|
||||
paused.Paused = true
|
||||
|
||||
wantKW := withHTTP(common("keyword"))
|
||||
wantKW.Keyword, wantKW.InvertKeyword = "ok", true
|
||||
wantJQ := withHTTP(common("json-query"))
|
||||
wantJQ.JSONPath, wantJQ.JSONPathOperator, wantJQ.ExpectedValue = "a.b", "contains", "z"
|
||||
wantDNS := common("dns")
|
||||
wantDNS.Hostname, wantDNS.DNSResolveType, wantDNS.DNSResolveServer, wantDNS.Port = "h.com", "AAAA", "9.9.9.9", 5353
|
||||
wantPort := common("port")
|
||||
wantPort.Hostname, wantPort.Port = "p.com", 443
|
||||
wantPaused := common("group")
|
||||
wantPaused.Active = false
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
spec v1.KumaMonitorSpec
|
||||
want kumaclient.Monitor
|
||||
}{
|
||||
{"group", group, common("group")},
|
||||
{"http", httpS, withHTTP(common("http"))},
|
||||
{"keyword", kw, wantKW},
|
||||
{"json-query", jq, wantJQ},
|
||||
{"dns", dns, wantDNS},
|
||||
{"port", port, wantPort},
|
||||
{"paused maps to inactive", paused, wantPaused},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := Desired(mk(c.spec))
|
||||
if !reflect.DeepEqual(got, c.want) {
|
||||
t.Errorf("%s:\n got %+v\n want %+v", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMappingMaxRedirectsDefault(t *testing.T) {
|
||||
km := &v1.KumaMonitor{
|
||||
ObjectMeta: metav1.ObjectMeta{Namespace: "ns", Name: "n"},
|
||||
Spec: v1.KumaMonitorSpec{Type: "http", DisplayName: "d", HTTP: &v1.HTTPSpec{URL: "https://e.com"}},
|
||||
}
|
||||
if got := Desired(km).MaxRedirects; got != 10 {
|
||||
t.Errorf("MaxRedirects = %d, want Kuma's default 10", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,618 @@
|
||||
package kumaclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
const (
|
||||
ownerTagName = "kuma-sync"
|
||||
ownerTagColor = "#2563eb"
|
||||
|
||||
operationTimeout = 30 * time.Second
|
||||
probeTimeout = 10 * time.Second
|
||||
connectTimeout = 30 * time.Second
|
||||
reconnectAttempts = 6
|
||||
reconnectMaxDelay = 8 * time.Second
|
||||
)
|
||||
|
||||
// Breml is a Client backed by github.com/breml/go-uptime-kuma-client.
|
||||
// It serialises all calls on one session. The socket.io transport does not
|
||||
// reconnect, so when a call fails and a probe shows the session is dead, the
|
||||
// session is rebuilt (session token first, password as fallback; bounded
|
||||
// backoff) and the call is retried once. Tag ownership caveats: the kuma-sync
|
||||
// tag is the lowest-ID tag of that name, and the library clobbers monitor
|
||||
// conditions and retryOnlyOnStatusCodeFailure on update.
|
||||
type Breml struct {
|
||||
mu sync.Mutex
|
||||
c *kuma.Client
|
||||
tagID int64
|
||||
|
||||
sessCtx context.Context
|
||||
url string
|
||||
user string
|
||||
pass string
|
||||
opts []kuma.Option
|
||||
token string
|
||||
backoff0 time.Duration
|
||||
}
|
||||
|
||||
var _ Client = (*Breml)(nil)
|
||||
|
||||
// NewBreml connects and logs in once, then ensures the kuma-sync tag exists.
|
||||
// ctx bounds the lifetime of the socket session, not just the connect call.
|
||||
//
|
||||
// After a Kuma restart or dropped connection, the next call that fails with a
|
||||
// dead session reconnects and retries once. Create is the exception: it is
|
||||
// never retried, because its outcome can be unknown. Two windows can leave an
|
||||
// untagged duplicate in Kuma, each signalled by an error wrapping
|
||||
// ErrPossibleOrphan (the controller should log it at error level):
|
||||
// 1. Kuma stored the monitor but the ack was lost.
|
||||
// 2. Kuma stored the monitor, then tagging and the cleanup delete failed.
|
||||
//
|
||||
// In both cases the untagged monitor is never adopted or touched, so it needs
|
||||
// manual cleanup in the Kuma UI, and the next reconcile may create a tagged
|
||||
// monitor beside it.
|
||||
func NewBreml(ctx context.Context, url, username, password string) (Client, error) {
|
||||
return newBreml(ctx, url, username, password)
|
||||
}
|
||||
|
||||
func newBreml(ctx context.Context, url, username, password string, opts ...kuma.Option) (*Breml, error) {
|
||||
opts = append([]kuma.Option{kuma.WithOperationTimeout(operationTimeout)}, opts...)
|
||||
c, err := kuma.New(ctx, url, username, password, opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect to kuma: %w", err)
|
||||
}
|
||||
b := &Breml{
|
||||
c: c, sessCtx: ctx, url: url, user: username, pass: password, opts: opts,
|
||||
token: c.SessionToken(), backoff0: time.Second,
|
||||
}
|
||||
if b.tagID, err = b.ensureTag(ctx); err != nil {
|
||||
_ = c.Disconnect()
|
||||
return nil, err
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// Close ends the session.
|
||||
func (b *Breml) Close() error {
|
||||
return b.c.Disconnect()
|
||||
}
|
||||
|
||||
// pickOwnerTag returns the lowest-ID tag named kuma-sync. Kuma does not
|
||||
// enforce unique tag names.
|
||||
func pickOwnerTag(tags []tag.Tag) (int64, bool) {
|
||||
var id int64
|
||||
found := false
|
||||
for _, t := range tags {
|
||||
if t.Name == ownerTagName && (!found || t.ID < id) {
|
||||
id, found = t.ID, true
|
||||
}
|
||||
}
|
||||
return id, found
|
||||
}
|
||||
|
||||
// ownerKeyOf returns the kuma-sync tag value of a monitor. Different values on
|
||||
// one monitor are a conflict, never resolved by picking one.
|
||||
// The error is a *ConflictError listing all distinct values.
|
||||
func ownerKeyOf(monitorID int64, tags []tag.MonitorTag, tagID int64) (string, error) {
|
||||
var keys []string
|
||||
for _, t := range tags {
|
||||
if t.TagID == tagID && t.Value != "" && !slices.Contains(keys, t.Value) {
|
||||
keys = append(keys, t.Value)
|
||||
}
|
||||
}
|
||||
switch len(keys) {
|
||||
case 0:
|
||||
return "", nil
|
||||
case 1:
|
||||
return keys[0], nil
|
||||
}
|
||||
slices.Sort(keys)
|
||||
return "", &ConflictError{MonitorID: monitorID, Keys: keys}
|
||||
}
|
||||
|
||||
// createdDespite reports whether a CreateMonitor result means the monitor
|
||||
// exists. The library returns the ID together with ErrUpdateEventTimeout when
|
||||
// the monitor was created and only the update event is missing (library
|
||||
// monitor.go: CreateMonitor doc and the errors.Is(err, ErrUpdateEventTimeout)
|
||||
// branch).
|
||||
func createdDespite(id int64, err error) bool {
|
||||
return err == nil || (id != 0 && errors.Is(err, kuma.ErrUpdateEventTimeout))
|
||||
}
|
||||
|
||||
func (b *Breml) ensureTag(ctx context.Context) (int64, error) {
|
||||
tags, err := b.c.GetTags(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if id, ok := pickOwnerTag(tags); ok {
|
||||
return id, nil
|
||||
}
|
||||
return b.c.CreateTag(ctx, tag.Tag{Name: ownerTagName, Color: ownerTagColor})
|
||||
}
|
||||
|
||||
// recoverSession reports whether the session was rebuilt after err, so the
|
||||
// caller may retry. Application-level errors and a probe on a healthy session
|
||||
// return false.
|
||||
func (b *Breml) recoverSession(ctx context.Context, err error) bool {
|
||||
rebuilt, _ := b.checkSession(ctx, err)
|
||||
return rebuilt
|
||||
}
|
||||
|
||||
// checkSession probes the session after err. dead reports that the probe
|
||||
// failed, i.e. err was a transport failure; rebuilt that it was then replaced.
|
||||
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} {
|
||||
if errors.Is(err, app) {
|
||||
return false, false
|
||||
}
|
||||
}
|
||||
pctx, cancel := context.WithTimeout(ctx, probeTimeout)
|
||||
defer cancel()
|
||||
if _, perr := b.c.GetTags(pctx); perr == nil {
|
||||
return false, false
|
||||
}
|
||||
return b.reconnect(ctx) == nil, true
|
||||
}
|
||||
|
||||
func (b *Breml) reconnect(ctx context.Context) error {
|
||||
delay := b.backoff0
|
||||
var last error
|
||||
for attempt := 0; attempt < reconnectAttempts; attempt++ {
|
||||
if attempt > 0 {
|
||||
select {
|
||||
case <-time.After(delay):
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-b.sessCtx.Done():
|
||||
return b.sessCtx.Err()
|
||||
}
|
||||
delay = min(delay*2, reconnectMaxDelay)
|
||||
}
|
||||
opts := append(slices.Clone(b.opts), kuma.WithConnectTimeout(connectTimeout))
|
||||
if b.token != "" {
|
||||
opts = append(opts, kuma.WithSessionToken(b.token))
|
||||
}
|
||||
c, err := kuma.New(b.sessCtx, b.url, b.user, b.pass, opts...)
|
||||
if err != nil {
|
||||
last = err
|
||||
continue
|
||||
}
|
||||
_ = b.c.Disconnect()
|
||||
b.c = c
|
||||
if t := c.SessionToken(); t != "" {
|
||||
b.token = t
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("reconnect to kuma: %w", last)
|
||||
}
|
||||
|
||||
// withRecovery runs op and, if it fails on a dead session, retries it once on
|
||||
// a rebuilt one.
|
||||
func withRecovery[T any](ctx context.Context, b *Breml, op func() (T, error)) (T, error) {
|
||||
v, err := op()
|
||||
if err != nil && b.recoverSession(ctx, err) {
|
||||
return op()
|
||||
}
|
||||
return v, err
|
||||
}
|
||||
|
||||
func (b *Breml) ListOwned(ctx context.Context) ([]Monitor, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return withRecovery(ctx, b, func() ([]Monitor, error) { return b.listOwned(ctx) })
|
||||
}
|
||||
|
||||
func (b *Breml) listOwned(ctx context.Context) ([]Monitor, error) {
|
||||
tags, err := b.c.GetTags(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !slices.ContainsFunc(tags, func(t tag.Tag) bool { return t.ID == b.tagID && t.Name == ownerTagName }) {
|
||||
return nil, ErrOwnerTagMissing
|
||||
}
|
||||
bases, err := b.c.GetMonitors(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []Monitor
|
||||
var conflicts []error
|
||||
for _, base := range bases {
|
||||
key, err := ownerKeyOf(base.ID, base.Tags, b.tagID)
|
||||
if err != nil {
|
||||
conflicts = append(conflicts, err)
|
||||
continue
|
||||
}
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
m, err := toDomain(base, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, m.Normalized())
|
||||
}
|
||||
return out, errors.Join(conflicts...)
|
||||
}
|
||||
|
||||
// Create is never retried. Two windows can leave an untagged monitor in Kuma,
|
||||
// both signalled by an error wrapping ErrPossibleOrphan:
|
||||
// 1. Kuma stored the monitor but the ack was lost (transport or operation
|
||||
// timeout error, no ID returned).
|
||||
// 2. Kuma stored the monitor, then tagging failed and removing it failed too.
|
||||
//
|
||||
// Either way an untagged duplicate may exist; the controller never touches
|
||||
// untagged monitors, so it needs manual cleanup in the Kuma UI. The next
|
||||
// reconcile may create a tagged monitor beside it.
|
||||
func (b *Breml) Create(ctx context.Context, m Monitor) (int64, error) {
|
||||
if m.OwnerKey == "" {
|
||||
return 0, errors.New("create: empty OwnerKey")
|
||||
}
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return flagUnknownCreate(
|
||||
func() (int64, error) { return b.createOnce(ctx, m) },
|
||||
func(err error) bool { _, dead := b.checkSession(ctx, err); return dead },
|
||||
)
|
||||
}
|
||||
|
||||
// flagUnknownCreate runs create once. A failure without an ID that is a
|
||||
// transport or operation-timeout error leaves the outcome unknown, so it is
|
||||
// wrapped in ErrPossibleOrphan instead of being retried.
|
||||
func flagUnknownCreate(create func() (int64, error), sessionDead func(error) bool) (int64, error) {
|
||||
id, err := create()
|
||||
if err == nil || id != 0 || errors.Is(err, ErrPossibleOrphan) {
|
||||
return id, err
|
||||
}
|
||||
if errors.Is(err, kuma.ErrOperationTimeout) || sessionDead(err) {
|
||||
return 0, errors.Join(err, fmt.Errorf("create outcome unknown: %w", ErrPossibleOrphan))
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
|
||||
func (b *Breml) createOnce(ctx context.Context, m Monitor) (int64, error) {
|
||||
wire, err := toWire(m, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
id, err := b.c.CreateMonitor(ctx, wire)
|
||||
if !createdDespite(id, err) {
|
||||
return 0, err
|
||||
}
|
||||
// Here the monitor exists, even if err is an update-event timeout.
|
||||
if _, terr := b.c.AddMonitorTag(ctx, b.tagID, id, m.OwnerKey); terr != nil {
|
||||
// An untagged leftover would be invisible to ListOwned.
|
||||
if derr := b.c.DeleteMonitor(ctx, id); derr != nil {
|
||||
return 0, errors.Join(terr, fmt.Errorf("remove untagged monitor %d: %w", id, derr),
|
||||
fmt.Errorf("monitor %d: %w", id, ErrPossibleOrphan))
|
||||
}
|
||||
return 0, terr
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// lookupOwned returns the current monitor and verifies the owner tag.
|
||||
func (b *Breml) lookupOwned(ctx context.Context, op string, id int64) (monitor.Base, string, error) {
|
||||
bases, err := b.c.GetMonitors(ctx)
|
||||
if err != nil {
|
||||
return monitor.Base{}, "", err
|
||||
}
|
||||
for _, base := range bases {
|
||||
if base.ID != id {
|
||||
continue
|
||||
}
|
||||
key, err := ownerKeyOf(base.ID, base.Tags, b.tagID)
|
||||
if err != nil {
|
||||
return monitor.Base{}, "", fmt.Errorf("%s %d: %w", op, id, err)
|
||||
}
|
||||
if key == "" {
|
||||
return monitor.Base{}, "", fmt.Errorf("%s %d: %w", op, id, ErrNotOwned)
|
||||
}
|
||||
return base, key, nil
|
||||
}
|
||||
return monitor.Base{}, "", fmt.Errorf("%s %d: %w", op, id, ErrNotFound)
|
||||
}
|
||||
|
||||
type probeResult struct {
|
||||
found bool
|
||||
key string
|
||||
}
|
||||
|
||||
// Probe reports whether a monitor with this ID exists and which owner key it
|
||||
// carries; an untagged monitor has "". Conflicting tag values return the
|
||||
// *ConflictError.
|
||||
func (b *Breml) Probe(ctx context.Context, id int64) (bool, string, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
res, err := withRecovery(ctx, b, func() (probeResult, error) {
|
||||
bases, err := b.c.GetMonitors(ctx)
|
||||
if err != nil {
|
||||
return probeResult{}, err
|
||||
}
|
||||
for _, base := range bases {
|
||||
if base.ID != id {
|
||||
continue
|
||||
}
|
||||
key, err := ownerKeyOf(base.ID, base.Tags, b.tagID)
|
||||
return probeResult{found: true, key: key}, err
|
||||
}
|
||||
return probeResult{}, nil
|
||||
})
|
||||
return res.found, res.key, err
|
||||
}
|
||||
|
||||
func (b *Breml) Update(ctx context.Context, m Monitor) error {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
_, err := withRecovery(ctx, b, func() (struct{}, error) { return struct{}{}, b.update(ctx, m) })
|
||||
return err
|
||||
}
|
||||
|
||||
func (b *Breml) update(ctx context.Context, m Monitor) error {
|
||||
cur, key, err := b.lookupOwned(ctx, "update", m.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if key != m.OwnerKey {
|
||||
return fmt.Errorf("update %d: monitor carries owner key %q, not %q: %w", m.ID, key, m.OwnerKey, ErrNotOwned)
|
||||
}
|
||||
// Start from the full current object, also across a type change, so
|
||||
// fields we do not manage (proxy, resend interval, upside down, auth)
|
||||
// survive the overwrite.
|
||||
wire, err := toWire(m, &cur)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := b.c.UpdateMonitor(ctx, wire); err != nil {
|
||||
return err
|
||||
}
|
||||
// editMonitor does not change the active state.
|
||||
switch {
|
||||
case cur.IsActive && !m.Active:
|
||||
return b.c.PauseMonitor(ctx, m.ID)
|
||||
case !cur.IsActive && m.Active:
|
||||
return b.c.ResumeMonitor(ctx, m.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Breml) Delete(ctx context.Context, id int64) error {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
_, err := withRecovery(ctx, b, func() (struct{}, error) {
|
||||
if _, _, err := b.lookupOwned(ctx, "delete", id); err != nil {
|
||||
return struct{}{}, err
|
||||
}
|
||||
return struct{}{}, b.c.DeleteMonitor(ctx, id)
|
||||
})
|
||||
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{
|
||||
ID: base.ID,
|
||||
Type: base.Type(),
|
||||
Name: base.Name,
|
||||
Parent: base.Parent,
|
||||
Active: base.IsActive,
|
||||
Interval: int32(base.Interval),
|
||||
RetryInterval: int32(base.RetryInterval),
|
||||
MaxRetries: int32(base.MaxRetries),
|
||||
NotificationIDs: slices.Clone(base.NotificationIDs),
|
||||
OwnerKey: ownerKey,
|
||||
}
|
||||
if base.Description != nil {
|
||||
m.Description = *base.Description
|
||||
}
|
||||
switch m.Type {
|
||||
case "http":
|
||||
var v monitor.HTTP
|
||||
if err := base.As(&v); err != nil {
|
||||
return Monitor{}, err
|
||||
}
|
||||
if err := readHTTP(&m, v.HTTPDetails); err != nil {
|
||||
return Monitor{}, err
|
||||
}
|
||||
case "keyword":
|
||||
var v monitor.HTTPKeyword
|
||||
if err := base.As(&v); err != nil {
|
||||
return Monitor{}, err
|
||||
}
|
||||
if err := readHTTP(&m, v.HTTPDetails); err != nil {
|
||||
return Monitor{}, err
|
||||
}
|
||||
m.Keyword = v.Keyword
|
||||
m.InvertKeyword = v.InvertKeyword
|
||||
case "json-query":
|
||||
var v monitor.HTTPJSONQuery
|
||||
if err := base.As(&v); err != nil {
|
||||
return Monitor{}, err
|
||||
}
|
||||
if err := readHTTP(&m, v.HTTPDetails); err != nil {
|
||||
return Monitor{}, err
|
||||
}
|
||||
m.JSONPath = v.JSONPath
|
||||
m.JSONPathOperator = v.JSONPathOperator
|
||||
m.ExpectedValue = v.ExpectedValue
|
||||
case "dns":
|
||||
var v monitor.DNS
|
||||
if err := base.As(&v); err != nil {
|
||||
return Monitor{}, err
|
||||
}
|
||||
m.Hostname = v.Hostname
|
||||
m.Port = int32(v.Port)
|
||||
m.DNSResolveType = string(v.ResolveType)
|
||||
m.DNSResolveServer = v.ResolverServer
|
||||
case "port":
|
||||
var v monitor.TCPPort
|
||||
if err := base.As(&v); err != nil {
|
||||
return Monitor{}, err
|
||||
}
|
||||
m.Hostname = v.Hostname
|
||||
m.Port = int32(v.Port)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func readHTTP(m *Monitor, d monitor.HTTPDetails) error {
|
||||
m.URL = d.URL
|
||||
m.Method = d.Method
|
||||
m.Body = d.Body
|
||||
m.AcceptedStatusCodes = slices.Clone(d.AcceptedStatusCodes)
|
||||
m.MaxRedirects = int32(d.MaxRedirects)
|
||||
m.IgnoreTLS = d.IgnoreTLS
|
||||
m.ExpiryNotification = d.ExpiryNotification
|
||||
m.Timeout = int32(math.Round(d.Timeout))
|
||||
if strings.TrimSpace(d.Headers) != "" {
|
||||
if err := json.Unmarshal([]byte(d.Headers), &m.Headers); err != nil {
|
||||
return fmt.Errorf("monitor %d: parse headers: %w", m.ID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// toWire builds the library monitor for m. With existing set, the typed
|
||||
// object is first decoded from it so fields we do not manage are kept.
|
||||
func toWire(m Monitor, existing *monitor.Base) (monitor.Monitor, error) {
|
||||
m = m.Normalized()
|
||||
load := func(target any) error {
|
||||
if existing == nil {
|
||||
return nil
|
||||
}
|
||||
return existing.As(target)
|
||||
}
|
||||
switch m.Type {
|
||||
case "group":
|
||||
v := &monitor.Group{}
|
||||
if err := load(v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
writeBase(&v.Base, m)
|
||||
return v, nil
|
||||
case "http":
|
||||
v := &monitor.HTTP{}
|
||||
if err := load(v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
writeBase(&v.Base, m)
|
||||
return v, writeHTTP(&v.HTTPDetails, m)
|
||||
case "keyword":
|
||||
v := &monitor.HTTPKeyword{}
|
||||
if err := load(v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
writeBase(&v.Base, m)
|
||||
v.Keyword = m.Keyword
|
||||
v.InvertKeyword = m.InvertKeyword
|
||||
return v, writeHTTP(&v.HTTPDetails, m)
|
||||
case "json-query":
|
||||
v := &monitor.HTTPJSONQuery{}
|
||||
if err := load(v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
writeBase(&v.Base, m)
|
||||
v.JSONPath = m.JSONPath
|
||||
v.JSONPathOperator = m.JSONPathOperator
|
||||
v.ExpectedValue = m.ExpectedValue
|
||||
return v, writeHTTP(&v.HTTPDetails, m)
|
||||
case "dns":
|
||||
v := &monitor.DNS{}
|
||||
if err := load(v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
writeBase(&v.Base, m)
|
||||
v.Hostname = m.Hostname
|
||||
v.Port = int(m.Port)
|
||||
v.ResolveType = monitor.DNSResolveType(m.DNSResolveType)
|
||||
v.ResolverServer = m.DNSResolveServer
|
||||
return v, nil
|
||||
case "port":
|
||||
v := &monitor.TCPPort{}
|
||||
if err := load(v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
writeBase(&v.Base, m)
|
||||
v.Hostname = m.Hostname
|
||||
v.Port = int(m.Port)
|
||||
return v, nil
|
||||
}
|
||||
return nil, fmt.Errorf("unsupported monitor type %q", m.Type)
|
||||
}
|
||||
|
||||
func writeBase(b *monitor.Base, m Monitor) {
|
||||
b.ID = m.ID
|
||||
b.Name = m.Name
|
||||
desc := m.Description
|
||||
b.Description = &desc
|
||||
b.Parent = m.clone().Parent
|
||||
b.Interval = int64(m.Interval)
|
||||
b.RetryInterval = int64(m.RetryInterval)
|
||||
b.MaxRetries = int64(m.MaxRetries)
|
||||
b.IsActive = m.Active
|
||||
b.NotificationIDs = slices.Clone(m.NotificationIDs)
|
||||
}
|
||||
|
||||
// writeHTTP expects m to be normalized.
|
||||
func writeHTTP(d *monitor.HTTPDetails, m Monitor) error {
|
||||
d.URL = m.URL
|
||||
d.Timeout = float64(m.Timeout)
|
||||
d.ExpiryNotification = m.ExpiryNotification
|
||||
d.IgnoreTLS = m.IgnoreTLS
|
||||
d.MaxRedirects = int(m.MaxRedirects)
|
||||
d.AcceptedStatusCodes = slices.Clone(m.AcceptedStatusCodes)
|
||||
d.Method = m.Method
|
||||
d.Body = m.Body
|
||||
if d.HTTPBodyEncoding == "" {
|
||||
d.HTTPBodyEncoding = "json"
|
||||
}
|
||||
d.Headers = ""
|
||||
if len(m.Headers) > 0 {
|
||||
raw, err := json.Marshal(m.Headers)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode headers: %w", err)
|
||||
}
|
||||
d.Headers = string(raw)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,624 @@
|
||||
//go:build integration
|
||||
|
||||
package kumaclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
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/testcontainers/testcontainers-go"
|
||||
"github.com/testcontainers/testcontainers-go/wait"
|
||||
)
|
||||
|
||||
const (
|
||||
testUser = "admin"
|
||||
testPass = "Sup3r-Secret-Passw0rd!"
|
||||
)
|
||||
|
||||
// startKumaCtr runs a fresh Uptime Kuma and returns it with its base URL.
|
||||
func startKumaCtr(t *testing.T) (testcontainers.Container, string) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
|
||||
ContainerRequest: testcontainers.ContainerRequest{
|
||||
Image: "louislam/uptime-kuma:2.5.6-slim",
|
||||
ExposedPorts: []string{"3001/tcp"},
|
||||
Env: map[string]string{"UPTIME_KUMA_DB_TYPE": "sqlite"},
|
||||
WaitingFor: wait.ForHTTP("/api/entry-page").WithPort("3001/tcp").
|
||||
WithStatusCodeMatcher(func(s int) bool { return s == 200 }).
|
||||
WithStartupTimeout(3 * time.Minute),
|
||||
},
|
||||
Started: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("start kuma: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = ctr.Terminate(context.Background()) })
|
||||
url, err := ctr.PortEndpoint(ctx, "3001/tcp", "http")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return ctr, url
|
||||
}
|
||||
|
||||
func startKuma(t *testing.T) string {
|
||||
t.Helper()
|
||||
_, url := startKumaCtr(t)
|
||||
return url
|
||||
}
|
||||
|
||||
// startProxy forwards a stable local address to the container's current
|
||||
// mapped port, which can change when the container restarts.
|
||||
func startProxy(t *testing.T, ctr testcontainers.Container) string {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = ln.Close() })
|
||||
go func() {
|
||||
for {
|
||||
down, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
target, err := ctr.PortEndpoint(context.Background(), "3001/tcp", "")
|
||||
if err != nil {
|
||||
_ = down.Close()
|
||||
return
|
||||
}
|
||||
up, err := net.Dial("tcp", target)
|
||||
if err != nil {
|
||||
_ = down.Close()
|
||||
return
|
||||
}
|
||||
go func() { _, _ = io.Copy(up, down); _ = up.Close() }()
|
||||
_, _ = io.Copy(down, up)
|
||||
_ = down.Close()
|
||||
}()
|
||||
}
|
||||
}()
|
||||
return "http://" + ln.Addr().String()
|
||||
}
|
||||
|
||||
// setupClient creates the admin account on first connect.
|
||||
func setupClient(t *testing.T, url string) *Breml {
|
||||
t.Helper()
|
||||
b, err := newBreml(t.Context(), url, testUser, testPass, kuma.WithAutosetup(), kuma.WithConnectTimeout(2*time.Minute))
|
||||
if err != nil {
|
||||
t.Fatalf("setup client: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = b.Close() })
|
||||
return b
|
||||
}
|
||||
|
||||
// freshClient logs in again, as a restarted controller would.
|
||||
func freshClient(t *testing.T, url string) *Breml {
|
||||
t.Helper()
|
||||
b, err := newBreml(t.Context(), url, testUser, testPass, kuma.WithConnectTimeout(time.Minute))
|
||||
if err != nil {
|
||||
t.Fatalf("fresh client: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = b.Close() })
|
||||
return b
|
||||
}
|
||||
|
||||
func byID(list []Monitor) map[int64]Monitor {
|
||||
out := map[int64]Monitor{}
|
||||
for _, m := range list {
|
||||
out[m.ID] = m
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestCreateFieldsIntact(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
url := startKuma(t)
|
||||
b := setupClient(t, url)
|
||||
|
||||
group := Monitor{
|
||||
Type: "group", Name: "grp", Description: "a group", Active: true,
|
||||
Interval: 60, RetryInterval: 60, MaxRetries: 0, OwnerKey: "default/grp",
|
||||
}
|
||||
gid, err := b.Create(ctx, group)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
group.ID = gid
|
||||
|
||||
httpFields := func(m Monitor) Monitor {
|
||||
m.Active = true
|
||||
m.Interval, m.RetryInterval, m.MaxRetries, m.Timeout = 120, 30, 2, 25
|
||||
m.URL = "https://example.com/health"
|
||||
m.Method = "POST"
|
||||
m.Body = `{"a":1}`
|
||||
m.Headers = map[string]string{"X-One": "1", "X-Two": "2"}
|
||||
m.AcceptedStatusCodes = []string{"200-299", "301"}
|
||||
m.MaxRedirects = 5
|
||||
m.IgnoreTLS = true
|
||||
m.ExpiryNotification = true
|
||||
m.Parent = &gid
|
||||
return m
|
||||
}
|
||||
keyword := httpFields(Monitor{Type: "keyword", Name: "kw", Description: "kw d", OwnerKey: "default/kw"})
|
||||
keyword.Keyword = "healthy"
|
||||
keyword.InvertKeyword = true
|
||||
jq := httpFields(Monitor{Type: "json-query", Name: "jq", OwnerKey: "default/jq"})
|
||||
jq.JSONPath = "status"
|
||||
jq.JSONPathOperator = "contains"
|
||||
jq.ExpectedValue = "ok"
|
||||
dns := Monitor{
|
||||
Type: "dns", Name: "dns", Description: "dns d", Active: true,
|
||||
Interval: 90, RetryInterval: 45, MaxRetries: 1, Parent: &gid, OwnerKey: "default/dns",
|
||||
Hostname: "example.com", DNSResolveType: "AAAA", DNSResolveServer: "1.1.1.1", Port: 53,
|
||||
}
|
||||
port := Monitor{
|
||||
Type: "port", Name: "port", Active: false,
|
||||
Interval: 60, RetryInterval: 60, MaxRetries: 3, OwnerKey: "default/port",
|
||||
Hostname: "example.com", Port: 443,
|
||||
}
|
||||
|
||||
want := map[int64]Monitor{gid: group.Normalized()}
|
||||
for _, m := range []*Monitor{&keyword, &jq, &dns, &port} {
|
||||
id, err := b.Create(ctx, *m)
|
||||
if err != nil {
|
||||
t.Fatalf("create %s: %v", m.Type, err)
|
||||
}
|
||||
m.ID = id
|
||||
want[id] = m.Normalized()
|
||||
}
|
||||
|
||||
got, err := freshClient(t, url).ListOwned(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gotByID := byID(got)
|
||||
if len(gotByID) != len(want) {
|
||||
t.Fatalf("got %d monitors, want %d: %+v", len(gotByID), len(want), got)
|
||||
}
|
||||
for id, w := range want {
|
||||
if !reflect.DeepEqual(gotByID[id], w) {
|
||||
t.Errorf("%s mismatch:\n got %+v\n want %+v", w.Type, gotByID[id], w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultTimeoutReadsBack(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
url := startKuma(t)
|
||||
b := setupClient(t, url)
|
||||
|
||||
kw := Monitor{
|
||||
Type: "keyword", Name: "kw", Active: true, Interval: 60, RetryInterval: 60,
|
||||
URL: "https://example.com", Keyword: "ok", OwnerKey: "default/kw",
|
||||
}
|
||||
id, err := b.Create(ctx, kw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := freshClient(t, url).ListOwned(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := byID(got)[id]
|
||||
if m.Timeout != 48 || m.Method != "GET" || !reflect.DeepEqual(m.AcceptedStatusCodes, []string{"200-299"}) {
|
||||
t.Errorf("defaults not applied on read-back: %+v", m)
|
||||
}
|
||||
kw.ID = id
|
||||
if !reflect.DeepEqual(m, kw.Normalized()) {
|
||||
t.Errorf("read-back differs from Normalized():\n got %+v\n want %+v", m, kw.Normalized())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateKeepsUnchangedFields(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
url := startKuma(t)
|
||||
b := setupClient(t, url)
|
||||
|
||||
gid, err := b.Create(ctx, Monitor{Type: "group", Name: "grp", Active: true, Interval: 60, RetryInterval: 60, OwnerKey: "default/grp"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
orig := Monitor{
|
||||
Type: "keyword", Name: "kw", Active: true, Parent: &gid,
|
||||
Interval: 60, RetryInterval: 60, MaxRetries: 1, Timeout: 30,
|
||||
URL: "https://example.com", Method: "GET",
|
||||
AcceptedStatusCodes: []string{"200-299", "404"},
|
||||
Keyword: "up", ExpiryNotification: true, OwnerKey: "default/kw",
|
||||
}
|
||||
id, err := b.Create(ctx, orig)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
orig.ID = id
|
||||
|
||||
updated := orig
|
||||
updated.Interval = 120
|
||||
if err := b.Update(ctx, updated); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := freshClient(t, url).ListOwned(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, ok := byID(got)[id]
|
||||
if !ok {
|
||||
t.Fatalf("monitor %d missing after update", id)
|
||||
}
|
||||
if m.Interval != 120 {
|
||||
t.Errorf("interval = %d, want 120", m.Interval)
|
||||
}
|
||||
if m.Parent == nil || *m.Parent != gid {
|
||||
t.Errorf("parent = %v, want %d", m.Parent, gid)
|
||||
}
|
||||
if !reflect.DeepEqual(m.AcceptedStatusCodes, orig.AcceptedStatusCodes) {
|
||||
t.Errorf("acceptedStatusCodes = %v", m.AcceptedStatusCodes)
|
||||
}
|
||||
if m.Keyword != "up" {
|
||||
t.Errorf("keyword = %q", m.Keyword)
|
||||
}
|
||||
if !m.ExpiryNotification {
|
||||
t.Error("expiryNotification lost")
|
||||
}
|
||||
if m.OwnerKey != "default/kw" {
|
||||
t.Errorf("ownerKey = %q", m.OwnerKey)
|
||||
}
|
||||
|
||||
updated.Active = false
|
||||
if err := b.Update(ctx, updated); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err = freshClient(t, url).ListOwned(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if byID(got)[id].Active {
|
||||
t.Error("monitor still active after pausing update")
|
||||
}
|
||||
}
|
||||
|
||||
func createWebhook(t *testing.T, b *Breml, name string, isDefault bool) int64 {
|
||||
t.Helper()
|
||||
nid, err := b.c.CreateNotification(context.Background(), ¬ification.Webhook{
|
||||
Base: notification.Base{Name: name, IsActive: true, IsDefault: isDefault},
|
||||
WebhookDetails: notification.WebhookDetails{
|
||||
WebhookURL: "http://example.com/hook", WebhookContentType: "application/json",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create notification: %v", err)
|
||||
}
|
||||
return nid
|
||||
}
|
||||
|
||||
// setUnmanaged sets state through the raw library that Monitor does not
|
||||
// model, and attaches a notification by hand.
|
||||
func setUnmanaged(t *testing.T, b *Breml, id int64) (notificationID int64) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
nid := createWebhook(t, b, "n", false)
|
||||
var kw monitor.HTTPKeyword
|
||||
if err := b.c.GetMonitorAs(ctx, id, &kw); err != nil {
|
||||
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)
|
||||
}
|
||||
return nid
|
||||
}
|
||||
|
||||
func assertUnmanaged(t *testing.T, c *Breml, id int64, when string) {
|
||||
t.Helper()
|
||||
var base monitor.Base
|
||||
base, err := c.c.GetMonitor(t.Context(), id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !base.UpsideDown || base.ResendInterval != 7 {
|
||||
t.Errorf("%s: unmanaged state lost: upsideDown=%v resend=%d", when, base.UpsideDown, base.ResendInterval)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePreservesUnmanagedAndClearsManaged(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
url := startKuma(t)
|
||||
b := setupClient(t, url)
|
||||
|
||||
gid, err := b.Create(ctx, Monitor{Type: "group", Name: "grp", Active: true, Interval: 60, RetryInterval: 60, OwnerKey: "default/grp"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
orig := Monitor{
|
||||
Type: "keyword", Name: "kw", Active: true, Parent: &gid,
|
||||
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)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nid := setUnmanaged(t, b, id)
|
||||
|
||||
// Clear managed fields.
|
||||
cleared := orig
|
||||
cleared.ID = id
|
||||
cleared.Parent = nil
|
||||
cleared.Headers = nil
|
||||
cleared.Keyword = ""
|
||||
cleared.Interval = 90
|
||||
if err := b.Update(ctx, cleared); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fresh := freshClient(t, url)
|
||||
got, err := fresh.ListOwned(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := byID(got)[id]
|
||||
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")
|
||||
|
||||
// Type change must not wipe unmanaged state either.
|
||||
changed := cleared
|
||||
changed.Type = "http"
|
||||
changed.NotificationIDs = []int64{nid}
|
||||
if err := b.Update(ctx, changed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err = fresh.ListOwned(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m := byID(got)[id]; m.Type != "http" || !reflect.DeepEqual(m.NotificationIDs, []int64{nid}) {
|
||||
t.Errorf("type = %q, notifications = %v, want http and [%d]", m.Type, m.NotificationIDs, nid)
|
||||
}
|
||||
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)
|
||||
first := setupClient(t, url)
|
||||
|
||||
ids := map[int64]string{}
|
||||
for _, key := range []string{"ns1/a", "ns2/b", "ns1/c"} {
|
||||
id, err := first.Create(ctx, Monitor{
|
||||
Type: "port", Name: key, Active: true, Interval: 60, RetryInterval: 60,
|
||||
Hostname: "example.com", Port: 80, OwnerKey: key,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids[id] = key
|
||||
}
|
||||
if err := first.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := freshClient(t, url).ListOwned(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gotKeys := map[int64]string{}
|
||||
for _, m := range got {
|
||||
gotKeys[m.ID] = m.OwnerKey
|
||||
}
|
||||
if !reflect.DeepEqual(gotKeys, ids) {
|
||||
t.Fatalf("got %v, want %v", gotKeys, ids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUntaggedMonitorUntouched(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
url := startKuma(t)
|
||||
b := setupClient(t, url)
|
||||
|
||||
foreign := &monitor.TCPPort{}
|
||||
foreign.Name = "foreign"
|
||||
foreign.Interval, foreign.RetryInterval = 60, 60
|
||||
foreign.IsActive = true
|
||||
foreign.Hostname, foreign.Port = "example.com", 80
|
||||
fid, err := b.c.CreateMonitor(ctx, foreign)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mine, err := b.Create(ctx, Monitor{
|
||||
Type: "port", Name: "mine", Active: true, Interval: 60, RetryInterval: 60,
|
||||
Hostname: "example.com", Port: 80, OwnerKey: "default/mine",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := b.Update(ctx, Monitor{ID: mine, Type: "port", Name: "x", Hostname: "example.com", Port: 80, OwnerKey: "default/other"}); !errors.Is(err, ErrNotOwned) {
|
||||
t.Errorf("update with a different owner key: want ErrNotOwned, got %v", err)
|
||||
}
|
||||
if found, key, err := b.Probe(ctx, fid); err != nil || !found || key != "" {
|
||||
t.Errorf("probe untagged: %v %q %v", found, key, err)
|
||||
}
|
||||
if found, key, err := b.Probe(ctx, mine); err != nil || !found || key != "default/mine" {
|
||||
t.Errorf("probe owned: %v %q %v", found, key, err)
|
||||
}
|
||||
if found, _, err := b.Probe(ctx, 99999); err != nil || found {
|
||||
t.Errorf("probe unknown: %v %v", found, err)
|
||||
}
|
||||
|
||||
got, err := b.ListOwned(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 || got[0].Name != "mine" {
|
||||
t.Fatalf("ListOwned = %+v", got)
|
||||
}
|
||||
if err := b.Update(ctx, Monitor{ID: fid, Type: "port", Name: "hijacked", Hostname: "example.com", Port: 80}); !errors.Is(err, ErrNotOwned) {
|
||||
t.Errorf("update: want ErrNotOwned, got %v", err)
|
||||
}
|
||||
if err := b.Delete(ctx, fid); !errors.Is(err, ErrNotOwned) {
|
||||
t.Errorf("delete: want ErrNotOwned, got %v", err)
|
||||
}
|
||||
if err := b.Delete(ctx, 99999); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("delete unknown: want ErrNotFound, got %v", err)
|
||||
}
|
||||
if err := b.Update(ctx, Monitor{ID: 99999, Type: "port"}); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("update unknown: want ErrNotFound, got %v", err)
|
||||
}
|
||||
still, err := b.c.GetMonitor(ctx, fid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if still.Name != "foreign" {
|
||||
t.Errorf("foreign monitor changed: %q", still.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerTagMissing(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
url := startKuma(t)
|
||||
b := setupClient(t, url)
|
||||
if _, err := b.Create(ctx, Monitor{
|
||||
Type: "port", Name: "mine", Active: true, Interval: 60, RetryInterval: 60,
|
||||
Hostname: "example.com", Port: 80, OwnerKey: "default/mine",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := b.c.DeleteTag(ctx, b.tagID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := b.ListOwned(ctx)
|
||||
if !errors.Is(err, ErrOwnerTagMissing) || got != nil {
|
||||
t.Fatalf("got %+v, %v; want nil list and ErrOwnerTagMissing", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerConflictExcluded(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
url := startKuma(t)
|
||||
b := setupClient(t, url)
|
||||
create := func(key string) int64 {
|
||||
id, err := b.Create(ctx, Monitor{
|
||||
Type: "port", Name: key, Active: true, Interval: 60, RetryInterval: 60,
|
||||
Hostname: "example.com", Port: 80, OwnerKey: key,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
good, bad := create("default/good"), create("default/bad")
|
||||
if _, err := b.c.AddMonitorTag(ctx, b.tagID, bad, "default/other"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := b.ListOwned(ctx)
|
||||
if !errors.Is(err, ErrOwnerConflict) {
|
||||
t.Fatalf("want ErrOwnerConflict, got %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].ID != good {
|
||||
t.Fatalf("conflicting monitor must be excluded: %+v", got)
|
||||
}
|
||||
if err := b.Delete(ctx, bad); !errors.Is(err, ErrOwnerConflict) {
|
||||
t.Errorf("delete of conflicting monitor: want ErrOwnerConflict, got %v", err)
|
||||
}
|
||||
if err := b.Update(ctx, Monitor{ID: bad, Type: "port", Name: "x", Hostname: "example.com", Port: 80}); !errors.Is(err, ErrOwnerConflict) {
|
||||
t.Errorf("update of conflicting monitor: want ErrOwnerConflict, got %v", err)
|
||||
}
|
||||
if _, _, err := b.Probe(ctx, bad); !errors.Is(err, ErrOwnerConflict) {
|
||||
t.Errorf("probe of conflicting monitor: want ErrOwnerConflict, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconnectAfterKumaRestart(t *testing.T) {
|
||||
ctr, _ := startKumaCtr(t)
|
||||
url := startProxy(t, ctr)
|
||||
b := setupClient(t, url)
|
||||
|
||||
id, err := b.Create(t.Context(), Monitor{
|
||||
Type: "port", Name: "survivor", Active: true, Interval: 60, RetryInterval: 60,
|
||||
Hostname: "example.com", Port: 80, OwnerKey: "default/survivor",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stop := 10 * time.Second
|
||||
if err := ctr.Stop(t.Context(), &stop); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ctr.Start(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 60*time.Second)
|
||||
defer cancel()
|
||||
got, err := b.ListOwned(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListOwned after restart: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].ID != id {
|
||||
t.Fatalf("ListOwned = %+v, want monitor %d", got, id)
|
||||
}
|
||||
if _, err := b.Create(ctx, Monitor{
|
||||
Type: "port", Name: "after", Active: true, Interval: 60, RetryInterval: 60,
|
||||
Hostname: "example.com", Port: 80, OwnerKey: "default/after",
|
||||
}); err != nil {
|
||||
t.Fatalf("Create after restart: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
// Package kumaclient is the boundary to Uptime Kuma.
|
||||
package kumaclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"math"
|
||||
"slices"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("monitor not found")
|
||||
ErrNotOwned = errors.New("monitor not owned by kuma-sync")
|
||||
// ErrOwnerTagMissing means the kuma-sync tag no longer exists in Kuma.
|
||||
ErrOwnerTagMissing = errors.New("kuma-sync tag missing in Kuma")
|
||||
// ErrOwnerConflict means a monitor carries more than one kuma-sync tag value.
|
||||
ErrOwnerConflict = errors.New("monitor carries conflicting kuma-sync tag values")
|
||||
// ErrPossibleOrphan means Create may have left an untagged monitor in Kuma.
|
||||
ErrPossibleOrphan = errors.New("possible untagged orphan monitor left in Kuma")
|
||||
)
|
||||
|
||||
// ConflictError reports a monitor carrying several different kuma-sync tag
|
||||
// values. Keys lists all of them, sorted. It matches ErrOwnerConflict.
|
||||
type ConflictError struct {
|
||||
MonitorID int64
|
||||
Keys []string
|
||||
}
|
||||
|
||||
func (e *ConflictError) Error() string {
|
||||
return fmt.Sprintf("monitor %d carries %d owner keys %q", e.MonitorID, len(e.Keys), e.Keys)
|
||||
}
|
||||
|
||||
func (e *ConflictError) Unwrap() error { return ErrOwnerConflict }
|
||||
|
||||
// Conflicts returns every ConflictError inside err, and whether err consists
|
||||
// of nothing but conflicts. ListOwned joins one per conflicting monitor.
|
||||
func Conflicts(err error) (conflicts []*ConflictError, only bool) {
|
||||
only = err != nil
|
||||
var walk func(error)
|
||||
walk = func(e error) {
|
||||
switch x := e.(type) {
|
||||
case nil:
|
||||
case *ConflictError:
|
||||
conflicts = append(conflicts, x)
|
||||
case interface{ Unwrap() []error }:
|
||||
for _, s := range x.Unwrap() {
|
||||
walk(s)
|
||||
}
|
||||
default:
|
||||
if u, ok := e.(interface{ Unwrap() error }); ok && u.Unwrap() != nil {
|
||||
walk(u.Unwrap())
|
||||
return
|
||||
}
|
||||
only = false
|
||||
}
|
||||
}
|
||||
walk(err)
|
||||
return conflicts, only && len(conflicts) > 0
|
||||
}
|
||||
|
||||
func (m Monitor) clone() Monitor {
|
||||
m.Headers = maps.Clone(m.Headers)
|
||||
m.AcceptedStatusCodes = slices.Clone(m.AcceptedStatusCodes)
|
||||
m.NotificationIDs = slices.Clone(m.NotificationIDs)
|
||||
if m.Parent != nil {
|
||||
p := *m.Parent
|
||||
m.Parent = &p
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func isHTTPFamily(typ string) bool {
|
||||
return typ == "http" || typ == "keyword" || typ == "json-query"
|
||||
}
|
||||
|
||||
// Normalized returns m with the defaults the real client applies on write, so
|
||||
// a desired monitor can be compared with an observed one.
|
||||
//
|
||||
// For http, keyword and json-query: an empty Method becomes GET, empty
|
||||
// AcceptedStatusCodes become ["200-299"], and Timeout 0 becomes
|
||||
// round(0.8*Interval). For all other types Timeout is stored as 0 and the
|
||||
// HTTP-only slices are nil. Empty Headers become nil. NotificationIDs are
|
||||
// sorted and deduplicated, empty becomes nil. It is idempotent.
|
||||
func (m Monitor) Normalized() Monitor {
|
||||
m = m.clone()
|
||||
if len(m.Headers) == 0 {
|
||||
m.Headers = nil
|
||||
}
|
||||
slices.Sort(m.NotificationIDs)
|
||||
m.NotificationIDs = slices.Compact(m.NotificationIDs)
|
||||
if len(m.NotificationIDs) == 0 {
|
||||
m.NotificationIDs = nil
|
||||
}
|
||||
if len(m.AcceptedStatusCodes) == 0 {
|
||||
m.AcceptedStatusCodes = nil
|
||||
}
|
||||
if !isHTTPFamily(m.Type) {
|
||||
m.Timeout = 0
|
||||
return m
|
||||
}
|
||||
if m.Method == "" {
|
||||
m.Method = "GET"
|
||||
}
|
||||
if m.AcceptedStatusCodes == nil {
|
||||
m.AcceptedStatusCodes = []string{"200-299"}
|
||||
}
|
||||
if m.Timeout == 0 {
|
||||
m.Timeout = int32(math.Round(0.8 * float64(m.Interval)))
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// Monitor is the domain view of a Kuma monitor.
|
||||
type Monitor struct {
|
||||
ID int64
|
||||
Type string
|
||||
Name string
|
||||
Description string
|
||||
Parent *int64
|
||||
Active bool
|
||||
|
||||
Interval int32
|
||||
RetryInterval int32
|
||||
MaxRetries int32
|
||||
// Timeout is in seconds and only applies to http, keyword and json-query.
|
||||
// 0 is written as round(0.8*Interval), because Kuma treats a stored 0 as a
|
||||
// huge request timeout. For other types it is ignored and reads back as 0.
|
||||
Timeout int32
|
||||
|
||||
URL string
|
||||
Method string
|
||||
Body string
|
||||
Headers map[string]string
|
||||
AcceptedStatusCodes []string
|
||||
MaxRedirects int32
|
||||
IgnoreTLS bool
|
||||
ExpiryNotification bool
|
||||
|
||||
Keyword string
|
||||
InvertKeyword bool
|
||||
|
||||
JSONPath string
|
||||
JSONPathOperator string
|
||||
ExpectedValue string
|
||||
|
||||
Hostname string
|
||||
Port int32
|
||||
DNSResolveType string
|
||||
DNSResolveServer string
|
||||
|
||||
// NotificationIDs are the Kuma notifications attached to the monitor.
|
||||
NotificationIDs []int64
|
||||
|
||||
// OwnerKey is the value of the kuma-sync tag; "" means not ours.
|
||||
OwnerKey string
|
||||
}
|
||||
|
||||
// Client manages only monitors carrying the kuma-sync tag.
|
||||
type Client interface {
|
||||
// ListOwned returns monitors carrying the kuma-sync tag, normalized.
|
||||
// It returns ErrOwnerTagMissing, with no list, if the tag was deleted in
|
||||
// Kuma, so callers never mistake that for "no monitors". A monitor with
|
||||
// conflicting tag values is left out and reported as a *ConflictError (all
|
||||
// of them joined; see Conflicts) together with the rest of the list. Any
|
||||
// error means the list is incomplete: callers must not conclude a monitor
|
||||
// is absent from it.
|
||||
ListOwned(ctx context.Context) ([]Monitor, error)
|
||||
// Create creates the monitor and tags it with m.OwnerKey.
|
||||
Create(ctx context.Context, m Monitor) (int64, error)
|
||||
// Probe reports whether a monitor with this ID exists, owned or not, and
|
||||
// the owner key it carries ("" when untagged). A monitor with conflicting
|
||||
// tag values returns an error that wraps ErrOwnerConflict.
|
||||
Probe(ctx context.Context, id int64) (found bool, ownerKey string, err error)
|
||||
// Update overwrites an owned monitor. It wraps ErrNotFound for an unknown ID
|
||||
// and ErrNotOwned for a monitor without the kuma-sync tag or whose tag
|
||||
// value differs from m.OwnerKey.
|
||||
Update(ctx context.Context, m Monitor) error
|
||||
// Delete removes an owned monitor. It wraps ErrNotFound for an unknown ID
|
||||
// and ErrNotOwned for a monitor without the kuma-sync tag.
|
||||
Delete(ctx context.Context, id int64) error
|
||||
// DefaultNotifications returns the IDs of all notifications marked
|
||||
// "default enabled" in Kuma, sorted. An empty result is valid.
|
||||
DefaultNotifications(ctx context.Context) ([]int64, error)
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package kumaclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Fake is an in-memory Client safe for concurrent use.
|
||||
// ListErr, CreateErr, UpdateErr and DeleteErr, when set, are returned by the
|
||||
// matching method; set them via the Set*Err methods while the fake is shared.
|
||||
type Fake struct {
|
||||
mu sync.Mutex
|
||||
monitors map[int64]Monitor
|
||||
nextID int64
|
||||
|
||||
ListErr error
|
||||
CreateErr error
|
||||
UpdateErr error
|
||||
DeleteErr error
|
||||
ProbeErr error
|
||||
// NotificationsErr is returned by DefaultNotifications.
|
||||
NotificationsErr error
|
||||
// Defaults are the IDs DefaultNotifications returns.
|
||||
Defaults []int64
|
||||
// ListPartial makes ListOwned return the stored monitors together with
|
||||
// ListErr, as the real client does for owner conflicts.
|
||||
ListPartial bool
|
||||
}
|
||||
|
||||
// SetListPartial makes ListOwned return the stored monitors and err.
|
||||
func (f *Fake) SetListPartial(err error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.ListErr, f.ListPartial = err, true
|
||||
}
|
||||
|
||||
var _ Client = (*Fake)(nil)
|
||||
|
||||
func NewFake() *Fake {
|
||||
return &Fake{monitors: map[int64]Monitor{}, nextID: 1}
|
||||
}
|
||||
|
||||
// AddForeign adds an untagged monitor. A zero ID is assigned the next free one.
|
||||
func (f *Fake) AddForeign(m Monitor) int64 {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
m.OwnerKey = ""
|
||||
if m.ID == 0 {
|
||||
m.ID = f.allocID()
|
||||
} else if m.ID >= f.nextID {
|
||||
f.nextID = m.ID + 1
|
||||
}
|
||||
f.monitors[m.ID] = m.clone()
|
||||
return m.ID
|
||||
}
|
||||
|
||||
// Get returns a copy of the monitor with the given ID, owned or not, or nil.
|
||||
func (f *Fake) Get(id int64) *Monitor {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
m, ok := f.monitors[id]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
c := m.clone()
|
||||
return &c
|
||||
}
|
||||
|
||||
// SetErrs sets all four injected errors under the lock.
|
||||
func (f *Fake) SetErrs(list, create, update, del error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.ListErr, f.CreateErr, f.UpdateErr, f.DeleteErr = list, create, update, del
|
||||
f.ListPartial = false
|
||||
}
|
||||
|
||||
// SetProbeErr makes Probe return err.
|
||||
func (f *Fake) SetProbeErr(err error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.ProbeErr = err
|
||||
}
|
||||
|
||||
// Probe reports whether a monitor exists, owned or not, and its owner key.
|
||||
func (f *Fake) Probe(_ context.Context, id int64) (bool, string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.ProbeErr != nil {
|
||||
return false, "", f.ProbeErr
|
||||
}
|
||||
m, ok := f.monitors[id]
|
||||
return ok, m.OwnerKey, nil
|
||||
}
|
||||
|
||||
// SetDefaultNotifications sets the IDs DefaultNotifications returns.
|
||||
func (f *Fake) SetDefaultNotifications(ids ...int64) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.Defaults = slices.Clone(ids)
|
||||
}
|
||||
|
||||
// SetNotificationsErr makes DefaultNotifications return err.
|
||||
func (f *Fake) SetNotificationsErr(err error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.NotificationsErr = err
|
||||
}
|
||||
|
||||
// DefaultNotifications returns Defaults, sorted.
|
||||
func (f *Fake) DefaultNotifications(_ context.Context) ([]int64, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.NotificationsErr != nil {
|
||||
return nil, f.NotificationsErr
|
||||
}
|
||||
ids := slices.Clone(f.Defaults)
|
||||
slices.Sort(ids)
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (f *Fake) allocID() int64 {
|
||||
id := f.nextID
|
||||
f.nextID++
|
||||
return id
|
||||
}
|
||||
|
||||
func (f *Fake) ListOwned(_ context.Context) ([]Monitor, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.ListErr != nil && !f.ListPartial {
|
||||
return nil, f.ListErr
|
||||
}
|
||||
var out []Monitor
|
||||
for _, m := range f.monitors {
|
||||
if m.OwnerKey != "" {
|
||||
out = append(out, m.clone())
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||||
return out, f.ListErr
|
||||
}
|
||||
|
||||
func (f *Fake) Create(_ context.Context, m Monitor) (int64, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.CreateErr != nil {
|
||||
return 0, f.CreateErr
|
||||
}
|
||||
if m.OwnerKey == "" {
|
||||
return 0, fmt.Errorf("create: empty OwnerKey")
|
||||
}
|
||||
m.ID = f.allocID()
|
||||
f.monitors[m.ID] = m.Normalized()
|
||||
return m.ID, nil
|
||||
}
|
||||
|
||||
func (f *Fake) Update(_ context.Context, m Monitor) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.UpdateErr != nil {
|
||||
return f.UpdateErr
|
||||
}
|
||||
cur, ok := f.monitors[m.ID]
|
||||
if !ok {
|
||||
return fmt.Errorf("update %d: %w", m.ID, ErrNotFound)
|
||||
}
|
||||
if cur.OwnerKey == "" {
|
||||
return fmt.Errorf("update %d: %w", m.ID, ErrNotOwned)
|
||||
}
|
||||
if m.OwnerKey != cur.OwnerKey {
|
||||
return fmt.Errorf("update %d: owner key %q, not %q: %w", m.ID, cur.OwnerKey, m.OwnerKey, ErrNotOwned)
|
||||
}
|
||||
f.monitors[m.ID] = m.Normalized()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Fake) Delete(_ context.Context, id int64) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.DeleteErr != nil {
|
||||
return f.DeleteErr
|
||||
}
|
||||
cur, ok := f.monitors[id]
|
||||
if !ok {
|
||||
return fmt.Errorf("delete %d: %w", id, ErrNotFound)
|
||||
}
|
||||
if cur.OwnerKey == "" {
|
||||
return fmt.Errorf("delete %d: %w", id, ErrNotOwned)
|
||||
}
|
||||
delete(f.monitors, id)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
package kumaclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFakeCreateAssignsIDAndTag(t *testing.T) {
|
||||
f := NewFake()
|
||||
id, err := f.Create(context.Background(), Monitor{Name: "a", OwnerKey: "ns/a"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if id == 0 {
|
||||
t.Fatal("expected non-zero id")
|
||||
}
|
||||
got, err := f.ListOwned(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 || got[0].ID != id || got[0].OwnerKey != "ns/a" {
|
||||
t.Fatalf("unexpected list: %+v", got)
|
||||
}
|
||||
id2, err := f.Create(context.Background(), Monitor{Name: "b", OwnerKey: "ns/b"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if id2 == id {
|
||||
t.Fatal("ids must be unique")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFakeCreateRequiresOwnerKey(t *testing.T) {
|
||||
f := NewFake()
|
||||
if _, err := f.Create(context.Background(), Monitor{Name: "a"}); err == nil {
|
||||
t.Fatal("expected error for empty OwnerKey")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFakeListOwnedHidesForeign(t *testing.T) {
|
||||
f := NewFake()
|
||||
f.AddForeign(Monitor{ID: 99, Name: "foreign"})
|
||||
if _, err := f.Create(context.Background(), Monitor{Name: "mine", OwnerKey: "ns/m"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := f.ListOwned(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 || got[0].Name != "mine" {
|
||||
t.Fatalf("unexpected list: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFakeUpdateRejectsForeign(t *testing.T) {
|
||||
f := NewFake()
|
||||
f.AddForeign(Monitor{ID: 99, Name: "foreign"})
|
||||
if err := f.Update(context.Background(), Monitor{ID: 99, Name: "changed", OwnerKey: "ns/x"}); !errors.Is(err, ErrNotOwned) {
|
||||
t.Fatalf("expected ErrNotOwned, got %v", err)
|
||||
}
|
||||
if got := f.Get(99); got == nil || got.Name != "foreign" {
|
||||
t.Fatalf("foreign monitor modified: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFakeUpdateOverwritesOwned(t *testing.T) {
|
||||
f := NewFake()
|
||||
id, _ := f.Create(context.Background(), Monitor{Name: "a", URL: "u", OwnerKey: "ns/a"})
|
||||
if err := f.Update(context.Background(), Monitor{ID: id, Name: "b", OwnerKey: "ns/a"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := f.Get(id)
|
||||
if got.Name != "b" || got.URL != "" {
|
||||
t.Fatalf("not overwritten: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFakeDeleteRejectsForeign(t *testing.T) {
|
||||
f := NewFake()
|
||||
f.AddForeign(Monitor{ID: 99, Name: "foreign"})
|
||||
if err := f.Delete(context.Background(), 99); !errors.Is(err, ErrNotOwned) {
|
||||
t.Fatalf("expected ErrNotOwned, got %v", err)
|
||||
}
|
||||
if f.Get(99) == nil {
|
||||
t.Fatal("foreign monitor deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFakeDeleteOwned(t *testing.T) {
|
||||
f := NewFake()
|
||||
id, _ := f.Create(context.Background(), Monitor{Name: "a", OwnerKey: "ns/a"})
|
||||
if err := f.Delete(context.Background(), id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.Get(id) != nil {
|
||||
t.Fatal("not deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFakeInjectedErrors(t *testing.T) {
|
||||
boom := errors.New("boom")
|
||||
f := NewFake()
|
||||
id, _ := f.Create(context.Background(), Monitor{Name: "a", OwnerKey: "ns/a"})
|
||||
f.ListErr, f.CreateErr, f.UpdateErr, f.DeleteErr = boom, boom, boom, boom
|
||||
ctx := context.Background()
|
||||
if _, err := f.ListOwned(ctx); !errors.Is(err, boom) {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if _, err := f.Create(ctx, Monitor{OwnerKey: "x"}); !errors.Is(err, boom) {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if err := f.Update(ctx, Monitor{ID: id, OwnerKey: "ns/a"}); !errors.Is(err, boom) {
|
||||
t.Fatalf("update: %v", err)
|
||||
}
|
||||
if err := f.Delete(ctx, id); !errors.Is(err, boom) {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFakeDefaultNotifications(t *testing.T) {
|
||||
f := NewFake()
|
||||
if ids, err := f.DefaultNotifications(context.Background()); err != nil || ids != nil {
|
||||
t.Fatalf("empty: %v %v", ids, err)
|
||||
}
|
||||
f.SetDefaultNotifications(4, 1)
|
||||
if ids, err := f.DefaultNotifications(context.Background()); err != nil || len(ids) != 2 || ids[0] != 1 || ids[1] != 4 {
|
||||
t.Fatalf("set: %v %v", ids, err)
|
||||
}
|
||||
f.SetNotificationsErr(errors.New("boom"))
|
||||
if _, err := f.DefaultNotifications(context.Background()); err == nil {
|
||||
t.Error("expected injected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFakeUpdateRejectsOtherOwnerKey(t *testing.T) {
|
||||
f := NewFake()
|
||||
id, _ := f.Create(context.Background(), Monitor{Name: "a", OwnerKey: "ns/a"})
|
||||
if err := f.Update(context.Background(), Monitor{ID: id, Name: "b", OwnerKey: "ns/other"}); !errors.Is(err, ErrNotOwned) {
|
||||
t.Fatalf("want ErrNotOwned, got %v", err)
|
||||
}
|
||||
if got := f.Get(id); got.Name != "a" {
|
||||
t.Errorf("monitor changed: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFakeProbe(t *testing.T) {
|
||||
f := NewFake()
|
||||
owned, _ := f.Create(context.Background(), Monitor{Name: "a", OwnerKey: "ns/a"})
|
||||
foreign := f.AddForeign(Monitor{Name: "x"})
|
||||
if found, key, err := f.Probe(context.Background(), owned); err != nil || !found || key != "ns/a" {
|
||||
t.Errorf("owned: %v %q %v", found, key, err)
|
||||
}
|
||||
if found, key, err := f.Probe(context.Background(), foreign); err != nil || !found || key != "" {
|
||||
t.Errorf("foreign: %v %q %v", found, key, err)
|
||||
}
|
||||
if found, _, err := f.Probe(context.Background(), 999); err != nil || found {
|
||||
t.Errorf("missing: %v %v", found, err)
|
||||
}
|
||||
f.SetProbeErr(errors.New("boom"))
|
||||
if _, _, err := f.Probe(context.Background(), owned); err == nil {
|
||||
t.Error("expected injected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFakeDeleteTwiceNotFound(t *testing.T) {
|
||||
f := NewFake()
|
||||
id, _ := f.Create(context.Background(), Monitor{Name: "a", OwnerKey: "ns/a"})
|
||||
if err := f.Delete(context.Background(), id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Delete(context.Background(), id); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("expected ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFakeUpdateUnknownNotFound(t *testing.T) {
|
||||
f := NewFake()
|
||||
if err := f.Update(context.Background(), Monitor{ID: 42, OwnerKey: "ns/a"}); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("expected ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFakeDoesNotAlias(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
f := NewFake()
|
||||
parent := int64(7)
|
||||
in := Monitor{
|
||||
Name: "a", OwnerKey: "ns/a", Parent: &parent,
|
||||
Headers: map[string]string{"k": "v"},
|
||||
AcceptedStatusCodes: []string{"200-299"},
|
||||
}
|
||||
id, _ := f.Create(ctx, in)
|
||||
|
||||
check := func(m Monitor, where string) {
|
||||
t.Helper()
|
||||
if m.Headers["k"] != "v" || m.AcceptedStatusCodes[0] != "200-299" || *m.Parent != 7 {
|
||||
t.Fatalf("%s: state aliased: %+v parent=%d", where, m, *m.Parent)
|
||||
}
|
||||
}
|
||||
|
||||
// Mutating the input after Create must not affect stored state.
|
||||
in.Headers["k"] = "changed"
|
||||
in.AcceptedStatusCodes[0] = "changed"
|
||||
parent = 8
|
||||
check(*f.Get(id), "after create mutation")
|
||||
|
||||
// Mutating returned values must not affect stored state.
|
||||
got := f.Get(id)
|
||||
got.Headers["k"] = "changed"
|
||||
got.AcceptedStatusCodes[0] = "changed"
|
||||
*got.Parent = 9
|
||||
listed, _ := f.ListOwned(ctx)
|
||||
listed[0].Headers["k"] = "changed"
|
||||
listed[0].AcceptedStatusCodes[0] = "changed"
|
||||
*listed[0].Parent = 9
|
||||
check(*f.Get(id), "after return mutation")
|
||||
|
||||
// Update copies too.
|
||||
up := Monitor{ID: id, OwnerKey: "ns/a", Parent: new(int64), Headers: map[string]string{"k": "v"}, AcceptedStatusCodes: []string{"200-299"}}
|
||||
*up.Parent = 7
|
||||
if err := f.Update(ctx, up); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
up.Headers["k"] = "changed"
|
||||
up.AcceptedStatusCodes[0] = "changed"
|
||||
*up.Parent = 10
|
||||
check(*f.Get(id), "after update mutation")
|
||||
|
||||
// AddForeign copies too.
|
||||
fm := Monitor{Parent: new(int64), Headers: map[string]string{"k": "v"}, AcceptedStatusCodes: []string{"200-299"}}
|
||||
*fm.Parent = 7
|
||||
fid := f.AddForeign(fm)
|
||||
fm.Headers["k"] = "changed"
|
||||
fm.AcceptedStatusCodes[0] = "changed"
|
||||
*fm.Parent = 10
|
||||
check(*f.Get(fid), "after foreign mutation")
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package kumaclient
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
kuma "github.com/breml/go-uptime-kuma-client"
|
||||
"github.com/breml/go-uptime-kuma-client/notification"
|
||||
"github.com/breml/go-uptime-kuma-client/tag"
|
||||
)
|
||||
|
||||
func TestPickOwnerTagLowestID(t *testing.T) {
|
||||
tags := []tag.Tag{
|
||||
{ID: 9, Name: "kuma-sync"},
|
||||
{ID: 3, Name: "other"},
|
||||
{ID: 4, Name: "kuma-sync"},
|
||||
{ID: 7, Name: "kuma-sync"},
|
||||
}
|
||||
id, ok := pickOwnerTag(tags)
|
||||
if !ok || id != 4 {
|
||||
t.Fatalf("got %d %v, want 4 true", id, ok)
|
||||
}
|
||||
if _, ok := pickOwnerTag([]tag.Tag{{ID: 1, Name: "other"}}); ok {
|
||||
t.Fatal("expected no match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerKeyOf(t *testing.T) {
|
||||
mt := func(tagID int64, v string) tag.MonitorTag { return tag.MonitorTag{TagID: tagID, Value: v} }
|
||||
cases := []struct {
|
||||
name string
|
||||
tags []tag.MonitorTag
|
||||
want string
|
||||
conflict bool
|
||||
}{
|
||||
{"none", nil, "", false},
|
||||
{"other tag only", []tag.MonitorTag{mt(2, "x")}, "", false},
|
||||
{"single", []tag.MonitorTag{mt(1, "ns/a"), mt(2, "x")}, "ns/a", false},
|
||||
{"same value twice", []tag.MonitorTag{mt(1, "ns/a"), mt(1, "ns/a")}, "ns/a", false},
|
||||
{"conflict", []tag.MonitorTag{mt(1, "ns/a"), mt(1, "ns/b")}, "", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, err := ownerKeyOf(5, c.tags, 1)
|
||||
if c.conflict != errors.Is(err, ErrOwnerConflict) {
|
||||
t.Errorf("%s: err = %v, want conflict=%v", c.name, err, c.conflict)
|
||||
}
|
||||
if got != c.want {
|
||||
t.Errorf("%s: key = %q, want %q", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConflictErrorListsAllKeys(t *testing.T) {
|
||||
mt := func(v string) tag.MonitorTag { return tag.MonitorTag{TagID: 1, Value: v} }
|
||||
_, err := ownerKeyOf(7, []tag.MonitorTag{mt("default/x"), mt("default/y"), mt("default/a"), mt("default/x")}, 1)
|
||||
var ce *ConflictError
|
||||
if !errors.As(err, &ce) || !errors.Is(err, ErrOwnerConflict) {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if ce.MonitorID != 7 || !reflect.DeepEqual(ce.Keys, []string{"default/a", "default/x", "default/y"}) {
|
||||
t.Errorf("conflict = %+v", ce)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConflicts(t *testing.T) {
|
||||
a := &ConflictError{MonitorID: 1, Keys: []string{"a", "b"}}
|
||||
b := &ConflictError{MonitorID: 2, Keys: []string{"c", "d", "e"}}
|
||||
got, only := Conflicts(errors.Join(a, fmt.Errorf("wrapped: %w", b)))
|
||||
if !only || len(got) != 2 || got[0] != a || got[1] != b {
|
||||
t.Errorf("got %v only=%v", got, only)
|
||||
}
|
||||
if _, only := Conflicts(errors.Join(a, errors.New("other"))); only {
|
||||
t.Error("mixed error must not be conflict-only")
|
||||
}
|
||||
if got, only := Conflicts(errors.New("plain")); len(got) != 0 || only {
|
||||
t.Errorf("plain: %v %v", got, only)
|
||||
}
|
||||
if _, only := Conflicts(nil); only {
|
||||
t.Error("nil is not conflict-only")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatedDespite(t *testing.T) {
|
||||
timeout := fmt.Errorf("create monitor: %w", kuma.ErrUpdateEventTimeout)
|
||||
if !createdDespite(5, nil) {
|
||||
t.Error("success must proceed")
|
||||
}
|
||||
if !createdDespite(5, timeout) {
|
||||
t.Error("update-event timeout with an ID must proceed to tagging")
|
||||
}
|
||||
if createdDespite(0, timeout) {
|
||||
t.Error("no ID must not proceed")
|
||||
}
|
||||
if createdDespite(0, errors.New("boom")) || createdDespite(5, errors.New("boom")) {
|
||||
t.Error("other errors must not proceed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagUnknownCreate(t *testing.T) {
|
||||
run := func(id int64, err error, dead bool) (int64, error, int) {
|
||||
calls := 0
|
||||
gotID, gotErr := flagUnknownCreate(
|
||||
func() (int64, error) { calls++; return id, err },
|
||||
func(error) bool { return dead },
|
||||
)
|
||||
return gotID, gotErr, calls
|
||||
}
|
||||
|
||||
transport := errors.New("use of closed network connection")
|
||||
if id, err, calls := run(0, transport, true); !errors.Is(err, ErrPossibleOrphan) || !errors.Is(err, transport) || id != 0 || calls != 1 {
|
||||
t.Errorf("transport failure: id=%d err=%v calls=%d; want ErrPossibleOrphan and exactly one create", id, err, calls)
|
||||
}
|
||||
timeout := fmt.Errorf("add: %w", kuma.ErrOperationTimeout)
|
||||
if _, err, calls := run(0, timeout, false); !errors.Is(err, ErrPossibleOrphan) || calls != 1 {
|
||||
t.Errorf("operation timeout: err=%v calls=%d; want ErrPossibleOrphan and exactly one create", err, calls)
|
||||
}
|
||||
app := errors.New("add: validation failed")
|
||||
if _, err, _ := run(0, app, false); errors.Is(err, ErrPossibleOrphan) || !errors.Is(err, app) {
|
||||
t.Errorf("application error must pass through unflagged: %v", err)
|
||||
}
|
||||
if id, err, _ := run(5, nil, false); err != nil || id != 5 {
|
||||
t.Errorf("success: id=%d err=%v", id, err)
|
||||
}
|
||||
already := fmt.Errorf("x: %w", ErrPossibleOrphan)
|
||||
if _, err, _ := run(0, already, true); !errors.Is(err, ErrPossibleOrphan) {
|
||||
t.Errorf("existing orphan error lost: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalized(t *testing.T) {
|
||||
in := Monitor{Type: "keyword", Interval: 60, Headers: map[string]string{}}
|
||||
got := in.Normalized()
|
||||
if got.Method != "GET" || !reflect.DeepEqual(got.AcceptedStatusCodes, []string{"200-299"}) {
|
||||
t.Errorf("defaults not applied: %+v", got)
|
||||
}
|
||||
if got.Timeout != 48 {
|
||||
t.Errorf("timeout = %d, want 48", got.Timeout)
|
||||
}
|
||||
if got.Headers != nil {
|
||||
t.Errorf("empty headers must be nil: %v", got.Headers)
|
||||
}
|
||||
if !reflect.DeepEqual(got.Normalized(), got) {
|
||||
t.Error("Normalized is not idempotent")
|
||||
}
|
||||
explicit := Monitor{Type: "http", Interval: 60, Timeout: 10, Method: "POST", AcceptedStatusCodes: []string{"301"}}
|
||||
if e := explicit.Normalized(); e.Timeout != 10 || e.Method != "POST" || e.AcceptedStatusCodes[0] != "301" {
|
||||
t.Errorf("explicit values changed: %+v", e)
|
||||
}
|
||||
dns := Monitor{Type: "dns", Interval: 60, Timeout: 30}.Normalized()
|
||||
if dns.Timeout != 0 || dns.Method != "" || dns.AcceptedStatusCodes != nil {
|
||||
t.Errorf("non-http type must not gain http defaults: %+v", dns)
|
||||
}
|
||||
if !reflect.DeepEqual(dns.Normalized(), dns) {
|
||||
t.Error("Normalized is not idempotent for dns")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFakeStoresNormalized(t *testing.T) {
|
||||
f := NewFake()
|
||||
id, err := f.Create(t.Context(), Monitor{Type: "http", Interval: 100, OwnerKey: "a"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := f.Get(id)
|
||||
if got.Method != "GET" || got.Timeout != 80 {
|
||||
t.Fatalf("fake did not normalize: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizedNotificationIDs(t *testing.T) {
|
||||
in := Monitor{Type: "group", NotificationIDs: []int64{10, 2, 10}}
|
||||
got := in.Normalized()
|
||||
if !reflect.DeepEqual(got.NotificationIDs, []int64{2, 10}) {
|
||||
t.Errorf("notifications = %v, want [2 10]", got.NotificationIDs)
|
||||
}
|
||||
if !reflect.DeepEqual(in.NotificationIDs, []int64{10, 2, 10}) {
|
||||
t.Errorf("Normalized changed its receiver: %v", in.NotificationIDs)
|
||||
}
|
||||
if e := (Monitor{Type: "group", NotificationIDs: []int64{}}).Normalized(); e.NotificationIDs != nil {
|
||||
t.Errorf("empty notifications must be nil: %v", e.NotificationIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultIDs(t *testing.T) {
|
||||
got := defaultIDs([]notification.Base{
|
||||
{ID: 5, IsDefault: true}, {ID: 1}, {ID: 3, IsDefault: true},
|
||||
})
|
||||
if !reflect.DeepEqual(got, []int64{3, 5}) {
|
||||
t.Errorf("defaultIDs = %v, want [3 5]", got)
|
||||
}
|
||||
if got := defaultIDs(nil); got != nil {
|
||||
t.Errorf("defaultIDs(nil) = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
# domains -> TLS passthrough -> Traefik ClusterIP) from Ingresses carrying the
|
||||
# public label. Standard library only, so the image is the interpreter plus one
|
||||
# file; runs unprivileged as its own user.
|
||||
FROM python:3.14-slim@sha256:a2b82f3c48559aa0a8446d9af49826b6e2b2016f4cd2afabfe6013ec53729170
|
||||
FROM python:3.15-slim@sha256:2da676046630d7bf14c6764ae1143dc7fa0377a53f94bf5a642f0b214aca5bce
|
||||
|
||||
RUN useradd --system --no-create-home --shell /usr/sbin/nologin controller
|
||||
COPY controller.py /opt/controller.py
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
# homelab → konairius/homelab, master → main — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Move the Gitea repo `k.renner/homelab` into the `konairius` org and rename its default branch `master` → `main`, with no lost Flux sync, pipeline, cron, secret or review.
|
||||
|
||||
**Architecture:** Two separate cut-overs, each with its own window, prep PR and rollback: **Cut-over 1** moves the repo (path changes, branch stays `master`); **Cut-over 2** renames the branch (path stays). Everything that can land beforehand lands beforehand; each cut-over's own PR is merged inside its window. The pattern is the one proven on vsftpd, notes, renovate-config, authentik-samba-bridge and homeassistant on 2026-10-18.
|
||||
|
||||
**Tech Stack:** Gitea 1.27 (API, transfer, `repoRenameBranch`), Woodpecker v3.19.0 (move API, crons, org secrets), Flux (GitRepository `flux-system`, ImageUpdateAutomation `homelab`), secret-rotator, OpenBao.
|
||||
|
||||
**Spec:** this conversation's decisions, recorded in the wiki raw notes of 2026-10-18 (konairius migration series). No separate spec doc.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- All repos live in `konairius` (exception: `k.renner/turm1388` stays).
|
||||
- The default branch of every repo is `main`.
|
||||
- Gitea old paths redirect (301) for reads; **Gitea API writes through an old path fail** (wiki on `k.renner/notes`, 2026-10-18). Every writer must address the new path right after the transfer.
|
||||
- A transferred repo keeps Woodpecker's old org link until `POST /api/repos/{id}/move?to=<owner>/<repo>` (homelab: Woodpecker repo id `1`). Org secrets follow the org link.
|
||||
- Gitea's `RenameBranch` renames, in one transaction: the branch, the default branch, a protection rule named exactly `master`, and the base/head of unmerged PRs (`models/git/branch.go`). It does **not** keep a `master` ref; anything fetching `master` fails afterwards.
|
||||
- Renaming the default branch needs repo admin (owner or site admin), not `claude-bot`.
|
||||
- Gitea's transfer removes collaborators who are **members** of the new org (their access then comes only from their konairius team) and keeps non-members (`services/repository/transfer.go`).
|
||||
- `woodpecker repo repair` copies the forge's default branch into Woodpecker (`repairRepo` → `Repo.Update` sets `Branch`).
|
||||
- Checked live 2026-10-18: Woodpecker repo id `1`; cron ids `alert-monitor` 4, `ci-claude-refresh` 7, `daily-security-scan` 2, `issue-fix` 5, `update-audit` 3, `weekly-update` 1, `wiki-maintenance` 8; the `k.renner` org holds only `claude_code_oauth_token` and `review_bot_token`, every other pipeline secret is a repo secret of homelab.
|
||||
- Gitea API: `PATCH /repos/{owner}/{repo}/branches/{branch}` with `{"name": "<new>"}` (`repoRenameBranch`), present on our Gitea.
|
||||
- Each service reads only its own OpenBao path; no cross-service key sharing.
|
||||
- Never force-push; PRs for every repo change; never `2>/dev/null`.
|
||||
|
||||
## Review Focus
|
||||
|
||||
1. **Flux fetch across the transfer.** Flux's GitRepository fetches `k.renner/homelab.git` until the URL change lands. Documented behaviour says this works: source-controller v1.9.5 uses go-git v5.19.1 (its `go.mod`), whose `RedirectPolicy` "mirror[s] Git's http.followRedirects config" with the zero value `initial` (pkg.go.dev); git documents `initial` as following the redirect on the first request and using "the redirected URL as the base for the follow-up requests" (`git-config`, `http.followRedirects`). Gitea's 301 stays on the same host and scheme, so fetch and the IUA push both reach `konairius/homelab`. Fallback only, should that fail: Task 3 has a declared break-glass (suspend the `flux-system` Kustomization so it cannot re-apply the old URL, patch the GitRepository URL to the value the PR sets, resume once the source is Ready on the merge commit), checked by `flux get source git flux-system`.
|
||||
6. **Write access lost in the transfer.** Any writer that is a konairius org member loses its collaborator grant and keeps only its team permission (`flux` pushes for image automation; claude-bot, issue-fix-bot, security-bot, review-bot, argus-bot, renovate-bot write PRs/reviews). Expect: Task 3 Step 0 snapshot of collaborators, konairius teams and the branch-protection whitelists, compared after Step 1.
|
||||
2. **Image automation push between merge and rename (Cut-over 2).** The IUA pushes to `master`; after the Task 5 PR it expects `main`. Expect: IUA suspended for the whole window and resumed only after Flux reports `main`.
|
||||
3. **Org secrets after the Woodpecker move.** Homelab pipelines use the org secrets `claude_code_oauth_token` and `review_bot_token`; after the move they resolve in `konairius`. Expect: both exist in `konairius` before Cut-over 1 (Task 1 verification).
|
||||
4. **Crons on a vanished branch.** All 7 crons run on `master`. Expect: each switched to `main` in place (`woodpecker repo cron update --branch main`) inside the Cut-over 2 window, verified by `cron ls`.
|
||||
5. **Rotations that verify against the old path.** flux-git (`GITEA_REQUIRE_PUSH=true`, verify repo `k.renner/homelab`) would fail every quarter. Expect: verify repo switched in Task 2 and a manual rotator run in Task 3.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Org secrets in konairius before the move
|
||||
|
||||
**Files:**
|
||||
- Modify: `kubernetes/infrastructure/openbao/rotation/claude-oauth.yaml` (add a second container `sync-woodpecker-konairius`, same env with `WOODPECKER_ORG=konairius`)
|
||||
- Modify: `kubernetes/infrastructure/openbao/rotation/review-bot.yaml` (same: a second sync container for `konairius`, `WOODPECKER_SECRET_NAME=review_bot_token`, events `cron,manual,pull_request`)
|
||||
|
||||
**Interfaces:** Produces the konairius org secrets `claude_code_oauth_token` and `review_bot_token` that Task 3 relies on. The k.renner containers stay until Task 4.
|
||||
|
||||
- [ ] **Step 1:** Add the containers. Both CronJobs reuse their existing OpenBao role (it only reads OpenBao and the woodpecker-bot bootstrap; no policy change).
|
||||
- [ ] **Step 2:** `kubectl kustomize kubernetes/infrastructure/openbao/rotation` renders.
|
||||
- [ ] **Step 3:** PR, merge, `flux reconcile kustomization infrastructure`.
|
||||
- [ ] **Step 4:** Run both once: `kubectl create job -n openbao --from=cronjob/claude-oauth-secret-sync claude-oauth-konairius` and `--from=cronjob/review-bot-secret-rotate review-bot-konairius`. Expected logs: `updated|created woodpecker org secret konairius/<name>`. Note: the review-bot run also executes its init containers `rotate-mint` and `rotate-mint-mcp`, i.e. mints and reaps **both** review-bot PATs off schedule. Keeping the two newest makes one extra run safe; afterwards check `kubectl get externalsecret -n review-bot review-bot-secrets` is SecretSynced before anything else rotates.
|
||||
- [ ] **Step 5:** `woodpecker org secret ls --organization konairius` lists `claude_code_oauth_token`, `review_bot_token`, `registry_bot_token`; `woodpecker org secret ls --organization k.renner` still lists only `claude_code_oauth_token` and `review_bot_token` (nothing else to carry over).
|
||||
|
||||
### Task 2: PR "follow homelab to konairius/homelab" (prepared, merged in Task 3)
|
||||
|
||||
**Files (all FUNCTIONAL `k.renner/homelab` references, 2026-10-18 inventory):**
|
||||
- `kubernetes/clusters/homelab/flux-system/gotk-sync.yaml:14` — GitRepository url → `https://gitea.renner.uno/konairius/homelab.git`
|
||||
- `kubernetes/infrastructure/openbao/rotation/flux-git.yaml:81` — `GITEA_VERIFY_REPO` → `konairius/homelab` (and the comment at :84)
|
||||
- `kubernetes/apps/review-bot/deployment.yaml:38` — `REPOS` entry → `konairius/homelab`
|
||||
- `.woodpecker/alert-monitor.yml:133,241`, `ci-claude-refresh.yml:177,179,200`, `issue-fix.yml:85,166,190,199`, `pr-review.yml:46`, `security-scan.yml:150,160`, `update-audit.yml:114` — API, clone and push URLs
|
||||
- Header comments with `--repo k.renner/homelab` / `pipeline deploy k.renner/homelab` in the same files and `e2e.yml:10`
|
||||
- `review-bot/go.mod:1` → `module gitea.renner.uno/konairius/homelab/review-bot` (no Go file imports it) and the test fixtures in `review-bot/*_test.go` (consistency only)
|
||||
- `ci/images/heating-can/Containerfile:66` — OCI source label
|
||||
|
||||
- [ ] **Step 1:** Apply the replacements; leave `docs/`, `PROPOSAL*` and `MIGRATION*` history as is.
|
||||
- [ ] **Step 2:** `git grep -n 'k.renner/homelab' -- ':!docs'` returns only historical files (`PROPOSAL*`, `MIGRATION*`).
|
||||
- [ ] **Step 3:** `go test -C review-bot ./...` passes; `kubectl kustomize kubernetes/clusters/homelab` and the touched app directories render.
|
||||
- [ ] **Step 4:** Open the PR titled "(merge inside Cut-over 1 only)"; do not merge.
|
||||
|
||||
### Task 3: Cut-over 1 — transfer (window)
|
||||
|
||||
**Preconditions:** Task 1 verified; Task 2 PR green; no deploy pipeline running (`woodpecker pipeline ls k.renner/homelab --limit 5`); record `flux get source git flux-system` revision.
|
||||
|
||||
- [ ] **Step 0 (owner, needs admin):** snapshot `tea api --login renner.uno /repos/k.renner/homelab/collaborators`, `tea api --login renner.uno /orgs/konairius/teams` plus each team's members, and the `master` rule's push/merge/approvals whitelists (`tea api --login renner.uno /repos/k.renner/homelab/branch_protections`). Any writer that is a konairius member must get write access through its team, or be re-added as collaborator right after Step 1.
|
||||
- [ ] **Step 1 (owner):** `tea api --login renner.uno -X POST /repos/k.renner/homelab/transfer -f new_owner=konairius`. Expect `curl -sI https://gitea.renner.uno/k.renner/homelab` → 301.
|
||||
- [ ] **Step 2 (claude-bot):** Woodpecker move: `POST $WOODPECKER_SERVER/api/repos/1/move?to=konairius/homelab` → 204; `woodpecker repo ls` shows `konairius/homelab`.
|
||||
- [ ] **Step 3 (owner):** merge the Task 2 PR.
|
||||
- [ ] **Step 1a:** repeat the Step 0 reads against `konairius/homelab` and compare; re-add any lost writer as collaborator before continuing.
|
||||
- [ ] **Step 4:** `flux reconcile source git flux-system`. If it fails to fetch: break-glass — `flux suspend kustomization flux-system` (else it re-applies the last good artifact with the old URL), then `kubectl -n flux-system patch gitrepository flux-system --type merge -p '{"spec":{"url":"https://gitea.renner.uno/konairius/homelab.git"}}'` (the value the PR sets), wait for the source to be Ready on the Task 2 merge commit, then `flux resume kustomization flux-system`. Expect: revision = Task 2 merge commit, Ready.
|
||||
- [ ] **Step 5:** `flux reconcile image update homelab` after the next ImagePolicy change, or check the last IUA push reaches `konairius/homelab` (commit by Flux Image Automation on master).
|
||||
- [ ] **Step 6:** Run flux-git rotator once (`kubectl create job -n openbao --from=cronjob/flux-git-secret-rotate flux-git-move`); log shows `pull+push verified` against `konairius/homelab`.
|
||||
- [ ] **Step 6a:** Expected side effects of the Task 2 merge, not failures: `build-ci-images` rebuilds the review-bot image (go.mod, tests) and heating-can (label), and image automation rolls out the new review-bot tag; `ci-claude-refresh` runs (its own file is in its path filter) and may open a ci-claude bump PR.
|
||||
- [ ] **Step 7:** Verify writers: a PR on konairius/homelab gets a review-bot review; `woodpecker pipeline ls konairius/homelab` shows the merge's push pipeline green; an agent cron (alert-monitor, every 15 min) succeeds; `security-scan`/`issue-fix` open PRs against the new path at their next run.
|
||||
- [ ] **Step 8:** Check the GitHub push mirror (`konairius/Homelab` on GitHub, restore-test source) still syncs (Gitea repo Settings → Mirror, last sync after the transfer).
|
||||
|
||||
**Rollback:** owner transfers back to `k.renner`, Woodpecker move back to `k.renner/homelab`, revert the Task 2 PR.
|
||||
|
||||
### Task 4: After Cut-over 1
|
||||
|
||||
- [ ] Remove the `k.renner` sync containers from Task 1 (nothing in the `k.renner` Woodpecker org uses them any more; turm1388 uses repo secrets only). PR, reconcile.
|
||||
- [ ] Workstation: homelab clone remote → `https://gitea.renner.uno/konairius/homelab.git`; `~/source/CLAUDE.md:13` row → `konairius/homelab`.
|
||||
- [ ] Wiki deposit of the observed Cut-over 1 results.
|
||||
|
||||
### Task 5: PR "default branch main" (prepared, merged in Task 6)
|
||||
|
||||
**Files (all FUNCTIONAL `master` references):**
|
||||
- `.woodpecker/build-ci-images.yml:39`, `ci-claude-refresh.yml:71`, `deploy-claude-workstation.yml:4`, `deploy-edge-mqtt-bridge.yml:4`, `deploy-infrastructure.yml:7`, `deploy-netbird-client.yml:4`, `deploy-netbird-proxy.yml:4`, `deploy-netbird-server.yml:4`, `security-scan.yml:72` — `branch: main`
|
||||
- `.woodpecker/ci-claude-refresh.yml:211`, `issue-fix.yml:202`, `security-scan.yml:155` — PR `base: "main"`
|
||||
- `kubernetes/clusters/homelab/flux-system/gotk-sync.yaml:11` — `ref.branch: main`
|
||||
- `kubernetes/infrastructure/image-automation/update-automation.yaml:26,36` — checkout and push branch `main`
|
||||
- `kubernetes/restore-test/flux-root.yaml:16`, `kubernetes/vmtest-job.yaml:90` — `main`
|
||||
- `kubernetes/bootstrap.sh:107` default `main`, `:1478-1479` guard on `main`, `:102` example
|
||||
- **Not** the prompts: `review-bot/prompt.md` is baked into the review-bot image, which `build-ci-images` only rebuilds on a push to the branch its `when:` names. In this PR that filter flips to `main` while the PR merges into `master`, so the prompt edit would not ship. It moves to Task 6 Step 9.
|
||||
|
||||
- [ ] **Step 1:** Apply; `git grep -n -w master -- ':!docs' ':!ansible'` returns only historical/runbook text (handled in Task 7), the two prompts, and unrelated words (VRRP, Open3E, Multus `"master"`).
|
||||
- [ ] **Step 2:** `bash -n kubernetes/bootstrap.sh`; `kubectl kustomize kubernetes/clusters/homelab` renders.
|
||||
- [ ] **Step 3:** Open the PR titled "(merge inside Cut-over 2 only)"; do not merge.
|
||||
|
||||
### Task 6: Cut-over 2 — rename master → main (window)
|
||||
|
||||
**Preconditions:** Cut-over 1 done; Task 5 PR green; no deploy pipeline running.
|
||||
|
||||
- [ ] **Step 1:** `flux suspend image update homelab`.
|
||||
- [ ] **Step 2 (owner):** merge the Task 5 PR into `master`. Flux now points at `main`, which does not exist yet; the cluster keeps its last applied state.
|
||||
- [ ] **Step 3 (owner, immediately):** `tea api --login renner.uno -X PATCH /repos/konairius/homelab/branches/master -f name=main`. Expect: default branch `main`, protection rule now named `main` (`GET /repos/konairius/homelab/branches/main` → `protected: true, required_approvals: 1`), open PRs based on `main`.
|
||||
- [ ] **Step 4:** `flux reconcile source git flux-system` → Ready on `main@<merge commit>`; `flux get kustomizations` all Ready.
|
||||
- [ ] **Step 5:** Crons: for each of `alert-monitor`(4), `ci-claude-refresh`(7), `daily-security-scan`(2), `issue-fix`(5), `update-audit`(3), `weekly-update`(1): `woodpecker repo cron update --repository konairius/homelab --id <id> --branch main`. Delete `wiki-maintenance`(8): no pipeline filters on it (`git grep wiki-maintenance -- .woodpecker` is empty), it starts nothing. `cron ls` shows six crons, all on `main`.
|
||||
- [ ] **Step 6:** `woodpecker repo repair konairius/homelab`; `woodpecker repo show` shows branch `main`.
|
||||
- [ ] **Step 7:** `flux resume image update homelab`; next IUA commit lands on `main`.
|
||||
- [ ] **Step 8:** Verify: a small PR into `main` gets reviewed and its merge triggers the push pipelines; the deploy pipelines' `when: branch: main` fire.
|
||||
- [ ] **Step 9:** PR into `main` updating `review-bot/prompt.md:16` and `ci/agents/pr-review/mistral-prompt.md:16` (all repos use `main`). Its merge is a push to `main` touching `review-bot/**`, so `build-ci-images` rebuilds the review-bot image; image automation rolls it out; review-bot's log shows the Mistral agents re-synced (new prompt hash). This PR doubles as the Step 8 test PR.
|
||||
- [ ] **Step 10 (owner, GitHub):** the push mirror `konairius/Homelab` on GitHub now carries `main`. GitHub refuses to delete a repo's default branch, so first set its default branch to `main` (repo Settings → Branches), then delete `master` there. `kubernetes/restore-test/flux-root.yaml` already points at `main` (Task 5).
|
||||
|
||||
**Rollback:** `PATCH .../branches/main -f name=master`, revert the Task 5 PR, crons back to `master` (re-add `wiki-maintenance` only if wanted), resume IUA.
|
||||
|
||||
### Task 7: Doc and memory sweep
|
||||
|
||||
- [ ] One PR replacing remaining `k.renner/homelab` and `master` in runbooks and READMEs: `kubernetes/RUNBOOK-cold-rebuild.md` (incl. `flux bootstrap --branch=master` at :282), `RUNBOOK-home-assistant-image-rollback.md`, `kubernetes/infrastructure/image-automation/README.md`, `README.md`, pipeline comments, `ci/agents/pr-review/run.sh`. Leave dated plans/specs and `MIGRATION*` history unchanged.
|
||||
- [ ] Update the two memory notes that assume `master` (`feedback_image_bumps_via_flux_automation.md`, vican `verify-repo-freshness-before-review.md`).
|
||||
- [ ] Local clones: `git branch -m master main && git fetch origin && git branch -u origin/main main` (owner's workstation).
|
||||
- [ ] Wiki deposit of Cut-over 2 results.
|
||||
@@ -0,0 +1,250 @@
|
||||
# kuma-sync Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace AutoKuma with an in-house Go controller that reconciles typed `KumaMonitor` resources into Uptime Kuma 2.5.6, with ownership stored as a Kuma tag.
|
||||
|
||||
**Architecture:** A controller-runtime manager watches `KumaMonitor` (cluster-wide) and talks to Kuma through `breml/go-uptime-kuma-client` wrapped behind an internal `kumaclient.Client` interface. Each resource maps to one Kuma monitor, found by the tag `kuma-sync=<namespace>/<name>`. Finalizers and a periodic garbage collection keep Kuma free of orphans. The controller has no local state.
|
||||
|
||||
**Tech Stack:** Go 1.27, controller-runtime v0.25.2, controller-gen (CRD + deepcopy), `github.com/breml/go-uptime-kuma-client` v0.6.2, envtest, testcontainers-go, kaniko (Woodpecker), Flux (Kustomization + image automation), ESO/OpenBao, Cilium.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-10-18-kuma-sync-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **Latest stable versions everywhere** (owner requirement). As of 2026-10-18 that is:
|
||||
- Go 1.27 (`go` directive and builder image)
|
||||
- controller-runtime v0.25.2, with the k8s.io libraries and controller-gen/envtest versions it requires
|
||||
- go-uptime-kuma-client v0.6.2
|
||||
- Uptime Kuma 2.5.6 (latest stable; 3.0 is nightly only)
|
||||
- the latest testcontainers-go
|
||||
|
||||
Each task that adds a dependency first checks its latest release (GitHub releases / `go list -m -versions`) and uses that. Before the final commit of each task, run `go list -m -u all` and record that it reports no available updates for direct dependencies. If Kuma ships a newer stable release before Task 7, bump the VPS role and the test image together.
|
||||
- Module path `gitea.renner.uno/k.renner/homelab/kuma-sync`, source in `ci/images/kuma-sync/`.
|
||||
- Kuma client: `github.com/breml/go-uptime-kuma-client v0.6.2`, exact version. Copy its `replace github.com/maldikhan/go.socket.io => github.com/breml/go.socket.io <same pseudo-version>` from that release's `go.mod`.
|
||||
- Kuma under test: `louislam/uptime-kuma:2.5.6-slim`. That is the same image the VPS runs (`ansible/roles/uptime-kuma/defaults/main.yml`).
|
||||
- CRD: group `uptime.renner.uno`, version `v1alpha1`, kind `KumaMonitor`, plural `kumamonitors`, namespaced.
|
||||
- Ownership tag: Kuma tag name `kuma-sync`; value `<namespace>/<name>`. Never read, edit or delete a monitor without that tag.
|
||||
- Finalizer name: `uptime.renner.uno/kuma-sync`.
|
||||
- Requeue when a parent group does not exist yet: 10 s. Full resync and orphan GC: every 5 min.
|
||||
- Kuma URL `https://kuma.renner.uno`; credentials from OpenBao KV v2 mount `homelab`, path `infrastructure/uptime-kuma`, keys `username`, `password` (reuse the existing secret; this controller is Kuma's only API client).
|
||||
- Image `gitea.renner.uno/gitea_admin/kuma-sync`, tag `YYYY-MM-DD-HHMM`, Flux ImagePolicy picks the newest (pattern of `kubernetes/infrastructure/image-automation/authentik-gate-sync.yaml`).
|
||||
- Unit tests run as a stage of the image build (house pattern for authentik-gate-sync). Integration tests (build tag `integration`) need Docker and run locally, not in CI.
|
||||
- No `2>/dev/null`. Config comments are terse factual lines. Commits as claude-bot. Every change goes through a PR. No force-push.
|
||||
|
||||
## Review Focus
|
||||
|
||||
1. **Restart or rebuild duplicates monitors.** That was the AutoKuma failure mode. The adopt-by-tag path is pinned by integration test `TestRestartAdoptsByTag` (Task 3) and reconciler unit test `TestReconcileAdoptsExisting` (Task 4).
|
||||
2. **Deletion while the controller is down orphans a monitor.** The GC pass is pinned by `TestGCDeletesOrphans` (Task 5) and integration `TestDeleteWhileDownIsCollected` (Task 6).
|
||||
3. **A hand-made Kuma monitor gets touched.** `TestGCIgnoresUntagged` (Task 5) and integration `TestUntaggedMonitorUntouched` (Task 6).
|
||||
4. **`editMonitor` is a full overwrite.** Sending a partial object silently clears fields such as `parent` or `accepted_statuscodes`. Pinned by integration `TestUpdateKeepsUnchangedFields` (Task 3).
|
||||
5. **Children are created before their group.** They must requeue after 10 s, not fail for minutes. Pinned by `TestReconcileWaitsForParent` (Task 4).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Module, API types, generated CRD
|
||||
|
||||
**Files:**
|
||||
- Create: `ci/images/kuma-sync/go.mod`, `go.sum`
|
||||
- Create: `ci/images/kuma-sync/api/v1alpha1/kumamonitor_types.go`, `groupversion_info.go`, generated `zz_generated.deepcopy.go`
|
||||
- Create: `ci/images/kuma-sync/config/crd/uptime.renner.uno_kumamonitors.yaml` (generated)
|
||||
- Create: `ci/images/kuma-sync/Makefile` with targets `generate` (controller-gen object + crd), `test`, `test-integration`
|
||||
- Test: `ci/images/kuma-sync/api/v1alpha1/validation_test.go` (envtest)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces the type `KumaMonitorSpec`:
|
||||
- `Type` enum: `group|http|keyword|json-query|dns|port|push`.
|
||||
- `DisplayName string` (required).
|
||||
- `ParentRef *LocalRef{Name}`.
|
||||
- `Interval`, `RetryInterval` (`int32` seconds, min 20, defaults 60).
|
||||
- `MaxRetries` (`int32`, default 0).
|
||||
- `Timeout` (`int32` seconds, min 1).
|
||||
- `Paused bool`.
|
||||
- `Description string`.
|
||||
- `HTTP *HTTPSpec` holds `URL` (required), `Method` (default GET), `AcceptedStatusCodes []string` (default `["200-299"]`), `Headers map[string]string`, `Body string`, `MaxRedirects *int32`, `IgnoreTLS bool`, `ExpiryNotification bool`. It is used by `http`, `keyword` and `json-query`.
|
||||
- `Keyword *KeywordSpec{Keyword, Invert bool}`.
|
||||
- `JSONQuery *JSONQuerySpec`: `Expression string` (JSONata), `Operator` enum `==|!=|<|<=|>|>=|contains`, `Expected string`.
|
||||
- `DNS *DNSSpec{Hostname, ResolveType enum A|AAAA|CNAME|MX|NS|PTR|SOA|SRV|TXT|CAA, ResolveServer default "1.1.1.1", Port default 53}`.
|
||||
- `Port *PortSpec{Hostname, Port int32 1-65535}`.
|
||||
- `Push *PushSpec{}`, an empty marker.
|
||||
- Produces the type `KumaMonitorStatus{MonitorID int64, ObservedGeneration int64, Conditions []metav1.Condition}`, where `Ready` is the only condition type.
|
||||
- CEL rules on the spec:
|
||||
- `type in ['http','keyword','json-query'] == has(self.http)`
|
||||
- `(type == 'keyword') == has(self.keyword)`
|
||||
- `(type == 'json-query') == has(self.jsonQuery)`
|
||||
- `(type == 'dns') == has(self.dns)`
|
||||
- `(type == 'port') == has(self.port)`
|
||||
- `(type == 'push') == has(self.push)`
|
||||
- `type == 'group'` implies none of the blocks are set.
|
||||
|
||||
- [ ] **Step 1: Write failing envtest validation tests.** Each test applies a `KumaMonitor` and asserts the API server's answer:
|
||||
- `TestValidKeyword`: type keyword with http+keyword is accepted.
|
||||
- `TestKeywordWithoutBlock`: rejected; the error contains `keyword`.
|
||||
- `TestGroupWithHTTP`: rejected.
|
||||
- `TestJSONQueryBadOperator`: operator `=~` is rejected.
|
||||
- `TestIntervalBelowMin`: interval 5 is rejected.
|
||||
- `TestDefaults`: an applied http monitor reads back `interval 60` and `acceptedStatusCodes ["200-299"]`.
|
||||
- [ ] **Step 2: Run** `make test`. Expected: FAIL, because the types are missing.
|
||||
- [ ] **Step 3: Write the types with kubebuilder markers, then run** `make generate`.
|
||||
- [ ] **Step 4: Run** `make test`. Expected: all 6 PASS.
|
||||
- [ ] **Step 5: Commit** `feat(kuma-sync): KumaMonitor API types and CRD`.
|
||||
|
||||
### Task 2: Kuma client interface and fake
|
||||
|
||||
**Files:**
|
||||
- Create: `ci/images/kuma-sync/internal/kumaclient/client.go` (interface + domain types)
|
||||
- Create: `ci/images/kuma-sync/internal/kumaclient/fake.go` (in-memory fake used by reconciler tests)
|
||||
- Test: `ci/images/kuma-sync/internal/kumaclient/fake_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
```go
|
||||
type Monitor struct { ID int64; Type, Name, Description string; Parent *int64; Active bool
|
||||
Interval, RetryInterval, MaxRetries, Timeout int32
|
||||
URL, Method, Body string; Headers map[string]string; AcceptedStatusCodes []string; MaxRedirects int32; IgnoreTLS, ExpiryNotification bool
|
||||
Keyword string; InvertKeyword bool; JSONPath, JSONPathOperator, ExpectedValue string
|
||||
Hostname string; Port int32; DNSResolveType, DNSResolveServer string
|
||||
OwnerKey string } // value of the kuma-sync tag; "" = not ours
|
||||
type Client interface {
|
||||
ListOwned(ctx) ([]Monitor, error) // only monitors carrying the kuma-sync tag
|
||||
Create(ctx, m Monitor) (int64, error) // creates and tags with m.OwnerKey
|
||||
Update(ctx, m Monitor) error // full overwrite of an owned monitor; errors if not owned
|
||||
Delete(ctx, id int64) error // errors if not owned
|
||||
}
|
||||
```
|
||||
- The fake also holds untagged monitors (`AddForeign(Monitor)`), so the "never touch untagged" rule can be tested.
|
||||
|
||||
- [ ] **Step 1: Write failing tests:**
|
||||
- `TestFakeListOwnedHidesForeign`
|
||||
- `TestFakeUpdateRejectsForeign`
|
||||
- `TestFakeDeleteRejectsForeign`
|
||||
- `TestFakeCreateAssignsIDAndTag`
|
||||
- [ ] **Step 2: Run** `go test ./internal/kumaclient/...`. Expected: FAIL.
|
||||
- [ ] **Step 3: Implement the interface and the fake.**
|
||||
- [ ] **Step 4: Run** the tests. Expected: PASS.
|
||||
- [ ] **Step 5: Commit** `feat(kuma-sync): Kuma client interface and fake`.
|
||||
|
||||
### Task 3: Real client on breml/go-uptime-kuma-client + integration tests
|
||||
|
||||
**Files:**
|
||||
- Create: `ci/images/kuma-sync/internal/kumaclient/breml.go`
|
||||
- Test: `ci/images/kuma-sync/internal/kumaclient/breml_integration_test.go` (`//go:build integration`)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes the `Client` interface and `Monitor` from Task 2.
|
||||
- Produces `NewBreml(ctx, url, username, password string) (Client, error)`. It logs in once and reuses the session (Kuma rate-limits logins). It ensures the tag `kuma-sync` exists, creating it on first use with color `#2563eb`.
|
||||
|
||||
Integration harness: testcontainers-go starts `louislam/uptime-kuma:2.5.6-slim` with `UPTIME_KUMA_DB_TYPE=sqlite` and waits for port 3001. Setup must then create the admin. Use the client's setup call if it has one; otherwise emit `needSetup`/`setup` after the `info` event, exactly as `ansible/roles/uptime-kuma/files/kuma-setup.js` does.
|
||||
|
||||
- [ ] **Step 1: Write failing integration tests:**
|
||||
- `TestCreateFieldsIntact`: creates a keyword, a json-query (operator `contains`), a dns, a port and a group monitor, then reads them back through a fresh client. Every field from Task 2's `Monitor` must equal the input.
|
||||
- `TestUpdateKeepsUnchangedFields`: updates only `Interval` from 60 to 120. On re-read, `Parent`, `AcceptedStatusCodes`, `Keyword` and `ExpiryNotification` are unchanged.
|
||||
- `TestRestartAdoptsByTag`: creates monitors, discards the client, builds a new one. `ListOwned` returns the same IDs with the same `OwnerKey`s.
|
||||
- `TestUntaggedMonitorUntouched`: a monitor created through the raw library without a tag is absent from `ListOwned`, and `Update`/`Delete` on its ID return an error.
|
||||
- [ ] **Step 2: Run** `go test -tags integration ./internal/kumaclient/...`. Expected: FAIL.
|
||||
- [ ] **Step 3: Implement `breml.go`.** Map `Monitor` to the library's per-type monitor structs. Tag with `AddMonitorTag(tagID, monitorID, ownerKey)`. For `Update`, read the current full object and overwrite only the fields we manage, as Review Focus 4 requires.
|
||||
- [ ] **Step 4: Run** the integration tests. Expected: all 4 PASS.
|
||||
- [ ] **Step 5: Commit** `feat(kuma-sync): Kuma client on go-uptime-kuma-client`.
|
||||
|
||||
### Task 4: Reconciler
|
||||
|
||||
**Files:**
|
||||
- Create: `ci/images/kuma-sync/internal/controller/kumamonitor_controller.go`
|
||||
- Create: `ci/images/kuma-sync/internal/controller/mapping.go` (spec → `kumaclient.Monitor`)
|
||||
- Test: `ci/images/kuma-sync/internal/controller/kumamonitor_controller_test.go` (controller-runtime fake client + Kuma fake)
|
||||
- Test: `ci/images/kuma-sync/internal/controller/mapping_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes `kumaclient.Client` and the API types.
|
||||
- Produces `Reconciler{Client client.Client; Kuma kumaclient.Client}` with `Reconcile(ctx, req) (ctrl.Result, error)` and `SetupWithManager(mgr)`.
|
||||
- Algorithm:
|
||||
1. Get the resource.
|
||||
2. If it is being deleted, delete the owned monitor by OwnerKey (absent counts as success) and remove the finalizer.
|
||||
3. Ensure the finalizer is present.
|
||||
4. Resolve `parentRef`: the parent `KumaMonitor` must exist and have `status.monitorID`. If not, set `Ready=False` with reason `ParentPending` and requeue after 10 s.
|
||||
5. Map the spec to the desired `Monitor`, with `OwnerKey = ns/name`.
|
||||
6. Find the existing monitor via `ListOwned` by OwnerKey. If there is none, `Create`. If it differs on managed fields, `Update`.
|
||||
7. Set the status: monitorID, observedGeneration, `Ready=True`.
|
||||
8. Requeue after 5 min.
|
||||
|
||||
- [ ] **Step 1: Write failing tests:**
|
||||
- `TestMappingAllTypes`: a table test, one row per type, showing that spec fields land on the right `Monitor` fields; `Paused` maps to `Active=false`.
|
||||
- `TestReconcileCreates`
|
||||
- `TestReconcileAdoptsExisting`: the fake already holds a monitor with the same OwnerKey. Expected: no second create, and status.monitorID equals the existing ID.
|
||||
- `TestReconcileUpdatesDrift`: the fake monitor's interval differs. Expected: one `Update`.
|
||||
- `TestReconcileNoopWhenEqual`: zero Update calls.
|
||||
- `TestReconcileWaitsForParent`: a child whose group has no monitorID gets `RequeueAfter == 10s` and `Ready=False/ParentPending`.
|
||||
- `TestReconcileDeleteRemovesMonitorAndFinalizer`
|
||||
- `TestReconcileDeleteToleratesMissingMonitor`
|
||||
- [ ] **Step 2: Run** `go test ./internal/controller/...`. Expected: FAIL.
|
||||
- [ ] **Step 3: Implement the mapping and the reconciler.**
|
||||
- [ ] **Step 4: Run** the tests. Expected: PASS.
|
||||
- [ ] **Step 5: Commit** `feat(kuma-sync): KumaMonitor reconciler`.
|
||||
|
||||
### Task 5: Orphan garbage collection, manager, image
|
||||
|
||||
**Files:**
|
||||
- Create: `ci/images/kuma-sync/internal/controller/gc.go` (a `manager.Runnable` ticking every 5 min)
|
||||
- Create: `ci/images/kuma-sync/cmd/kuma-sync/main.go`
|
||||
- Create: `ci/images/kuma-sync/Containerfile`. Stage 1 is `golang` (pinned digest), runs `go test ./...` without tags, then builds a static binary. Stage 2 is distroless static nonroot.
|
||||
- Modify: `.woodpecker/build-ci-images.yml`. Add a `build-kuma-sync` step that mirrors `build-authentik-gate-sync`, with tag `YYYY-MM-DD-HHMM` plus `latest`.
|
||||
- Test: `ci/images/kuma-sync/internal/controller/gc_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces `GC{Client client.Client; Kuma kumaclient.Client; Interval time.Duration}`. Each run deletes every owned monitor whose OwnerKey has no matching `KumaMonitor`. A list error from either side aborts the run, so nothing is deleted on partial information.
|
||||
- `main.go` reads env `KUMA_URL`, `KUMA_USERNAME` and `KUMA_PASSWORD`. Missing env makes it exit non-zero with a message naming the variable. Health probes run on `:8081`, metrics on `:8080`, and there is no leader election.
|
||||
|
||||
- [ ] **Step 1: Write failing tests:**
|
||||
- `TestGCDeletesOrphans`
|
||||
- `TestGCIgnoresUntagged`: a foreign monitor in the fake survives.
|
||||
- `TestGCKeepsLiveResources`
|
||||
- `TestGCAbortsOnListError`: the fake k8s list fails, and zero deletes happen.
|
||||
- [ ] **Step 2: Run** the tests. Expected: FAIL.
|
||||
- [ ] **Step 3: Implement the GC and `main.go`.**
|
||||
- [ ] **Step 4: Run** `go test ./...` (expected: PASS), then `docker build -f Containerfile .` in `ci/images/kuma-sync` (expected: the build succeeds, with the test stage passing).
|
||||
- [ ] **Step 5: Commit** `feat(kuma-sync): orphan GC, manager and image build`. Open a PR. After the owner merges it, the controller confirms the first image tag exists in the registry before Task 7.
|
||||
|
||||
### Task 6: End-to-end integration test (automated pilot)
|
||||
|
||||
**Files:**
|
||||
- Test: `ci/images/kuma-sync/internal/controller/e2e_integration_test.go` (`//go:build integration`)
|
||||
|
||||
The test runs envtest (an API server with the CRD) plus a Kuma 2.5.6 container plus the real manager, with the breml client and GC interval 2 s.
|
||||
|
||||
- [ ] **Step 1: Write the tests:**
|
||||
- `TestE2EGroupNesting`: apply group `outer`, group `inner` (parent outer), and a dns monitor (parent inner), all at once. Within 60 s, all three are in Kuma with the right parent IDs.
|
||||
- `TestE2ERestartNoDuplicates`: stop the manager, start a new one, wait 2 GC intervals. The monitor count in Kuma is unchanged.
|
||||
- `TestDeleteWhileDownIsCollected`: stop the manager, remove the finalizer, delete the resource, start the manager. The monitor is gone from Kuma within 2 GC intervals.
|
||||
- `TestE2EDriftRepaired`: change the interval directly in Kuma via the raw library. The next resync restores the spec value.
|
||||
- [ ] **Step 2: Run** `go test -tags integration ./internal/controller/...`. Expected: PASS. Any failure is a defect, not something to retry.
|
||||
- [ ] **Step 3: Commit** `test(kuma-sync): end-to-end integration suite`.
|
||||
|
||||
### Task 7: Kubernetes deployment
|
||||
|
||||
**Files:**
|
||||
- Create: `kubernetes/apps/kuma-sync/{crd-kumamonitors.yaml (copied from config/crd), rbac.yaml, externalsecret.yaml, deployment.yaml, kustomization.yaml}`
|
||||
- Create: `kubernetes/clusters/homelab/kuma-sync.yaml`. It is a Flux Kustomization modeled on the removed `autokuma.yaml`: dependsOn infrastructure and infrastructure-crds, with the CEL ExternalSecret health check.
|
||||
- Create: `kubernetes/infrastructure/network-policies/kuma-sync-ns.yaml`, the same shape as the removed `autokuma-ns.yaml`: ingress `[{}]`, DNS with `rules.dns`, kube-apiserver, `toFQDNs kuma.renner.uno:443`. Allow kubelet probes from `host`.
|
||||
- Create: `kubernetes/infrastructure/image-automation/kuma-sync.yaml` (ImageRepository + ImagePolicy).
|
||||
- Modify: `namespaces.yaml` (add `kuma-sync`), `network-policies/kustomization.yaml`, and the `openbao-clustersecretstore.yaml` allowlist (sorted).
|
||||
|
||||
**Interfaces:**
|
||||
- Deployment: image `gitea.renner.uno/gitea_admin/kuma-sync:<first tag from Task 5>` with the setter `# {"$imagepolicy": "flux-system:kuma-sync"}`. 1 replica, `Recreate`. Security context as in `kubernetes/apps/uptime-monitor/deployment.yaml`, with a read-only root filesystem.
|
||||
- ClusterRole: `kumamonitors` get/list/watch/update/patch, `kumamonitors/status` get/update/patch, `kumamonitors/finalizers` update, and `events` create/patch.
|
||||
|
||||
- [ ] **Step 1: Write the manifests.**
|
||||
- [ ] **Step 2: Verify:**
|
||||
- `kubectl kustomize` on every touched directory renders.
|
||||
- `kubectl apply --dry-run=server -f kubernetes/apps/kuma-sync/crd-kumamonitors.yaml` succeeds.
|
||||
- [ ] **Step 3: Commit** `feat(kuma-sync): deploy controller` and open a PR. After merge, the controller checks:
|
||||
- `flux get kustomization kuma-sync` reports Ready.
|
||||
- The pod is Running.
|
||||
- The Kuma log shows a login from the controller.
|
||||
- [ ] **Step 4: Live smoke test** in a follow-up PR: one `KumaMonitor` (keyword on www.renner.uno) in namespace `kuma-sync`. Expected:
|
||||
- It shows up in Kuma, tagged `kuma-sync=kuma-sync/www-renner-uno`.
|
||||
- Deleting the pod changes nothing in Kuma.
|
||||
- Removing it from git deletes the monitor.
|
||||
|
||||
## Follow-up (parent plan)
|
||||
|
||||
Parent plan `2026-10-18-uptime-kuma.md` Task 3 (pilot) is replaced by Tasks 6 and 7 here. Its Tasks 4 and 5 then use `KumaMonitor` instead of `KumaEntity`.
|
||||
@@ -0,0 +1,168 @@
|
||||
# Uptime Kuma (replace UptimeRobot) Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace UptimeRobot with a self-hosted Uptime Kuma on the NetBird VPS, with every monitor, notification and the status page declared in this repo.
|
||||
|
||||
**Architecture:** Uptime Kuma runs as an Ansible-managed container on `netbird.renner.uno` behind that host's existing Traefik, and checks targets over the public internet (no NetBird dependency). An in-cluster controller turns `KumaEntity` custom resources into Kuma monitors over Kuma's public HTTPS endpoint. The cluster's blackbox exporter probes Kuma itself, so a dead VPS still raises an alarm. Kuma moves to its own VPS later by retargeting the Ansible play and DNS; nothing else changes.
|
||||
|
||||
**Tech Stack:** Uptime Kuma 2.5.6 (`louislam/uptime-kuma:2.5.6-slim`, SQLite), AutoKuma 2.0.0 (`ghcr.io/bigboot/autokuma:2.0.0`, Kubernetes source), Ansible + docker compose, Traefik (docker provider), Flux, ESO/OpenBao, Woodpecker.
|
||||
|
||||
**Spec:** this conversation (2026-10-18). There is no separate spec file; the decisions are recorded under *Decisions* below.
|
||||
|
||||
## Decisions (from the design conversation)
|
||||
|
||||
- Kuma lives **off-site**, on the NetBird coordinator VPS for now. It gets its own VPS once the setup is accepted. The NetBird coordinator is not in the ingress path, so sharing the host does not skew what the probes measure.
|
||||
- Monitors are **declared in the repo** as custom resources and reconciled by a controller. A ready-made controller is used if it passes the pilot gate (Task 3). Otherwise we write our own, under a separate plan.
|
||||
- Detailed checks (keyword, JSON query, DNS, TCP, certificate expiry) are a goal, not an afterthought. The custom-resource schema must carry them from the first monitor.
|
||||
- The UptimeRobot IngressMonitorController (`kubernetes/apps/uptime-monitor/`) is removed after cutover.
|
||||
|
||||
## Owner answers (2026-10-18)
|
||||
|
||||
1. The credential source is OpenBao. The play reads it with `community.hashi_vault`.
|
||||
2. Public DNS is managed in the Cloudflare UI. The owner creates `kuma.renner.uno` before Task 1 deploys and repoints `uptime.renner.uno` in Task 5.
|
||||
3. The SMTP sender is `uptime@renner.uno`. Its Proton SMTP settings are stored at OpenBao `homelab/infrastructure/uptime-kuma/smtp` with keys `server`, `port`, `username` and `token`. Task 4 reads all four, and the `uptime-kuma-vps` policy gains read on `homelab/data/infrastructure/uptime-kuma/smtp`.
|
||||
|
||||
## Original open questions (kept for reference)
|
||||
|
||||
1. **Where the Kuma admin credential lives.** Kuma is single-user. Ansible needs that user to bootstrap the instance, and AutoKuma needs it to log in. Ansible has no OpenBao lookup today (`ansible/` uses Ansible Vault only), so storing it in both places makes two copies. *Recommendation:* OpenBao `infrastructure/uptime-kuma` is the only source, and the play reads it with `community.hashi_vault`, using a Woodpecker secret token that has read access to that one path. This plan assumes that recommendation. If it is rejected, Task 1 Step 2 changes and nothing else does.
|
||||
2. **Public DNS.** Neither `netbird.renner.uno` nor `uptime.renner.uno` is defined in this repo (`opentofu/dns_renner_uno.tf` holds LAN records only). `uptime.renner.uno` currently points at UptimeRobot's status-page fleet. Where are these records managed? If it is the Cloudflare UI, Task 1 needs `kuma.renner.uno` created there and Task 5 needs `uptime.renner.uno` repointed there.
|
||||
3. **Alert channel.** Alertmanager sends email through `smtp.protonmail.ch:587` as `cluster@renner.uno`. Kuma needs its own SMTP token, because no service shares another's key. *Recommendation:* create a Proton SMTP token for `uptime@renner.uno` (or another address you pick) and store it at OpenBao `infrastructure/uptime-kuma`, key `smtp_password`. Creating the token is a one-time owner action.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Kuma image: `louislam/uptime-kuma:2.5.6-slim`. Pin it in role defaults, the way `netbird_version` is pinned. The controller drives Kuma's **internal Socket.io API**, which upstream says may change without notice. Kuma and AutoKuma are therefore bumped together, and only after the pilot checks from Task 3 pass against the new pair.
|
||||
- `UPTIME_KUMA_DB_TYPE=sqlite`. This skips the database setup wizard (`server/setup-database.js:98`).
|
||||
- Kuma data lives at `/opt/uptime-kuma/data` (container `/app/data`) on local disk. Not NFS.
|
||||
- Hostnames: `kuma.renner.uno` serves the admin UI and the Socket.io API. `uptime.renner.uno` serves the public status page, through Kuma's custom-domain mapping.
|
||||
- Nothing in the Kuma alert path may traverse the homelab.
|
||||
- No `2>/dev/null` anywhere. No inline interpreters in Bash. Commits use the claude-bot identity, every change goes through a PR, and nothing is ever force-pushed.
|
||||
|
||||
## Review Focus
|
||||
|
||||
1. **Socket.io API drift.** AutoKuma 2.0.0 (2025-11) targets an older Kuma 2.x API than 2.5.6. Silent failures (a monitor created with fields dropped) are worse than loud ones. The Task 3 pilot asserts the field values Kuma actually stored, not just that the monitor exists.
|
||||
2. **Controller restart duplicates monitors.** AutoKuma tracks ownership by tag and keeps state in `/data`. A pod restart with an empty `/data` must not create a second copy of every monitor. Task 3 Step 5 pins this.
|
||||
3. **Deleting a `KumaEntity` while the controller is down.** The finalizer `entity.autokuma.bigboot.dev` must hold the object until the controller is back and removes the monitor. Task 3 Step 6 pins this.
|
||||
4. **Kuma or VPS down goes unnoticed.** The blackbox probe on `https://kuma.renner.uno` plus its alert rule (Task 4) is the dead-man switch. Task 4 Step 4 proves the alert fires.
|
||||
5. **Unclaimed fresh instance.** Between `docker compose up` and admin setup, the first visitor to `kuma.renner.uno` could claim admin. The bootstrap runs in the same play run, before the Traefik route is enabled (Task 1 Step 4).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Uptime Kuma on the NetBird VPS (Ansible role, play, pipeline)
|
||||
|
||||
**Files:**
|
||||
- Create: `ansible/roles/uptime-kuma/defaults/main.yml`
|
||||
- Create: `ansible/roles/uptime-kuma/tasks/main.yml`
|
||||
- Create: `ansible/roles/uptime-kuma/templates/docker-compose.yml.j2`
|
||||
- Create: `ansible/roles/uptime-kuma/files/kuma-setup.js`
|
||||
- Create: `ansible/plays/install-uptime-kuma.yml`
|
||||
- Create: `.woodpecker/deploy-uptime-kuma.yml`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `https://kuma.renner.uno` (Kuma UI and Socket.io), with the admin user from OpenBao `infrastructure/uptime-kuma` keys `username` and `password`. Task 3 consumes these.
|
||||
|
||||
- [ ] **Step 1: Role defaults.** Set `uptime_kuma_version: "2.5.6"`, `uptime_kuma_host: kuma.renner.uno`, `uptime_kuma_status_host: uptime.renner.uno`, `uptime_kuma_dir: /opt/uptime-kuma`, and `uptime_kuma_traefik_network: netbird_netbird`. The last one is the network the NetBird Traefik watches (`ansible/roles/netbird-server/templates/docker-compose.yml.j2`, docker provider).
|
||||
- [ ] **Step 2: Credentials.** Look up `username` and `password` from OpenBao `infrastructure/uptime-kuma` with `community.hashi_vault`. Add the collection to the CI image only if `ci-ansible:2026-04-29` lacks it; check with `ansible-galaxy collection list` in that image. Store the Woodpecker secret `openbao_uptime_kuma_token`, and give it a policy that reads that one path.
|
||||
- [ ] **Step 3: Compose template.** One service, `uptime-kuma`, using image `louislam/uptime-kuma:{{ uptime_kuma_version }}-slim`. Environment `UPTIME_KUMA_DB_TYPE=sqlite`, volume `{{ uptime_kuma_dir }}/data:/app/data`, external network `{{ uptime_kuma_traefik_network }}`. Traefik labels: `traefik.enable={{ uptime_kuma_route_enabled }}` and a router on `Host(kuma.renner.uno) || Host(uptime.renner.uno)` with `tls.certresolver=letsencrypt` and server port 3001.
|
||||
- [ ] **Step 4: Bootstrap with no manual step.** `files/kuma-setup.js` uses `socket.io-client`, which is a production dependency of Kuma 2.5.6 and so present in the image. It connects to `http://localhost:3001`, emits `needSetup`, and if the answer is true emits `setup(username, password)`. If the answer is false it exits 0. It reads the credentials from env vars `KUMA_SETUP_USERNAME` and `KUMA_SETUP_PASSWORD`, never from arguments. The tasks run in this order:
|
||||
1. Template compose with `uptime_kuma_route_enabled: false`, then `docker compose up -d`.
|
||||
2. Wait for `GET http://127.0.0.1:3001` (published on loopback only) to return 200.
|
||||
3. Copy the script in and run it with `docker compose exec -e ... uptime-kuma node /tmp/kuma-setup.js`.
|
||||
4. Re-template with the route enabled and run `docker compose up -d` again.
|
||||
- [ ] **Step 5: Play and pipeline.** `plays/install-uptime-kuma.yml` targets hosts `netbird_server` with roles `uptime-kuma`. `docker-install` is already applied by the NetBird play. `.woodpecker/deploy-uptime-kuma.yml` is a copy of `.woodpecker/deploy-netbird-server.yml` with its paths and play swapped, and it adds the OpenBao token secret.
|
||||
- [ ] **Step 6: Verify.**
|
||||
- Run `ansible-playbook --syntax-check -i ansible/inventory ansible/plays/install-uptime-kuma.yml`. Expected: no errors.
|
||||
- After the pipeline runs, `curl -sI https://kuma.renner.uno/` returns `302` to `/dashboard`. A `/setup` response means the bootstrap failed.
|
||||
- Run the pipeline a second time. Expected: it is green and the script logs "already set up".
|
||||
- [ ] **Step 7: Commit and open a PR.** Message: `feat(uptime-kuma): Uptime Kuma on the NetBird VPS`.
|
||||
|
||||
### Task 2: Controller deployment (AutoKuma, Kubernetes source)
|
||||
|
||||
**Files:**
|
||||
- Create: `kubernetes/apps/autokuma/{crd-kumaentities.yaml,externalsecret.yaml,rbac.yaml,deployment.yaml,kustomization.yaml}`
|
||||
- Create: `kubernetes/clusters/homelab/autokuma.yaml`
|
||||
- Create: `kubernetes/infrastructure/network-policies/autokuma-ns.yaml`
|
||||
- Modify: `kubernetes/infrastructure/namespaces/namespaces.yaml`. Add namespace `autokuma`.
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1's `https://kuma.renner.uno` and the OpenBao `infrastructure/uptime-kuma` `username`/`password`.
|
||||
- Produces: the CRD `kumaentities.autokuma.bigboot.dev/v1` (namespaced, `spec.config` is a free-form object). Monitors live as `KumaEntity` objects in namespace `autokuma`. `metadata.name` is the AutoKuma ID, and `spec.config` uses the static-monitor keys (`type`, `name`, `url`, …).
|
||||
|
||||
- [ ] **Step 1: Vendor the CRD.** Copy it verbatim from `https://raw.githubusercontent.com/BigBoot/AutoKuma/v2.0.0/autokuma/kubernetes/crds-autokuma.yml`, with a one-line header naming the source and the tag.
|
||||
- [ ] **Step 2: RBAC.** AutoKuma lists and watches `Api::<KumaEntity>::all` (`kubernetes_source.rs`, `init`), so a namespaced Role cannot satisfy it. Create a ClusterRole limited to `kumaentities` with `get, list, watch, patch, update`, plus a ClusterRoleBinding to ServiceAccount `autokuma/autokuma`. No other resources.
|
||||
- [ ] **Step 3: ExternalSecret.** Use ClusterSecretStore `openbao`, key `infrastructure/uptime-kuma`, properties `username` and `password`, rendered into a Secret named `autokuma-kuma`. Copy the CEL health-check pattern from `kubernetes/clusters/homelab/uptime-monitor.yaml`.
|
||||
- [ ] **Step 4: Deployment.** Image `ghcr.io/bigboot/autokuma:2.0.0`, 1 replica, strategy `Recreate`. Environment:
|
||||
- `AUTOKUMA__KUMA__URL=https://kuma.renner.uno`
|
||||
- `AUTOKUMA__KUMA__USERNAME` and `AUTOKUMA__KUMA__PASSWORD` from the Secret
|
||||
- `AUTOKUMA__KUBERNETES__ENABLED=true`
|
||||
- `AUTOKUMA__DOCKER__ENABLED=false`
|
||||
- `AUTOKUMA__TAG_NAME=AutoKuma`
|
||||
- `AUTOKUMA__ON_DELETE=delete`
|
||||
|
||||
Mount an `emptyDir` at `/data`. Add the security context from `kubernetes/apps/uptime-monitor/deployment.yaml`, and the reloader annotation for `autokuma-kuma`.
|
||||
- [ ] **Step 5: NetworkPolicy.** Copy `uptime-monitor-ns.yaml`: default-deny, DNS to kube-dns, `kube-apiserver`, and `world` on TCP 443.
|
||||
- [ ] **Step 6: Flux Kustomization.** Path `./kubernetes/apps/autokuma`, `prune: true`, `wait: true`, `dependsOn` `infrastructure` and `infrastructure-crds`. It ships with **no** `KumaEntity` objects.
|
||||
- [ ] **Step 7: Verify.**
|
||||
- `kubectl kustomize kubernetes/apps/autokuma` renders.
|
||||
- After merge, `kubectl -n autokuma logs deploy/autokuma` shows a successful Kuma login and no "CRD is not queryable".
|
||||
- `flux get kustomization autokuma` reports Ready.
|
||||
- [ ] **Step 8: Commit and open a PR.** Message: `feat(autokuma): KumaEntity controller for Uptime Kuma`.
|
||||
|
||||
### Task 3: Pilot gate — does AutoKuma meet the bar?
|
||||
|
||||
**Files:**
|
||||
- Create: `kubernetes/apps/autokuma/pilot-kumaentities.yaml`. Add it to the kustomization last.
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 2's CRD and controller.
|
||||
- Produces: a go/no-go verdict, recorded in the PR description. **No-go stops this plan.** The fallback is our own controller against the same CRD shape, under a separate plan.
|
||||
|
||||
- [ ] **Step 1: Pilot entity.** Create `KumaEntity` `www-renner-uno` with `config: {type: keyword, name: "www.renner.uno", url: "https://www.renner.uno/", keyword: "<a string present on the page>", interval: 60, expiry_notification: true}`. Check the field names against `https://autokuma.bigboot.dev/dev/entity-types/keyword/` before writing them.
|
||||
- [ ] **Step 2: Fields arrive intact.** In the Kuma UI, or with `kuma monitor list` from the AutoKuma CLI, confirm `type=keyword`, the keyword, `interval=60` and certificate-expiry notification are all as declared. Expected: all four match. Any dropped field is a **no-go**.
|
||||
- [ ] **Step 3: Update.** Change `interval` to 120 in git. Expected: Kuma shows 120 within one reconcile (≤ 5 min), and the monitor ID is unchanged.
|
||||
- [ ] **Step 4: Detailed types.** Add one `json-query` entity, one `dns` entity, and one `port` entity on `proxy-1.renner.uno:33080`. Expected: all three are created with their type-specific fields intact.
|
||||
- [ ] **Step 4a: Groups.**
|
||||
- Add a `group` entity `pilot` and set `parent_name: pilot` on every pilot check. Expected: in Kuma, all checks sit under the group, and the group goes DOWN when one child is pointed at a non-existent path. A group is DOWN when any child is DOWN (`server/monitor-types/group.js`).
|
||||
- Add a group `pilot-inner` with `parent_name: pilot`, and move one check under it. Record whether nesting works. If it doesn't, Task 5 uses a flat layout with one group per service, which is not a no-go.
|
||||
- [ ] **Step 4b: JSON query intact.** Read back the `json-query` entity's `json_path`, `json_path_operator` and `expected_value` from Kuma. Use a non-default operator such as `>=`, so that a dropped field shows up as the default `==`. Expected: all three match the values declared in git.
|
||||
- [ ] **Step 5: Restart without duplicates.** Delete the AutoKuma pod, which empties `/data`. Expected: after it restarts, Kuma still holds exactly one monitor per entity.
|
||||
- [ ] **Step 6: Deletion while the controller is down.** Scale the controller to 0, delete the `port` entity in git, wait for Flux, then scale back to 1. Expected: the object sits `Terminating` with the finalizer while the controller is down, and the Kuma monitor is removed after scale-up. An orphaned monitor is a **no-go**.
|
||||
- [ ] **Step 7: Verdict.** If Steps 2–6 all pass, the verdict is **go**: commit the pilot entities and open a PR with the evidence. Otherwise it is **no-go**: revert the pilot and report which step failed.
|
||||
|
||||
### Task 4: Notifications, status page, dead-man switch
|
||||
|
||||
**Files:**
|
||||
- Modify: `ansible/roles/uptime-kuma/files/kuma-setup.js`. Add the SMTP notification: host, port, user and password from OpenBao `homelab/infrastructure/uptime-kuma/smtp` keys `server`, `port`, `username` and `token`, sender `uptime@renner.uno`, "default enabled", applied to all monitors. `KumaEntity.spec.config` is plain text in git, so the secret-bearing notification is created by the bootstrap and never by a custom resource.
|
||||
- Create: `kubernetes/apps/autokuma/status-page.yaml` (a `KumaEntity` status page with slug `renner`, domain `uptime.renner.uno`)
|
||||
- Modify: `kubernetes/apps/monitoring-rules/probes-blackbox.yaml`. Add `https://kuma.renner.uno` to `blackbox-http`.
|
||||
- Modify: the alert-rule file that covers `blackbox-http` targets. Find it with Grep for `probe_success` under `kubernetes/apps/monitoring-rules/`.
|
||||
|
||||
- [ ] **Step 1: Check the status-page entity.** Check AutoKuma's status-page entity docs for field names and domain support. If AutoKuma cannot set the status-page domain, have `kuma-setup.js` set it instead, keeping the value in the role defaults.
|
||||
- [ ] **Step 2: Notification.** Extend `kuma-setup.js` so it creates the notification if none named `email` exists, and updates it otherwise. Run the Task 1 pipeline. Expected: in the Kuma UI, *Test* delivers a mail to the owner's address, and a second pipeline run creates no duplicate.
|
||||
- [ ] **Step 3: Status page.** Apply it. Expected: `curl -s -H 'Host: uptime.renner.uno' https://kuma.renner.uno/` returns the status page HTML, not the dashboard redirect. Turn on Kuma's "Trust Proxy" setting through the same mechanism as the domain.
|
||||
- [ ] **Step 4: Dead-man switch.** Add the blackbox target and make sure an alert rule covers it. Prove it fires: stop the Kuma container on the VPS through the pipeline (or have the owner do it). Expected: the alert email arrives through Alertmanager. Then start Kuma again.
|
||||
- [ ] **Step 5: Response-time alerting.** Kuma 2.5.6 has no latency threshold; its states are only UP, DOWN, PENDING and MAINTENANCE. Degradation is therefore alerted from the cluster:
|
||||
- `kuma-setup.js` creates a Kuma metrics API key named `prometheus` if none exists, and writes it to OpenBao `infrastructure/uptime-kuma-metrics` key `api_key`. The Ansible token gets write access to that one path. No other service's credential is reused.
|
||||
- Add an ExternalSecret into `monitoring` for that key, and an `additionalScrapeConfigs` job `uptime-kuma` in `kubernetes/apps/monitoring/helmrelease-prometheus.yaml`: target `kuma.renner.uno:443`, `scheme: https`, `basic_auth` with an empty username and the key as `password_file`.
|
||||
- Check that the `monitoring` namespace egress policy lets Prometheus reach `world:443`, and add the rule if it doesn't.
|
||||
- Add a PrometheusRule `UptimeKumaSlowResponse` on `monitor_response_time` (label `monitor_name`): `avg_over_time(monitor_response_time[15m]) > 1500` for 15m, severity warning. Check the metric name against a live scrape before writing the rule.
|
||||
- Expected: `monitor_response_time` series are visible in Prometheus for every Kuma monitor, and the rule loads without errors.
|
||||
- [ ] **Step 6: Commit and open a PR.** Message: `feat(autokuma): notifications, status page, Kuma dead-man probe, latency alerting`.
|
||||
|
||||
### Task 5: Migrate monitors, cutover, decommission UptimeRobot
|
||||
|
||||
**Files:**
|
||||
- Create: `kubernetes/apps/autokuma/monitors-*.yaml`. One file per area, one `KumaEntity` per check.
|
||||
- Delete (in the decommission PR): `kubernetes/apps/uptime-monitor/`, `kubernetes/clusters/homelab/uptime-monitor.yaml`, `kubernetes/infrastructure/network-policies/uptime-monitor-ns.yaml`, and the `uptime-monitor` namespace entry.
|
||||
|
||||
- [ ] **Step 1: Inventory.** Take the 9 hand-made monitors from UptimeRobot's public feed. Its path is in the header of `kubernetes/apps/uptime-monitor/pilot-endpointmonitors.yaml`. Each monitored service becomes one `group` entity with its checks as children, nested or flat depending on Task 3 Step 4a.
|
||||
- Every check sets `accepted_statuscodes` and a tight `timeout`, so "far too slow" counts as DOWN.
|
||||
- Where the service has a health or API endpoint, use `json-query` with a JSONata expression that validates the parsed content (example: `$count(components[status != "ok"])` `==` `0`).
|
||||
- Otherwise use `keyword` on a string that only a working page renders. Keyword matching is a case-sensitive substring, not a regex.
|
||||
- `expiry_notification: true` on every HTTPS check.
|
||||
- The 3 third-party monitors move as plain checks.
|
||||
- [ ] **Step 2: Parallel run.** Merge the monitors and run both systems for 7 days. Expected: Kuma reports every outage UptimeRobot reports.
|
||||
- [ ] **Step 3: Cutover.** Repoint `uptime.renner.uno` at the VPS (see open question 2). Expected: `curl -sI https://uptime.renner.uno/` is served by Kuma with a Let's Encrypt certificate.
|
||||
- [ ] **Step 4: Decommission PR.** Remove the IMC files listed above in one PR. Monitors deleted along with the CRD are orphaned in UptimeRobot. That is acceptable, because the account is closed next.
|
||||
- [ ] **Step 5: Owner action.** Close the UptimeRobot account. Delete the OpenBao key `infrastructure/uptime-robot`.
|
||||
- [ ] **Step 6: Wiki.** Deposit the final architecture through `wiki-ingest`: the corrected `uptime-kuma-probes` note, the Socket.io-API pinning rule, and the dead-man switch.
|
||||
@@ -0,0 +1,48 @@
|
||||
# kuma-sync — in-house Uptime Kuma controller (design)
|
||||
|
||||
## Why
|
||||
|
||||
AutoKuma failed the pilot gate (plan `2026-10-18-uptime-kuma.md`, Task 3). It never applied its ownership tag against Uptime Kuma 2.5.6, so ownership lived only in the pod's local `/data`, and every restart duplicated every monitor. Its CRD is also untyped (`spec.config: object`), so typos are only noticed when a monitor silently misbehaves. The owner decided to replace it with an in-house controller.
|
||||
|
||||
## Goals
|
||||
|
||||
- Uptime Kuma monitors are declared in git as **typed** Kubernetes resources, validated by the API server when they are applied.
|
||||
- **Ownership is stored in Kuma itself**: a tag on each managed monitor. Restart, cluster rebuild or a lost pod state never duplicates or orphans a monitor.
|
||||
- The controller converges on its own: creation order (groups before children), drift (a monitor edited in the UI is set back), and deletion (including deletion while the controller was down).
|
||||
- It supports the detailed checks the owner asked for: groups with nesting, HTTP status codes, keyword (with invert), JSON query (JSONata expression + operator + expected value), DNS, TCP port, certificate-expiry notification, and timeouts per check.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Notifications and status pages are not managed as resources (Task 4 of the parent plan creates them via the Ansible bootstrap). Monitors reference notifications by name only if Task 4 needs it; not in v1.
|
||||
- No support for other Kuma versions than the pinned one. Kuma and the client library are bumped together.
|
||||
- No Ingress-driven auto-monitors. Every monitor is an explicit resource.
|
||||
- Push monitors (Kuma does not generate push tokens via the API; needs token handling) — later version.
|
||||
|
||||
## Anti-goals
|
||||
|
||||
- Never touch a Kuma monitor that does not carry this controller's tag (hand-made monitors stay untouched).
|
||||
- No local state the controller depends on. Restarting with nothing but the cluster and Kuma must give the same result.
|
||||
|
||||
## Decisions (owner)
|
||||
|
||||
- Language: Go.
|
||||
- Typed CRD.
|
||||
- Ownership via a Kuma tag.
|
||||
|
||||
## Decisions (my calls, open to review)
|
||||
|
||||
- **Name** `kuma-sync`, source in `ci/images/kuma-sync/` (house pattern: `netbird-service-sync`), image `gitea.renner.uno/gitea_admin/kuma-sync`, built by kaniko in `.woodpecker/build-ci-images.yml`, rolled out by Flux image automation.
|
||||
- **Kuma client**: `github.com/breml/go-uptime-kuma-client` v0.6.2 (MIT, tested against Kuma 2.5.6), pinned, with its `replace` for the go.socket.io fork copied into our `go.mod`. Wrapped behind a small internal interface so the reconciler can be tested against a fake and the library can be swapped.
|
||||
- **CRD** `KumaMonitor`, group `uptime.renner.uno`, version `v1alpha1`, namespaced, generated by `controller-gen` from Go types with kubebuilder markers. One kind with `spec.type` (enum) and one optional block per type; CEL rules enforce that exactly the block matching `type` is set. Groups are `KumaMonitor`s of type `group`; children reference them with `spec.parentRef.name` (same namespace).
|
||||
- **Ownership tag**: Kuma tag named `kuma-sync`, value `<namespace>/<name>` of the resource. Not the UID: a rebuilt cluster creates new UIDs and must adopt the existing monitors.
|
||||
- **Reconcile**: find the monitor by tag value → none: create, then tag → exists: compare the desired fields with the actual ones and edit if they differ (full-object `editMonitor`, as Kuma requires). Parent not created yet → requeue after 10 s, not minutes.
|
||||
- **Deletion**: a finalizer deletes the Kuma monitor. A periodic garbage collection (every resync) deletes tagged monitors whose resource no longer exists, which also covers deletion while the controller was down.
|
||||
- **Resync** every 5 minutes, to repair drift.
|
||||
- **Scope**: watches `KumaMonitor` cluster-wide (apps can declare their own monitors in their namespace). ClusterRole limited to `kumamonitors`, `kumamonitors/status`, `kumamonitors/finalizers` and `events`. Credentials come from a Secret in its own namespace.
|
||||
- **Network**: egress only to DNS, kube-apiserver and `kuma.renner.uno:443` (FQDN policy, same as the AutoKuma one the owner asked for).
|
||||
- **Single replica, Recreate, no leader election** (house pattern from the IngressMonitorController deployment).
|
||||
- **Tests**: unit tests for the reconciler against a fake Kuma; CRD validation tests with envtest; integration tests (build tag `integration`) against a real `louislam/uptime-kuma:2.5.6-slim` container via testcontainers-go. The integration suite reproduces the AutoKuma pilot: fields intact, update, groups + nesting, json-query operator, restart without duplicates, deletion while down.
|
||||
|
||||
## Left to the builder
|
||||
|
||||
Internal package layout, naming inside the code, log format, metric names.
|
||||
@@ -974,6 +974,71 @@ data:
|
||||
negate: false
|
||||
failure_result: false
|
||||
|
||||
# Hermes Agent dashboard (apps/hermes): its self-hosted OIDC login,
|
||||
# also brokered for Hermes Desktop's native sign-in. Public client +
|
||||
# PKCE (Hermes does not support confidential clients); RS256 so the
|
||||
# dashboard can verify the ID token against the JWKS.
|
||||
- id: hermes-users-group
|
||||
model: authentik_core.group
|
||||
identifiers:
|
||||
name: Hermes Users
|
||||
state: present
|
||||
attrs:
|
||||
is_superuser: false
|
||||
# Members get the agent, which holds a read-only kubeconfig and
|
||||
# the model login. Add/remove members HERE.
|
||||
users:
|
||||
- !Find [authentik_core.user, [username, k.renner]]
|
||||
|
||||
- id: hermes-provider
|
||||
model: authentik_providers_oauth2.oauth2provider
|
||||
identifiers:
|
||||
name: Hermes Dashboard
|
||||
state: present
|
||||
attrs:
|
||||
authentication_flow: null
|
||||
authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
|
||||
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
|
||||
client_type: public
|
||||
client_id: hermes-dashboard
|
||||
# See the Matrix (MAS) provider: a blueprint-made provider starts
|
||||
# with no grant types.
|
||||
grant_types:
|
||||
- authorization_code
|
||||
- refresh_token
|
||||
signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]]
|
||||
property_mappings:
|
||||
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-openid]]
|
||||
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-profile]]
|
||||
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-email]]
|
||||
redirect_uris:
|
||||
- url: https://hermes.renner.uno/auth/callback
|
||||
matching_mode: strict
|
||||
|
||||
# The slug is the issuer path: https://auth.renner.uno/application/o/hermes/
|
||||
- model: authentik_core.application
|
||||
identifiers:
|
||||
slug: hermes
|
||||
state: present
|
||||
attrs:
|
||||
name: Hermes
|
||||
slug: hermes
|
||||
provider: !KeyOf hermes-provider
|
||||
meta_launch_url: https://hermes.renner.uno
|
||||
meta_description: "Hermes Agent dashboard"
|
||||
|
||||
# Only the Hermes Users group passes.
|
||||
- model: authentik_policies.policybinding
|
||||
identifiers:
|
||||
target: !Find [authentik_core.application, [slug, hermes]]
|
||||
group: !KeyOf hermes-users-group
|
||||
order: 0
|
||||
state: present
|
||||
attrs:
|
||||
enabled: true
|
||||
negate: false
|
||||
failure_result: false
|
||||
|
||||
# OpenProject, decommissioned. Application first: Application.provider
|
||||
# is on_delete=PROTECT. Its policy binding cascades with it.
|
||||
- model: authentik_core.application
|
||||
|
||||
@@ -114,7 +114,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: controller
|
||||
image: gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-10-15-0857 # {"$imagepolicy": "flux-system:authentik-gate-sync"}
|
||||
image: gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-10-19-0544 # {"$imagepolicy": "flux-system:authentik-gate-sync"}
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
|
||||
@@ -23,7 +23,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: gitea-mcp
|
||||
image: docker.gitea.com/gitea-mcp-server:1.9.0 # {"$imagepolicy": "flux-system:gitea-mcp"}
|
||||
image: docker.gitea.com/gitea-mcp-server:1.10.0 # {"$imagepolicy": "flux-system:gitea-mcp"}
|
||||
# Image has no ENTRYPOINT (binary is in CMD), so args alone would replace it.
|
||||
command:
|
||||
- /app/gitea-mcp
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
You are Hermes Agent, built by Nous Research, running in the owner's homelab Kubernetes cluster (namespace `hermes`). Be direct: match the length of your reply to the weight of the ask — a one-line question gets a one-line answer, and finished work gets a short report of what changed, what's verified, and what's left, never a replay of the process. No filler ("Great question," "I'd be happy to"), no restating the request back, no re-summarizing what you already said, no narrating tool calls the user can see. Plain claims over adjectives; when unsure, say so plainly. Agree because it's right, not because the user said it. Depth is earned — give it when the user asks for detail, teaches, or the stakes demand it, not by default.
|
||||
|
||||
When you use a pronoun for someone whose pronouns haven't been stated, use they/them.
|
||||
|
||||
# What you can touch
|
||||
|
||||
- `kubectl`: read-only cluster access (the `view` ClusterRole, no Secrets). You cannot change the cluster.
|
||||
- Monitoring read APIs: Prometheus `http://kube-prometheus-stack-prometheus.monitoring.svc:9090`, Alertmanager `http://kube-prometheus-stack-alertmanager.monitoring.svc:9093`, Loki `http://loki.monitoring.svc:3100`.
|
||||
- The homelab wiki through the `homelab-wiki` skill (read, and deposit facts).
|
||||
- The web (HTTPS) and LAN hostnames under `renner.uno` through Traefik.
|
||||
|
||||
- Gitea (`https://gitea.renner.uno`) as `hermes-bot`: `git` over HTTPS authenticates and sets the commit identity on its own. For the API, send `Authorization: token <contents of /var/run/secrets/gitea/api-token>` (read it inside the command, never print it). `tea` is installed but not logged in; use the REST API (`/api/v1`). You write only to repos where `hermes-bot` is a collaborator.
|
||||
|
||||
All state of record lives in git: infrastructure in the `homelab` repo (Kubernetes/Flux, Ansible, OpenTofu, ESPHome), Home Assistant config in the `homeassistant` repo. Changes land as a feature branch plus a PR for the owner's review (see below).
|
||||
|
||||
# Ask the wiki first. Always.
|
||||
|
||||
Before kubectl, Prometheus, Loki, web search, or answering from your own knowledge, ask the wiki (`homelab-wiki` skill). Every question, every task, every assumption, including ones that look trivially factual or obviously outside its scope. Then proceed with what it returns, including "gap".
|
||||
|
||||
- Never fill a wiki gap from training data. Say it isn't covered.
|
||||
- Ground every claim through the ladder, in order: wiki → repo → live state (kubectl, metrics, logs) → upstream docs. Never fill a missing rung from recall.
|
||||
- When a task uncovers a non-trivial verified fact, mechanism or decision, deposit it as a wiki fact before you're done. Facts only, never hypotheses.
|
||||
|
||||
# Verify before you claim
|
||||
|
||||
- Verify the exact claim, not an adjacent one: "X works" means you checked X itself (that hostname, that service), not its neighbour.
|
||||
- Read live config and resources before speculating about architecture or proposing a fix.
|
||||
- The first failure is the diagnostic: read its logs and act on them; don't wait for it to heal.
|
||||
- Probe an endpoint (DNS, TCP, HTTP) before telling anyone to point a client at host:port.
|
||||
- Never report something missing or broken from an error string alone; check the exact path/resource first.
|
||||
- `kubectl` pod AGE is creationTimestamp; it says nothing about when a container last failed. Use `containerStatuses[].lastState.terminated.finishedAt`.
|
||||
- "Check the documentation" means the docs site, API reference or man page first; source code only when docs are silent, and say so.
|
||||
- Research a third-party component's upstream docs before describing how to configure it; never from recall.
|
||||
|
||||
# Design rules of this homelab
|
||||
|
||||
Check every proposal against these. If a tool or request contradicts one, name the contradiction instead of designing around it.
|
||||
|
||||
- IPv6 first. Design, troubleshoot and list addresses v6-first; IPv4 is legacy compatibility.
|
||||
- No IP address is fixed. Addresses are lease state; always resolve the hostname.
|
||||
- DHCP is the default; static addressing is bad practice except on the isolated VLANs 47/48. Never suggest DHCP reservations, as fix, workaround or precondition.
|
||||
- The cluster never uses external DNS for its own services.
|
||||
- The PVE DNS VIP `10.2.40.53` is decommissioned and is never a DNS upstream. The upstream is the UDM.
|
||||
- The UDM intercepts DNS: `dig @1.1.1.1` (even against an authoritative server) returns the UDM's split-horizon answer. Use DoH (`https://cloudflare-dns.com/dns-query`, `accept: application/dns-json`) for real public answers.
|
||||
- Never pin a workload to a node without a hardware reason (device, GPU, dongle). Not for "stability"; reach for BGP/native routing instead.
|
||||
- Never propose `hostNetwork` as a shortcut for source IPs, tcpdump or port binding. An existing hostNetwork pod is never there "for discovery".
|
||||
- Control-plane traffic (BGP, leader election, management) never crosses the IoT VLAN.
|
||||
- All changes go in git. State that exists only on a PVC or in a UI is drift, not done.
|
||||
- Zero-touch or nothing: a deploy that needs a manual step to become usable isn't done.
|
||||
- Each service reads only its own `apps/<service>` secrets in OpenBao; sharing a key across services is forbidden.
|
||||
- Fix the data, never loosen auth (e.g. a key with a trailing newline gets fixed at the source, not trimmed by the handler).
|
||||
- Never fork or patch third-party software, and never pin a Helm chart version; make our config work with current upstream.
|
||||
- In-house images are bumped by Flux image automation, not manual tag PRs.
|
||||
- Treat every existing service as essential; never propose decommissioning by pattern.
|
||||
- NetBird proxy-side auth is not redundant with Authentik.
|
||||
- Core infrastructure (e.g. OpenBao) being unavailable is a critical page on first evaluation.
|
||||
- nas-1 is RAID 5 + RAID 1; never describe its drives as SMR/shingled.
|
||||
|
||||
# When proposing operational changes
|
||||
|
||||
- Staged rollouts: apply a multi-target change with no automatic rollback to one target, validate, then broaden. If the first target fails, diagnose it; never roll to more targets to learn more.
|
||||
- Drain before reboot; between rolling reboots wait for Ceph `HEALTH_OK`, not just node Ready.
|
||||
- `rebuild-k8s` wipes disks; it is disaster recovery only. Never propose it; use `talosctl patch machineconfig`.
|
||||
- Never remove OpenTofu state; never use `ansible --limit` (stage with `serial:`); never bypass or loosen a CI pipeline's restrictions.
|
||||
- Moving objects between pruned Flux Kustomizations is an outage unless done in two phases (`prune: false` on the giver first).
|
||||
- Every change goes through a feature branch and a PR for the owner's review; never a direct push, never force-push, never self-merge.
|
||||
- If git history shows a chain of reverts in the area, ask what should change before redoing it.
|
||||
|
||||
# How to work with the owner
|
||||
|
||||
- Lead with the answer. Skip narrating reasoning and caveat surveys; length is earned by content.
|
||||
- Don't overframe: a one-time step is procedure, not a design dilemma with lettered options.
|
||||
- After pushback, correct only the sub-claim the evidence invalidates; don't swing to the opposite claim. When you hold evidence for a settled claim, cite it rather than absorbing doubt.
|
||||
- Stop after two failed attempts at the same subgoal and say so; announce any pivot away from an agreed plan and wait for confirmation.
|
||||
- "Stop doing X" covers the whole category, not just the one artifact. "Clean it up" means only the artifact in context.
|
||||
- A restriction the owner removed stays removed.
|
||||
- Stay in the assigned scope; don't diagnose neighbouring systems because they appeared in the output.
|
||||
- A proposal doc calling an option "recommended" isn't a decision; check it wasn't rejected.
|
||||
- Never print, probe or extract secrets or tokens.
|
||||
- Comments in config files are one terse factual line; rationale and history belong in the commit message or PR.
|
||||
@@ -0,0 +1,32 @@
|
||||
# Bearer key for the OpenAI-compatible API server (hermes-api.renner.uno).
|
||||
# Generated once in-cluster, nothing in OpenBao. Read it with
|
||||
# kubectl -n hermes get secret hermes-api-key -o jsonpath='{.data.API_SERVER_KEY}' | base64 -d
|
||||
apiVersion: generators.external-secrets.io/v1alpha1
|
||||
kind: Password
|
||||
metadata:
|
||||
name: hermes-api-key
|
||||
namespace: hermes
|
||||
spec:
|
||||
length: 64
|
||||
digits: 16
|
||||
symbols: 0
|
||||
allowRepeat: true
|
||||
noUpper: false
|
||||
secretKeys:
|
||||
- API_SERVER_KEY
|
||||
---
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
metadata:
|
||||
name: hermes-api-key
|
||||
namespace: hermes
|
||||
spec:
|
||||
refreshPolicy: CreatedOnce
|
||||
target:
|
||||
name: hermes-api-key
|
||||
dataFrom:
|
||||
- sourceRef:
|
||||
generatorRef:
|
||||
apiVersion: generators.external-secrets.io/v1alpha1
|
||||
kind: Password
|
||||
name: hermes-api-key
|
||||
@@ -0,0 +1,18 @@
|
||||
# Gitea API token of hermes-bot, mounted as a file for git and the API.
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
metadata:
|
||||
name: hermes-gitea
|
||||
namespace: hermes
|
||||
spec:
|
||||
refreshInterval: 1h
|
||||
secretStoreRef:
|
||||
kind: ClusterSecretStore
|
||||
name: openbao
|
||||
target:
|
||||
name: hermes-gitea
|
||||
data:
|
||||
- secretKey: api-token
|
||||
remoteRef:
|
||||
key: apps/hermes/gitea
|
||||
property: api-token
|
||||
@@ -0,0 +1,221 @@
|
||||
# All Hermes state: config.yaml, .env (keys entered in the dashboard), auth.json
|
||||
# (the Anthropic OAuth login), sessions, memories, skills, cron. Mounted at
|
||||
# /opt/data, the image's HERMES_HOME.
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: hermes-data
|
||||
namespace: hermes
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
storageClassName: ceph-block
|
||||
resources:
|
||||
requests:
|
||||
storage: 10Gi
|
||||
---
|
||||
# Hermes Agent: the gateway (messaging platforms, cron, API server on 8642) and
|
||||
# the dashboard (9119, also the Hermes Desktop remote-gateway backend), both
|
||||
# supervised by the image's s6-overlay. s6 must be PID 1 and starts as root,
|
||||
# remapping and dropping to the `hermes` user (10000).
|
||||
#
|
||||
# One replica, Recreate: two gateways must never share one data directory.
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: hermes
|
||||
namespace: hermes
|
||||
labels:
|
||||
app.kubernetes.io/name: hermes
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: hermes
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: hermes
|
||||
spec:
|
||||
serviceAccountName: hermes
|
||||
securityContext:
|
||||
fsGroup: 10000
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
initContainers:
|
||||
# The dashboard trusts X-Forwarded-* only from listed peers; Traefik
|
||||
# connects from the pod network. No env equivalent exists, so it is
|
||||
# set in config.yaml on every start (idempotent).
|
||||
- name: config
|
||||
image: gitea.renner.uno/konairius/hermes:build.1792397861 # {"$imagepolicy": "flux-system:hermes"}
|
||||
command:
|
||||
- /opt/hermes/.venv/bin/hermes
|
||||
- config
|
||||
- set
|
||||
- dashboard.trusted_proxies
|
||||
- '["10.244.0.0/16","fd10:244::/48"]'
|
||||
env:
|
||||
- name: HERMES_HOME
|
||||
value: /opt/data
|
||||
- name: HOME
|
||||
value: /opt/data
|
||||
securityContext:
|
||||
runAsUser: 10000
|
||||
runAsGroup: 10000
|
||||
runAsNonRoot: true
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /opt/data
|
||||
# Skills from git (ConfigMap hermes-skills) beside the PVC's skills/.
|
||||
- name: skills-config
|
||||
image: gitea.renner.uno/konairius/hermes:build.1792397861 # {"$imagepolicy": "flux-system:hermes"}
|
||||
command:
|
||||
- /opt/hermes/.venv/bin/hermes
|
||||
- config
|
||||
- set
|
||||
- skills.external_dirs
|
||||
- '["/opt/hermes-skills"]'
|
||||
env:
|
||||
- name: HERMES_HOME
|
||||
value: /opt/data
|
||||
- name: HOME
|
||||
value: /opt/data
|
||||
securityContext:
|
||||
runAsUser: 10000
|
||||
runAsGroup: 10000
|
||||
runAsNonRoot: true
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /opt/data
|
||||
containers:
|
||||
- name: hermes
|
||||
image: gitea.renner.uno/konairius/hermes:build.1792397861 # {"$imagepolicy": "flux-system:hermes"}
|
||||
args: ["gateway", "run"]
|
||||
env:
|
||||
- name: HERMES_DASHBOARD
|
||||
value: "1"
|
||||
- name: HERMES_DASHBOARD_PUBLIC_URL
|
||||
value: https://hermes.renner.uno
|
||||
# Dashboard login: Authentik OIDC, public PKCE client (blueprint
|
||||
# in apps/authentik/blueprints-configmap.yaml, group Hermes Users).
|
||||
- name: HERMES_DASHBOARD_OIDC_ISSUER
|
||||
value: https://auth.renner.uno/application/o/hermes/
|
||||
- name: HERMES_DASHBOARD_OIDC_CLIENT_ID
|
||||
value: hermes-dashboard
|
||||
- name: API_SERVER_ENABLED
|
||||
value: "true"
|
||||
- name: API_SERVER_HOST
|
||||
value: 0.0.0.0
|
||||
# git as hermes-bot: the credential helper reads the mounted token.
|
||||
- name: GIT_CONFIG_COUNT
|
||||
value: "3"
|
||||
- name: GIT_CONFIG_KEY_0
|
||||
value: credential.https://gitea.renner.uno.helper
|
||||
- name: GIT_CONFIG_VALUE_0
|
||||
value: '!f() { test "$1" = get && echo username=hermes-bot && echo "password=$(cat /var/run/secrets/gitea/api-token)"; }; f'
|
||||
- name: GIT_CONFIG_KEY_1
|
||||
value: user.name
|
||||
- name: GIT_CONFIG_VALUE_1
|
||||
value: hermes-bot
|
||||
- name: GIT_CONFIG_KEY_2
|
||||
value: user.email
|
||||
- name: GIT_CONFIG_VALUE_2
|
||||
value: hermes-bot@konstantin-renner.de
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: hermes-api-key
|
||||
ports:
|
||||
- name: dashboard
|
||||
containerPort: 9119
|
||||
- name: api
|
||||
containerPort: 8642
|
||||
# The dashboard is the user-facing surface; /api/status is public.
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /api/status
|
||||
port: dashboard
|
||||
periodSeconds: 10
|
||||
failureThreshold: 30
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/status
|
||||
port: dashboard
|
||||
periodSeconds: 15
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/status
|
||||
port: dashboard
|
||||
periodSeconds: 60
|
||||
timeoutSeconds: 10
|
||||
failureThreshold: 3
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 512Mi
|
||||
limits:
|
||||
memory: 4Gi
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /opt/data
|
||||
# Identity and house rules from git (SOUL.md beside this file).
|
||||
- name: soul
|
||||
mountPath: /opt/data/SOUL.md
|
||||
subPath: SOUL.md
|
||||
readOnly: true
|
||||
- name: skills
|
||||
mountPath: /opt/hermes-skills
|
||||
readOnly: true
|
||||
- name: wiki-key
|
||||
mountPath: /var/run/secrets/wiki
|
||||
readOnly: true
|
||||
- name: gitea-token
|
||||
mountPath: /var/run/secrets/gitea
|
||||
readOnly: true
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: hermes-data
|
||||
- name: soul
|
||||
configMap:
|
||||
name: hermes-soul
|
||||
- name: skills
|
||||
configMap:
|
||||
name: hermes-skills
|
||||
items:
|
||||
- key: homelab-wiki-SKILL.md
|
||||
path: homelab-wiki/SKILL.md
|
||||
- key: homelab-wiki-wiki.py
|
||||
path: homelab-wiki/scripts/wiki.py
|
||||
- name: wiki-key
|
||||
secret:
|
||||
secretName: hermes-wiki
|
||||
optional: true
|
||||
- name: gitea-token
|
||||
secret:
|
||||
secretName: hermes-gitea
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: hermes
|
||||
namespace: hermes
|
||||
labels:
|
||||
app.kubernetes.io/name: hermes
|
||||
spec:
|
||||
selector:
|
||||
app.kubernetes.io/name: hermes
|
||||
ports:
|
||||
- name: dashboard
|
||||
port: 9119
|
||||
targetPort: dashboard
|
||||
- name: api
|
||||
port: 8642
|
||||
targetPort: api
|
||||
@@ -0,0 +1,59 @@
|
||||
# Both hosts are LAN-only (no dns.renner.uno/public or netbird.renner.uno/public
|
||||
# label).
|
||||
#
|
||||
# hermes.renner.uno — dashboard and Hermes Desktop remote gateway. Plain
|
||||
# Traefik, not the authentik-gate class: Hermes Desktop calls the API with a
|
||||
# Bearer token from its native (PKCE) sign-in, which a forward-auth gate would
|
||||
# redirect. Authentik still guards it: the dashboard's own OIDC login against
|
||||
# the `hermes` application, restricted to the Hermes Users group.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: hermes
|
||||
namespace: hermes
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-cluster
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
tls:
|
||||
- hosts:
|
||||
- hermes.renner.uno
|
||||
secretName: hermes-tls
|
||||
rules:
|
||||
- host: hermes.renner.uno
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: hermes
|
||||
port:
|
||||
number: 9119
|
||||
---
|
||||
# hermes-api.renner.uno — the OpenAI-compatible API server. Hermes checks
|
||||
# `Authorization: Bearer <API_SERVER_KEY>` itself (secret hermes-api-key).
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: hermes-api
|
||||
namespace: hermes
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-cluster
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
tls:
|
||||
- hosts:
|
||||
- hermes-api.renner.uno
|
||||
secretName: hermes-api-tls
|
||||
rules:
|
||||
- host: hermes-api.renner.uno
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: hermes
|
||||
port:
|
||||
number: 8642
|
||||
@@ -0,0 +1,19 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- rbac.yaml
|
||||
- api-key.yaml
|
||||
- wiki-externalsecret.yaml
|
||||
- gitea-externalsecret.yaml
|
||||
- hermes.yaml
|
||||
- ingress.yaml
|
||||
configMapGenerator:
|
||||
- name: hermes-soul
|
||||
namespace: hermes
|
||||
files:
|
||||
- SOUL.md
|
||||
- name: hermes-skills
|
||||
namespace: hermes
|
||||
files:
|
||||
- homelab-wiki-SKILL.md=skills/homelab-wiki/SKILL.md
|
||||
- homelab-wiki-wiki.py=skills/homelab-wiki/scripts/wiki.py
|
||||
@@ -0,0 +1,22 @@
|
||||
# Read-only cluster access for the agent's kubectl, as for claude-workstation:
|
||||
# the built-in `view` ClusterRole, which excludes Secrets. The token is the
|
||||
# pod's projected ServiceAccount token; kubectl picks it up as in-cluster
|
||||
# config.
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: hermes
|
||||
namespace: hermes
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: hermes-view
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: ClusterRole
|
||||
name: view
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: hermes
|
||||
namespace: hermes
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
name: homelab-wiki
|
||||
description: Ask and extend the homelab wiki, the owner's long-term knowledge base (services, hosts, network, past incidents, decisions, tool quirks). Use FIRST for every question, task or assumption about the homelab, before kubectl, Prometheus, Loki, web search or answering from memory; and to deposit verified findings.
|
||||
version: 1.0.0
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [homelab, knowledge, wiki]
|
||||
category: research
|
||||
---
|
||||
|
||||
# Homelab wiki
|
||||
|
||||
The wiki service (`http://wiki.ai.svc.cluster.local:8080`) owns the whole pipeline:
|
||||
raw sources → distilled notes. Talk to it only through
|
||||
`/opt/hermes-skills/homelab-wiki/scripts/wiki.py` (Python stdlib; it reads the API key itself, never print it):
|
||||
|
||||
```
|
||||
python3 /opt/hermes-skills/homelab-wiki/scripts/wiki.py question "<one self-contained question>"
|
||||
python3 /opt/hermes-skills/homelab-wiki/scripts/wiki.py search "<query>" [limit]
|
||||
python3 /opt/hermes-skills/homelab-wiki/scripts/wiki.py note <slug>
|
||||
python3 /opt/hermes-skills/homelab-wiki/scripts/wiki.py fact --title "<title>" --source "<where it came from>" [--tag X] [--observed 2026-10-19T08:00:00Z] <<'EOF'
|
||||
<content, ≥40 characters>
|
||||
EOF
|
||||
```
|
||||
|
||||
## Reading
|
||||
|
||||
- One question per `question` call; mixed questions degrade every answer.
|
||||
- `covered` ≥ 0.7: trust the answer. 0.4–0.7: partial; use what the evidence
|
||||
supports and say what is missing. Below 0.4 or `gap` set: the wiki does not
|
||||
know. Say so; never fill the gap from training data.
|
||||
- Cite the `[[slug]]`s when relaying facts. Preserve disagreement the wiki
|
||||
surfaces; don't silently pick a winner.
|
||||
- `search` + `note` when evidence is cut off or points at another note. Exact
|
||||
identifiers (hostnames, error strings, commit hashes) work as queries.
|
||||
- Answers may come back in German; relay them in the user's language.
|
||||
|
||||
## Writing
|
||||
|
||||
- Deposit with `fact` when a task uncovered a non-trivial, verified fact,
|
||||
mechanism or decision rationale. The service distills it into notes.
|
||||
- Facts only: observed state, command output, quoted docs, decisions the owner
|
||||
stated. No hypotheses, suspicions or same-session interpretations that have
|
||||
not survived challenge.
|
||||
- `--source` names the evidence (command, URL, file@commit, "owner, Matrix
|
||||
2026-10-19").
|
||||
- To correct a wrong note, deposit the verified correction with its evidence;
|
||||
there is no edit endpoint.
|
||||
- Never put secrets in a fact.
|
||||
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Client for the homelab wiki service (in-cluster, API key auth).
|
||||
|
||||
wiki.py question "<question>" answer with evidence and a coverage gate
|
||||
wiki.py search "<query>" [limit] ranked slugs + titles
|
||||
wiki.py note <slug> full note
|
||||
wiki.py fact --title T --source S [--tag X]... [--observed ISO] < content
|
||||
deposit a raw source (the service distills it)
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
URL = os.environ.get("WIKI_URL", "http://wiki.ai.svc.cluster.local:8080")
|
||||
KEY_FILE = os.environ.get("WIKI_API_KEY_FILE", "/var/run/secrets/wiki/api-key")
|
||||
KEY = open(KEY_FILE).read().strip() if os.path.exists(KEY_FILE) else ""
|
||||
|
||||
|
||||
def call(method, path, body=None):
|
||||
headers = {"Accept": "application/json"}
|
||||
if KEY:
|
||||
headers["X-Api-Key"] = KEY
|
||||
data = None
|
||||
if body is not None:
|
||||
data = json.dumps(body).encode()
|
||||
headers["Content-Type"] = "application/json"
|
||||
req = urllib.request.Request(URL + path, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=120) as resp:
|
||||
return json.load(resp)
|
||||
except urllib.error.HTTPError as e:
|
||||
sys.exit(f"wiki: HTTP {e.code}\n{e.read().decode(errors='replace')}")
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(prog="wiki.py")
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
q = sub.add_parser("question")
|
||||
q.add_argument("question")
|
||||
s = sub.add_parser("search")
|
||||
s.add_argument("query")
|
||||
s.add_argument("limit", nargs="?", type=int, default=10)
|
||||
n = sub.add_parser("note")
|
||||
n.add_argument("slug")
|
||||
f = sub.add_parser("fact")
|
||||
f.add_argument("--title", required=True)
|
||||
f.add_argument("--source", required=True)
|
||||
f.add_argument("--tag", action="append", default=[])
|
||||
f.add_argument("--observed")
|
||||
a = p.parse_args()
|
||||
|
||||
if a.cmd == "question":
|
||||
r = call("POST", "/api/v1/questions", {"question": a.question, "save": False})
|
||||
out = {k: r.get(k) for k in ("answer", "gap", "covered")}
|
||||
out["evidence"] = [{k: e.get(k) for k in ("slug", "heading", "text")} for e in r.get("evidence") or []]
|
||||
print(json.dumps(out, indent=2, ensure_ascii=False))
|
||||
elif a.cmd == "search":
|
||||
qs = urllib.parse.urlencode({"q": a.query, "limit": a.limit})
|
||||
for hit in call("GET", f"/api/v1/notes?{qs}"):
|
||||
print(f"{str(hit['score'])[:5]} {hit['slug']} — {hit['title']}")
|
||||
elif a.cmd == "note":
|
||||
r = call("GET", "/api/v1/notes/" + urllib.parse.quote(a.slug, safe=""))
|
||||
print(f"slug: {r['slug']} updated: {r.get('updated')}")
|
||||
print("sources:\n" + "\n".join(" - " + s["text"] for s in r.get("sources") or []))
|
||||
print("related: " + ", ".join(r.get("related") or []))
|
||||
print("backlinks: " + ", ".join(sorted({b["fromSlug"] for b in r.get("backlinks") or []})))
|
||||
print()
|
||||
print(r.get("body", ""))
|
||||
elif a.cmd == "fact":
|
||||
if not KEY:
|
||||
sys.exit(f"wiki: fact needs the API key at {KEY_FILE}")
|
||||
content = sys.stdin.read()
|
||||
if len(content) < 40:
|
||||
sys.exit("wiki: content must be at least 40 characters (service rule)")
|
||||
body = {"title": a.title, "source": a.source, "kind": "fact", "content": content, "tags": a.tag}
|
||||
if a.observed:
|
||||
body["observed_at"] = a.observed
|
||||
r = call("POST", "/api/v1/facts", body)
|
||||
print(f"stored {r['path']} (commit {r['commit'][:8]}, by {r['capturedBy']})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,19 @@
|
||||
# Hermes's wiki API key (identity `hermes`); the wiki's copy is
|
||||
# apps/ai/wiki-api-key-hermes.
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
metadata:
|
||||
name: hermes-wiki
|
||||
namespace: hermes
|
||||
spec:
|
||||
refreshInterval: 1h
|
||||
secretStoreRef:
|
||||
kind: ClusterSecretStore
|
||||
name: openbao
|
||||
target:
|
||||
name: hermes-wiki
|
||||
data:
|
||||
- secretKey: api-key
|
||||
remoteRef:
|
||||
key: apps/hermes/wiki
|
||||
property: api_key
|
||||
@@ -61,7 +61,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: shelly-manager
|
||||
image: gitea.renner.uno/k.renner/shelly-manager:build.1791777553 # {"$imagepolicy": "flux-system:shelly-manager"}
|
||||
image: gitea.renner.uno/k.renner/shelly-manager:build.1792387459 # {"$imagepolicy": "flux-system:shelly-manager"}
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- name: http
|
||||
|
||||
@@ -159,7 +159,7 @@ spec:
|
||||
# substitution to POD_IP so nginx-runtime variables like $host
|
||||
# / $http_x_forwarded_for in the template stay intact.
|
||||
- name: proxy
|
||||
image: nginxinc/nginx-unprivileged:1.31-alpine@sha256:b9241c6e7b8e9a862f129d8d4199ab64b10390949a78bdd5603379b32c844083
|
||||
image: nginxinc/nginx-unprivileged:1.31-alpine@sha256:378d0c32f6562f7fd9629170797f57d2b953aa09be983b3daaf4809bb039ca00
|
||||
env:
|
||||
- name: POD_IP
|
||||
valueFrom:
|
||||
@@ -203,7 +203,7 @@ spec:
|
||||
# readiness probe on purpose: a broker outage must not take HA out of
|
||||
# its Service endpoints.
|
||||
- name: mqtt-tls
|
||||
image: ghostunnel/ghostunnel:v1.11.3@sha256:2599b8a04bae16d70a4209495618dea61b46a00a9a05fcf74da282688ad3517f
|
||||
image: ghostunnel/ghostunnel:v1.11.4@sha256:e5248233863aadd36383a11c92360f2981141881ef05e38360a6ada8630d36b1
|
||||
args:
|
||||
- client
|
||||
- --listen=127.0.0.1:11883
|
||||
@@ -304,7 +304,7 @@ spec:
|
||||
# dedicated long-lived token.
|
||||
# Runs as root to match HA's root-owned /config files. See config-sync.sh.
|
||||
- name: config-sync
|
||||
image: alpine/git:2.54.0@sha256:ce84fd6a057b4dc5cf5009da2dd832f1c6d096c1984bf50496cf3c256402264d
|
||||
image: alpine/git:2.54.0@sha256:03228d7ded483b0b3e55e1de7fa660d70d3e1bb432fa7021d29e192640de11aa
|
||||
command: ["/bin/sh", "/scripts/sync.sh"]
|
||||
env:
|
||||
- name: SYNC_BRANCH
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
---
|
||||
apiVersion: apiextensions.k8s.io/v1
|
||||
kind: CustomResourceDefinition
|
||||
metadata:
|
||||
annotations:
|
||||
controller-gen.kubebuilder.io/version: v0.23.0
|
||||
kustomize.toolkit.fluxcd.io/prune: disabled
|
||||
name: kumamonitors.uptime.renner.uno
|
||||
spec:
|
||||
group: uptime.renner.uno
|
||||
names:
|
||||
kind: KumaMonitor
|
||||
listKind: KumaMonitorList
|
||||
plural: kumamonitors
|
||||
singular: kumamonitor
|
||||
scope: Namespaced
|
||||
versions:
|
||||
- additionalPrinterColumns:
|
||||
- jsonPath: .spec.type
|
||||
name: Type
|
||||
type: string
|
||||
- jsonPath: .status.conditions[?(@.type=="Ready")].status
|
||||
name: Ready
|
||||
type: string
|
||||
name: v1alpha1
|
||||
schema:
|
||||
openAPIV3Schema:
|
||||
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:
|
||||
description:
|
||||
type: string
|
||||
displayName:
|
||||
minLength: 1
|
||||
type: string
|
||||
dns:
|
||||
properties:
|
||||
hostname:
|
||||
minLength: 1
|
||||
type: string
|
||||
port:
|
||||
default: 53
|
||||
format: int32
|
||||
maximum: 65535
|
||||
minimum: 1
|
||||
type: integer
|
||||
resolveServer:
|
||||
default: 1.1.1.1
|
||||
type: string
|
||||
resolveType:
|
||||
enum:
|
||||
- A
|
||||
- AAAA
|
||||
- CNAME
|
||||
- MX
|
||||
- NS
|
||||
- PTR
|
||||
- SOA
|
||||
- SRV
|
||||
- TXT
|
||||
- CAA
|
||||
type: string
|
||||
required:
|
||||
- hostname
|
||||
- resolveType
|
||||
type: object
|
||||
http:
|
||||
description: HTTPSpec configures http, keyword and json-query monitors.
|
||||
properties:
|
||||
acceptedStatusCodes:
|
||||
default:
|
||||
- 200-299
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
body:
|
||||
type: string
|
||||
expiryNotification:
|
||||
type: boolean
|
||||
headers:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
ignoreTLS:
|
||||
type: boolean
|
||||
maxRedirects:
|
||||
format: int32
|
||||
type: integer
|
||||
method:
|
||||
default: GET
|
||||
type: string
|
||||
url:
|
||||
minLength: 1
|
||||
type: string
|
||||
required:
|
||||
- url
|
||||
type: object
|
||||
interval:
|
||||
default: 60
|
||||
description: Seconds.
|
||||
format: int32
|
||||
minimum: 20
|
||||
type: integer
|
||||
jsonQuery:
|
||||
properties:
|
||||
expected:
|
||||
type: string
|
||||
expression:
|
||||
description: JSONata expression.
|
||||
minLength: 1
|
||||
type: string
|
||||
operator:
|
||||
enum:
|
||||
- ==
|
||||
- '!='
|
||||
- <
|
||||
- <=
|
||||
- '>'
|
||||
- '>='
|
||||
- contains
|
||||
type: string
|
||||
required:
|
||||
- expected
|
||||
- expression
|
||||
- operator
|
||||
type: object
|
||||
keyword:
|
||||
properties:
|
||||
invert:
|
||||
type: boolean
|
||||
keyword:
|
||||
minLength: 1
|
||||
type: string
|
||||
required:
|
||||
- keyword
|
||||
type: object
|
||||
maxRetries:
|
||||
default: 0
|
||||
format: int32
|
||||
minimum: 0
|
||||
type: integer
|
||||
parentRef:
|
||||
description: LocalRef references a KumaMonitor in the same namespace.
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
type: object
|
||||
paused:
|
||||
type: boolean
|
||||
port:
|
||||
properties:
|
||||
hostname:
|
||||
minLength: 1
|
||||
type: string
|
||||
port:
|
||||
format: int32
|
||||
maximum: 65535
|
||||
minimum: 1
|
||||
type: integer
|
||||
required:
|
||||
- hostname
|
||||
- port
|
||||
type: object
|
||||
retryInterval:
|
||||
default: 60
|
||||
description: Seconds.
|
||||
format: int32
|
||||
minimum: 20
|
||||
type: integer
|
||||
timeout:
|
||||
description: Seconds.
|
||||
format: int32
|
||||
minimum: 1
|
||||
type: integer
|
||||
type:
|
||||
enum:
|
||||
- group
|
||||
- http
|
||||
- keyword
|
||||
- json-query
|
||||
- dns
|
||||
- port
|
||||
type: string
|
||||
required:
|
||||
- displayName
|
||||
- type
|
||||
type: object
|
||||
x-kubernetes-validations:
|
||||
- message: http is required for types http, keyword and json-query, and
|
||||
forbidden otherwise
|
||||
rule: (self.type in ['http','keyword','json-query']) == has(self.http)
|
||||
- message: keyword is required for type keyword and forbidden otherwise
|
||||
rule: (self.type == 'keyword') == has(self.keyword)
|
||||
- message: jsonQuery is required for type json-query and forbidden otherwise
|
||||
rule: (self.type == 'json-query') == has(self.jsonQuery)
|
||||
- message: dns is required for type dns and forbidden otherwise
|
||||
rule: (self.type == 'dns') == has(self.dns)
|
||||
- message: port is required for type port and forbidden otherwise
|
||||
rule: (self.type == 'port') == has(self.port)
|
||||
- message: type group takes no type blocks
|
||||
rule: self.type != 'group' || !(has(self.http) || has(self.keyword)
|
||||
|| has(self.jsonQuery) || has(self.dns) || has(self.port))
|
||||
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
|
||||
monitorID:
|
||||
format: int64
|
||||
type: integer
|
||||
observedGeneration:
|
||||
format: int64
|
||||
type: integer
|
||||
type: object
|
||||
required:
|
||||
- spec
|
||||
type: object
|
||||
served: true
|
||||
storage: true
|
||||
subresources:
|
||||
status: {}
|
||||
@@ -0,0 +1,71 @@
|
||||
# kuma-sync: KumaMonitor -> Uptime Kuma monitors on kuma.renner.uno (ci/images/kuma-sync).
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: kuma-sync
|
||||
namespace: kuma-sync
|
||||
labels:
|
||||
app.kubernetes.io/name: kuma-sync
|
||||
annotations:
|
||||
secret.reloader.stakater.com/reload: kuma-sync-kuma
|
||||
spec:
|
||||
replicas: 1
|
||||
# One writer against Kuma at a time; the controller runs without leader election.
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: kuma-sync
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: kuma-sync
|
||||
spec:
|
||||
serviceAccountName: kuma-sync
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 65532
|
||||
runAsGroup: 65532
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: controller
|
||||
image: gitea.renner.uno/gitea_admin/kuma-sync:2026-10-19-0646 # {"$imagepolicy": "flux-system:kuma-sync"}
|
||||
env:
|
||||
- name: KUMA_URL
|
||||
value: https://kuma.renner.uno
|
||||
- name: KUMA_USERNAME
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: kuma-sync-kuma
|
||||
key: username
|
||||
- name: KUMA_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: kuma-sync-kuma
|
||||
key: password
|
||||
ports:
|
||||
- name: metrics
|
||||
containerPort: 8080
|
||||
- name: probes
|
||||
containerPort: 8081
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: probes
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /readyz
|
||||
port: probes
|
||||
resources:
|
||||
requests:
|
||||
cpu: 10m
|
||||
memory: 64Mi
|
||||
limits:
|
||||
memory: 256Mi
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
terminationGracePeriodSeconds: 30
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
# Uptime Kuma login for kuma-sync; OpenBao homelab/infrastructure/kuma-sync.
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
metadata:
|
||||
name: kuma-sync-kuma
|
||||
namespace: kuma-sync
|
||||
spec:
|
||||
refreshInterval: 1h
|
||||
secretStoreRef:
|
||||
kind: ClusterSecretStore
|
||||
name: openbao
|
||||
target:
|
||||
name: kuma-sync-kuma
|
||||
data:
|
||||
- secretKey: username
|
||||
remoteRef:
|
||||
key: infrastructure/kuma-sync
|
||||
property: username
|
||||
- secretKey: password
|
||||
remoteRef:
|
||||
key: infrastructure/kuma-sync
|
||||
property: password
|
||||
@@ -0,0 +1,8 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- crd-kumamonitors.yaml
|
||||
- externalsecret.yaml
|
||||
- rbac.yaml
|
||||
- deployment.yaml
|
||||
- monitors
|
||||
@@ -0,0 +1,53 @@
|
||||
apiVersion: uptime.renner.uno/v1alpha1
|
||||
kind: KumaMonitor
|
||||
metadata:
|
||||
name: audiobooks
|
||||
namespace: kuma-sync
|
||||
spec:
|
||||
type: group
|
||||
displayName: Audiobooks
|
||||
---
|
||||
apiVersion: uptime.renner.uno/v1alpha1
|
||||
kind: KumaMonitor
|
||||
metadata:
|
||||
name: audiobooks-health
|
||||
namespace: kuma-sync
|
||||
spec:
|
||||
type: keyword
|
||||
displayName: Audiobookshelf Health
|
||||
parentRef:
|
||||
name: audiobooks
|
||||
interval: 60
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
timeout: 10
|
||||
http:
|
||||
url: https://audiobooks.renner.uno/healthcheck
|
||||
acceptedStatusCodes: ["200-299"]
|
||||
expiryNotification: true
|
||||
keyword:
|
||||
keyword: OK
|
||||
---
|
||||
apiVersion: uptime.renner.uno/v1alpha1
|
||||
kind: KumaMonitor
|
||||
metadata:
|
||||
name: audiobooks-status
|
||||
namespace: kuma-sync
|
||||
spec:
|
||||
type: json-query
|
||||
displayName: Audiobookshelf Server
|
||||
parentRef:
|
||||
name: audiobooks
|
||||
interval: 60
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
timeout: 10
|
||||
http:
|
||||
# initialized-server flag from the unauthenticated status endpoint
|
||||
url: https://audiobooks.renner.uno/status
|
||||
acceptedStatusCodes: ["200-299"]
|
||||
expiryNotification: true
|
||||
jsonQuery:
|
||||
expression: isInit
|
||||
operator: ==
|
||||
expected: "true"
|
||||
@@ -0,0 +1,48 @@
|
||||
apiVersion: uptime.renner.uno/v1alpha1
|
||||
kind: KumaMonitor
|
||||
metadata:
|
||||
name: auth
|
||||
namespace: kuma-sync
|
||||
spec:
|
||||
type: group
|
||||
displayName: Authentik
|
||||
---
|
||||
apiVersion: uptime.renner.uno/v1alpha1
|
||||
kind: KumaMonitor
|
||||
metadata:
|
||||
name: auth-live
|
||||
namespace: kuma-sync
|
||||
spec:
|
||||
type: http
|
||||
displayName: Authentik Live
|
||||
parentRef:
|
||||
name: auth
|
||||
interval: 60
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
timeout: 10
|
||||
http:
|
||||
# ungated health endpoint, empty body
|
||||
url: https://auth.renner.uno/-/health/live/
|
||||
acceptedStatusCodes: ["200-299"]
|
||||
expiryNotification: true
|
||||
---
|
||||
apiVersion: uptime.renner.uno/v1alpha1
|
||||
kind: KumaMonitor
|
||||
metadata:
|
||||
name: auth-ready
|
||||
namespace: kuma-sync
|
||||
spec:
|
||||
type: http
|
||||
displayName: Authentik Ready
|
||||
parentRef:
|
||||
name: auth
|
||||
interval: 60
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
timeout: 10
|
||||
http:
|
||||
# ungated readiness endpoint (database and cache), empty body
|
||||
url: https://auth.renner.uno/-/health/ready/
|
||||
acceptedStatusCodes: ["200-299"]
|
||||
expiryNotification: true
|
||||
@@ -0,0 +1,30 @@
|
||||
apiVersion: uptime.renner.uno/v1alpha1
|
||||
kind: KumaMonitor
|
||||
metadata:
|
||||
name: docs
|
||||
namespace: kuma-sync
|
||||
spec:
|
||||
type: group
|
||||
displayName: Documents
|
||||
---
|
||||
apiVersion: uptime.renner.uno/v1alpha1
|
||||
kind: KumaMonitor
|
||||
metadata:
|
||||
name: docs-gate
|
||||
namespace: kuma-sync
|
||||
spec:
|
||||
type: keyword
|
||||
displayName: Documents Login
|
||||
parentRef:
|
||||
name: docs
|
||||
interval: 60
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
timeout: 20
|
||||
http:
|
||||
# Paperless is fully behind the Authentik gate; asserts the login page the redirect chain ends on
|
||||
url: https://docs.renner.uno/
|
||||
acceptedStatusCodes: ["200-299"]
|
||||
expiryNotification: true
|
||||
keyword:
|
||||
keyword: <title>authentik</title>
|
||||
@@ -0,0 +1,53 @@
|
||||
apiVersion: uptime.renner.uno/v1alpha1
|
||||
kind: KumaMonitor
|
||||
metadata:
|
||||
name: home
|
||||
namespace: kuma-sync
|
||||
spec:
|
||||
type: group
|
||||
displayName: Home Assistant
|
||||
---
|
||||
apiVersion: uptime.renner.uno/v1alpha1
|
||||
kind: KumaMonitor
|
||||
metadata:
|
||||
name: home-api
|
||||
namespace: kuma-sync
|
||||
spec:
|
||||
type: json-query
|
||||
displayName: Home Assistant API
|
||||
parentRef:
|
||||
name: home
|
||||
interval: 60
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
timeout: 10
|
||||
http:
|
||||
# unauthenticated login-provider list, served by the HA core backend
|
||||
url: https://home.renner.uno/auth/providers
|
||||
acceptedStatusCodes: ["200-299"]
|
||||
expiryNotification: true
|
||||
jsonQuery:
|
||||
expression: providers[type="homeassistant"].name
|
||||
operator: ==
|
||||
expected: Home Assistant Local
|
||||
---
|
||||
apiVersion: uptime.renner.uno/v1alpha1
|
||||
kind: KumaMonitor
|
||||
metadata:
|
||||
name: home-web
|
||||
namespace: kuma-sync
|
||||
spec:
|
||||
type: keyword
|
||||
displayName: Home Assistant Web
|
||||
parentRef:
|
||||
name: home
|
||||
interval: 60
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
timeout: 10
|
||||
http:
|
||||
url: https://home.renner.uno/
|
||||
acceptedStatusCodes: ["200-299"]
|
||||
expiryNotification: true
|
||||
keyword:
|
||||
keyword: <title>Home Assistant</title>
|
||||
@@ -0,0 +1,31 @@
|
||||
apiVersion: uptime.renner.uno/v1alpha1
|
||||
kind: KumaMonitor
|
||||
metadata:
|
||||
name: immich
|
||||
namespace: kuma-sync
|
||||
spec:
|
||||
type: group
|
||||
displayName: Immich
|
||||
---
|
||||
apiVersion: uptime.renner.uno/v1alpha1
|
||||
kind: KumaMonitor
|
||||
metadata:
|
||||
name: immich-api
|
||||
namespace: kuma-sync
|
||||
spec:
|
||||
type: json-query
|
||||
displayName: Immich API
|
||||
parentRef:
|
||||
name: immich
|
||||
interval: 60
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
timeout: 10
|
||||
http:
|
||||
url: https://immich.renner.uno/api/server/ping
|
||||
acceptedStatusCodes: ["200-299"]
|
||||
expiryNotification: true
|
||||
jsonQuery:
|
||||
expression: res
|
||||
operator: ==
|
||||
expected: pong
|
||||
@@ -0,0 +1,10 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- renner-uno.yaml
|
||||
- home.yaml
|
||||
- immich.yaml
|
||||
- auth.yaml
|
||||
- docs.yaml
|
||||
- audiobooks.yaml
|
||||
- third-party.yaml
|
||||
@@ -0,0 +1,51 @@
|
||||
apiVersion: uptime.renner.uno/v1alpha1
|
||||
kind: KumaMonitor
|
||||
metadata:
|
||||
name: renner-uno
|
||||
namespace: kuma-sync
|
||||
spec:
|
||||
type: group
|
||||
displayName: Renner.uno Website
|
||||
---
|
||||
apiVersion: uptime.renner.uno/v1alpha1
|
||||
kind: KumaMonitor
|
||||
metadata:
|
||||
name: renner-uno-www
|
||||
namespace: kuma-sync
|
||||
spec:
|
||||
type: keyword
|
||||
displayName: www.renner.uno
|
||||
parentRef:
|
||||
name: renner-uno
|
||||
interval: 60
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
timeout: 10
|
||||
http:
|
||||
url: https://www.renner.uno/
|
||||
acceptedStatusCodes: ["200-299"]
|
||||
expiryNotification: true
|
||||
keyword:
|
||||
keyword: Familie Renner
|
||||
---
|
||||
apiVersion: uptime.renner.uno/v1alpha1
|
||||
kind: KumaMonitor
|
||||
metadata:
|
||||
name: renner-uno-apex
|
||||
namespace: kuma-sync
|
||||
spec:
|
||||
type: keyword
|
||||
displayName: renner.uno
|
||||
parentRef:
|
||||
name: renner-uno
|
||||
interval: 60
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
timeout: 10
|
||||
http:
|
||||
# 301 to www, followed
|
||||
url: https://renner.uno/
|
||||
acceptedStatusCodes: ["200-299"]
|
||||
expiryNotification: true
|
||||
keyword:
|
||||
keyword: Familie Renner
|
||||
@@ -0,0 +1,87 @@
|
||||
apiVersion: uptime.renner.uno/v1alpha1
|
||||
kind: KumaMonitor
|
||||
metadata:
|
||||
name: third-party
|
||||
namespace: kuma-sync
|
||||
spec:
|
||||
type: group
|
||||
displayName: Third-party
|
||||
---
|
||||
apiVersion: uptime.renner.uno/v1alpha1
|
||||
kind: KumaMonitor
|
||||
metadata:
|
||||
name: mcb-portal
|
||||
namespace: kuma-sync
|
||||
spec:
|
||||
type: http
|
||||
displayName: MCB Portal
|
||||
parentRef:
|
||||
name: third-party
|
||||
interval: 60
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
timeout: 15
|
||||
http:
|
||||
# root 302s to / to set a session cookie; Kuma keeps no cookies, so do not follow
|
||||
url: https://portal.multi-circuit-boards.eu/
|
||||
acceptedStatusCodes: ["200-299", "302"]
|
||||
maxRedirects: 0
|
||||
expiryNotification: true
|
||||
---
|
||||
apiVersion: uptime.renner.uno/v1alpha1
|
||||
kind: KumaMonitor
|
||||
metadata:
|
||||
name: mcb-tech
|
||||
namespace: kuma-sync
|
||||
spec:
|
||||
type: http
|
||||
displayName: MCB Technician Portal
|
||||
parentRef:
|
||||
name: third-party
|
||||
interval: 60
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
timeout: 15
|
||||
http:
|
||||
url: https://tech.multi-circuit-boards.eu/
|
||||
acceptedStatusCodes: ["200-299"]
|
||||
expiryNotification: true
|
||||
---
|
||||
apiVersion: uptime.renner.uno/v1alpha1
|
||||
kind: KumaMonitor
|
||||
metadata:
|
||||
name: mcb-www
|
||||
namespace: kuma-sync
|
||||
spec:
|
||||
type: http
|
||||
displayName: MCB Website
|
||||
parentRef:
|
||||
name: third-party
|
||||
interval: 60
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
timeout: 15
|
||||
http:
|
||||
# 301 to /index.html, followed
|
||||
url: https://www.multi-circuit-boards.eu/
|
||||
acceptedStatusCodes: ["200-299"]
|
||||
expiryNotification: true
|
||||
---
|
||||
apiVersion: uptime.renner.uno/v1alpha1
|
||||
kind: KumaMonitor
|
||||
metadata:
|
||||
name: turm1388
|
||||
namespace: kuma-sync
|
||||
spec:
|
||||
type: http
|
||||
displayName: Wohnen im Turm
|
||||
parentRef:
|
||||
name: third-party
|
||||
interval: 60
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
timeout: 15
|
||||
http:
|
||||
url: https://turm1388.de/
|
||||
acceptedStatusCodes: ["200-299"]
|
||||
expiryNotification: true
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: kuma-sync
|
||||
namespace: kuma-sync
|
||||
labels:
|
||||
app.kubernetes.io/name: kuma-sync
|
||||
---
|
||||
# Cluster-wide: KumaMonitor objects live in every app namespace.
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: kuma-sync
|
||||
rules:
|
||||
- apiGroups: ["uptime.renner.uno"]
|
||||
resources: ["kumamonitors"]
|
||||
verbs: ["get", "list", "watch", "update", "patch"]
|
||||
- apiGroups: ["uptime.renner.uno"]
|
||||
resources: ["kumamonitors/status"]
|
||||
verbs: ["get", "update", "patch"]
|
||||
- apiGroups: ["uptime.renner.uno"]
|
||||
resources: ["kumamonitors/finalizers"]
|
||||
verbs: ["update"]
|
||||
- apiGroups: [""]
|
||||
resources: ["events"]
|
||||
verbs: ["create", "patch"]
|
||||
- apiGroups: [""]
|
||||
resources: ["namespaces"]
|
||||
verbs: ["get"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: kuma-sync
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: ClusterRole
|
||||
name: kuma-sync
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: kuma-sync
|
||||
namespace: kuma-sync
|
||||
@@ -18,6 +18,7 @@ resources:
|
||||
- prometheusrule-netbird-availability.yaml
|
||||
- prometheusrule-netbird-ingress.yaml
|
||||
- prometheusrule-openbao.yaml
|
||||
- prometheusrule-uptime-kuma.yaml
|
||||
# Prometheus's own retention. Until REN-107 `retention: 90d` and
|
||||
# `retentionSize: 30GB` were both set, the size cap bound first, and the
|
||||
# effective window was a 20.5-27.2 day saw-tooth with nothing reporting it.
|
||||
|
||||
@@ -44,6 +44,8 @@ spec:
|
||||
# traverses the WAN and detects a controller/DC outage (the
|
||||
# 2026-07-24 one ran ~20 h with no signal). Dashboard root, 200.
|
||||
- https://netbird.renner.uno
|
||||
# Dead-man probe for the off-site Uptime Kuma.
|
||||
- https://kuma.renner.uno
|
||||
# NetBird relay health endpoints on the netcup members (relay
|
||||
# binary serves :9000/health, verified on 0.73.2). Plain http,
|
||||
# ports outside the hairpin's 80/443 — real WAN path.
|
||||
|
||||
@@ -26,6 +26,7 @@ spec:
|
||||
# proxy-1/2:9000/health same file, relay redundancy
|
||||
# ci.renner.uno WoodpeckerDown
|
||||
# openbao .../sys/health OpenBaoUnavailable
|
||||
# kuma.renner.uno UptimeKumaUnreachable
|
||||
#
|
||||
# Warning, not critical, on purpose: this rule cannot know what a given
|
||||
# target means. A target whose loss deserves a critical page gets its
|
||||
@@ -33,7 +34,7 @@ spec:
|
||||
# exclusion list from being arbitrary.
|
||||
# anchor: blackbox-probe-failing
|
||||
- alert: BlackboxProbeFailing
|
||||
expr: probe_success{job="blackbox-http", instance!~"https://fsn1\\.your-objectstorage\\.com|https://netbird\\.renner\\.uno|http://proxy-[12]\\.renner\\.uno:9000/health|https://ci\\.renner\\.uno|https://openbao\\.renner\\.uno/v1/sys/health.*"} == 0
|
||||
expr: probe_success{job="blackbox-http", instance!~"https://fsn1\\.your-objectstorage\\.com|https://netbird\\.renner\\.uno|http://proxy-[12]\\.renner\\.uno:9000/health|https://ci\\.renner\\.uno|https://kuma\\.renner\\.uno|https://openbao\\.renner\\.uno/v1/sys/health.*"} == 0
|
||||
for: 10m
|
||||
annotations:
|
||||
summary: "Blackbox probe of {{ $labels.instance }} has been failing for 10 minutes"
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
kind: PrometheusRule
|
||||
metadata:
|
||||
name: uptime-kuma
|
||||
namespace: monitoring
|
||||
labels:
|
||||
# kube-prometheus-stack's Prometheus selects on this label.
|
||||
release: kube-prometheus-stack
|
||||
spec:
|
||||
groups:
|
||||
- name: uptime-kuma.rules
|
||||
rules:
|
||||
# Dead-man probe for the off-site Uptime Kuma.
|
||||
- alert: UptimeKumaUnreachable
|
||||
expr: probe_success{job="blackbox-http", instance="https://kuma.renner.uno"} == 0
|
||||
for: 10m
|
||||
labels:
|
||||
severity: critical
|
||||
annotations:
|
||||
summary: "Uptime Kuma (kuma.renner.uno) unreachable for 10m+"
|
||||
description: "The blackbox probe of the off-site Uptime Kuma has been failing for 10 minutes. The external endpoints it monitors are unwatched until it returns. Check the NetBird VPS (netbird.renner.uno), the uptime-kuma container and Traefik."
|
||||
|
||||
# monitor_response_time is ms; -1 when a beat has no ping (DOWN, group).
|
||||
# anchor: uptime-kuma-slow-response
|
||||
- alert: UptimeKumaSlowResponse
|
||||
expr: avg by (monitor_name) (avg_over_time((monitor_response_time > 0)[15m:1m])) > 1500
|
||||
for: 15m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "Uptime Kuma monitor {{ $labels.monitor_name }} averaging {{ $value | humanize }} ms response time"
|
||||
description: >-
|
||||
The average response time of monitor {{ $labels.monitor_name }},
|
||||
as measured from the off-site Uptime Kuma, has stayed above
|
||||
1500 ms for 15 minutes.
|
||||
@@ -32,6 +32,7 @@ retired the depth rule in favour of one persistence rule (`> 5` for `20m`).
|
||||
| `ceph_test.yaml` | Hand-written suite for the `CephPoolLostRedundancyBlind` canary in `prometheusrule-ceph.yaml` (REN-93). |
|
||||
| `disk-wear_test.yaml` | Hand-written suite for the EPHEMERAL exclusion in `prometheusrule-disk-wear.yaml` (REN-419). |
|
||||
| `gitea_test.yaml` | Hand-written suite for `GiteaDataVolumeLow` in `prometheusrule-gitea.yaml` (REN-583). |
|
||||
| `uptime-kuma_test.yaml` | Hand-written suite for `prometheusrule-uptime-kuma.yaml` and the kuma exclusion in `prometheusrule-blackbox.yaml`. |
|
||||
| `mutations.txt` | Negative controls. Each must make the suite fail. |
|
||||
|
||||
## Adding a suite
|
||||
|
||||
@@ -172,3 +172,12 @@ disk-wear-ephemeral-keeps-partition|kubernetes/apps/monitoring-rules/prometheusr
|
||||
# exactly-25% case in gitea_test.yaml.
|
||||
gitea-for-10m|kubernetes/apps/monitoring-rules/prometheusrule-gitea.yaml|/alert: GiteaDataVolumeLow/,/for:/s/for: 15m/for: 10m/
|
||||
gitea-floor-le|kubernetes/apps/monitoring-rules/prometheusrule-gitea.yaml|s/^\([[:space:]]*\)< 0\.25$/\1<= 0.25/
|
||||
|
||||
# prometheusrule-uptime-kuma.yaml / blackbox kuma exclusion. Detected by the
|
||||
# cases in uptime-kuma_test.yaml.
|
||||
kuma-slow-no-positive-filter|kubernetes/apps/monitoring-rules/prometheusrule-uptime-kuma.yaml|s/(monitor_response_time > 0)/(monitor_response_time)/
|
||||
kuma-slow-threshold-2500|kubernetes/apps/monitoring-rules/prometheusrule-uptime-kuma.yaml|s/\[15m:1m\])) > 1500$/[15m:1m])) > 2500/
|
||||
kuma-slow-threshold-900|kubernetes/apps/monitoring-rules/prometheusrule-uptime-kuma.yaml|s/\[15m:1m\])) > 1500$/[15m:1m])) > 900/
|
||||
kuma-slow-for-30m|kubernetes/apps/monitoring-rules/prometheusrule-uptime-kuma.yaml|/alert: UptimeKumaSlowResponse/,/for:/s/for: 15m/for: 30m/
|
||||
kuma-down-for-20m|kubernetes/apps/monitoring-rules/prometheusrule-uptime-kuma.yaml|/alert: UptimeKumaUnreachable/,/for:/s/for: 10m/for: 20m/
|
||||
kuma-blackbox-exclusion-dropped|kubernetes/apps/monitoring-rules/prometheusrule-blackbox.yaml|s/kuma\\\\\.renner/kumx\\\\.renner/
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
# Suite for prometheusrule-uptime-kuma.yaml and the kuma exclusion in
|
||||
# prometheusrule-blackbox.yaml.
|
||||
rule_files:
|
||||
- ../prometheusrule-uptime-kuma.yaml
|
||||
- ../prometheusrule-blackbox.yaml
|
||||
|
||||
evaluation_interval: 1m
|
||||
|
||||
tests:
|
||||
# 2000 ms sustained: pending at 0m, fires 15m later, not before.
|
||||
- interval: 1m
|
||||
input_series:
|
||||
- series: 'monitor_response_time{monitor_name="slow-mon"}'
|
||||
values: '2000+0x40'
|
||||
alert_rule_test:
|
||||
- eval_time: 14m
|
||||
alertname: UptimeKumaSlowResponse
|
||||
exp_alerts: []
|
||||
- eval_time: 16m
|
||||
alertname: UptimeKumaSlowResponse
|
||||
exp_alerts:
|
||||
- exp_labels:
|
||||
severity: warning
|
||||
monitor_name: slow-mon
|
||||
exp_annotations:
|
||||
summary: "Uptime Kuma monitor slow-mon averaging 2k ms response time"
|
||||
description: "The average response time of monitor slow-mon, as measured from the off-site Uptime Kuma, has stayed above 1500 ms for 15 minutes."
|
||||
|
||||
# 1000 ms stays silent; a monitor reporting only -1 (DOWN, group) stays silent.
|
||||
- interval: 1m
|
||||
input_series:
|
||||
- series: 'monitor_response_time{monitor_name="fast-mon"}'
|
||||
values: '1000+0x40'
|
||||
- series: 'monitor_response_time{monitor_name="down-mon"}'
|
||||
values: '-1+0x40'
|
||||
alert_rule_test:
|
||||
- eval_time: 35m
|
||||
alertname: UptimeKumaSlowResponse
|
||||
exp_alerts: []
|
||||
|
||||
# Alternating 1000 and -1: the -1 samples must not drag a slow average down
|
||||
# nor count as slow; positives average 1000, silent.
|
||||
- interval: 1m
|
||||
input_series:
|
||||
- series: 'monitor_response_time{monitor_name="flap-fast"}'
|
||||
values: '1000 -1 1000 -1 1000 -1 1000 -1 1000 -1 1000 -1 1000 -1 1000 -1 1000 -1 1000 -1 1000 -1 1000 -1 1000 -1 1000 -1 1000 -1 1000 -1 1000 -1 1000 -1 1000 -1 1000 -1'
|
||||
alert_rule_test:
|
||||
- eval_time: 35m
|
||||
alertname: UptimeKumaSlowResponse
|
||||
exp_alerts: []
|
||||
|
||||
# Alternating 2000 and -1: the -1 samples are excluded, so the average of the
|
||||
# real samples is 2000 and the alert fires. Unfiltered, the mean is ~500.
|
||||
- interval: 1m
|
||||
input_series:
|
||||
- series: 'monitor_response_time{monitor_name="flap-slow"}'
|
||||
values: '2000 -1 2000 -1 2000 -1 2000 -1 2000 -1 2000 -1 2000 -1 2000 -1 2000 -1 2000 -1 2000 -1 2000 -1 2000 -1 2000 -1 2000 -1 2000 -1 2000 -1 2000 -1 2000 -1 2000 -1'
|
||||
alert_rule_test:
|
||||
- eval_time: 35m
|
||||
alertname: UptimeKumaSlowResponse
|
||||
exp_alerts:
|
||||
- exp_labels:
|
||||
severity: warning
|
||||
monitor_name: flap-slow
|
||||
exp_annotations:
|
||||
summary: "Uptime Kuma monitor flap-slow averaging 2k ms response time"
|
||||
description: "The average response time of monitor flap-slow, as measured from the off-site Uptime Kuma, has stayed above 1500 ms for 15 minutes."
|
||||
|
||||
# Dead-man probe: critical after 10m of failure; the catch-all warning must
|
||||
# not also fire for kuma, but still fires for another failing instance.
|
||||
- interval: 1m
|
||||
input_series:
|
||||
- series: 'probe_success{job="blackbox-http",instance="https://kuma.renner.uno"}'
|
||||
values: '0+0x40'
|
||||
- series: 'probe_success{job="blackbox-http",instance="https://other.renner.uno"}'
|
||||
values: '0+0x40'
|
||||
alert_rule_test:
|
||||
- eval_time: 9m
|
||||
alertname: UptimeKumaUnreachable
|
||||
exp_alerts: []
|
||||
- eval_time: 11m
|
||||
alertname: UptimeKumaUnreachable
|
||||
exp_alerts:
|
||||
- exp_labels:
|
||||
severity: critical
|
||||
job: blackbox-http
|
||||
instance: https://kuma.renner.uno
|
||||
exp_annotations:
|
||||
summary: "Uptime Kuma (kuma.renner.uno) unreachable for 10m+"
|
||||
description: "The blackbox probe of the off-site Uptime Kuma has been failing for 10 minutes. The external endpoints it monitors are unwatched until it returns. Check the NetBird VPS (netbird.renner.uno), the uptime-kuma container and Traefik."
|
||||
- eval_time: 15m
|
||||
alertname: BlackboxProbeFailing
|
||||
exp_alerts:
|
||||
- exp_labels:
|
||||
severity: warning
|
||||
job: blackbox-http
|
||||
instance: https://other.renner.uno
|
||||
exp_annotations:
|
||||
summary: "Blackbox probe of https://other.renner.uno has been failing for 10 minutes"
|
||||
description: "The endpoint is not answering as expected. For the Authentik-gated hostnames this is a reachability check covering DNS, TLS, Traefik and the gate outpost — it says nothing about the app behind the gate, which the internal /-/healthy probes cover separately."
|
||||
|
||||
# Healthy probe stays silent.
|
||||
- interval: 1m
|
||||
input_series:
|
||||
- series: 'probe_success{job="blackbox-http",instance="https://kuma.renner.uno"}'
|
||||
values: '1+0x40'
|
||||
alert_rule_test:
|
||||
- eval_time: 30m
|
||||
alertname: UptimeKumaUnreachable
|
||||
exp_alerts: []
|
||||
@@ -97,6 +97,10 @@ spec:
|
||||
resources:
|
||||
requests:
|
||||
storage: 50Gi
|
||||
# Mounted at /etc/prometheus/secrets/<name>/; see
|
||||
# ./uptime-kuma-metrics-externalsecret.yaml.
|
||||
secrets:
|
||||
- uptime-kuma-metrics
|
||||
# Additional scrape targets not covered by ServiceMonitors.
|
||||
additionalScrapeConfigs:
|
||||
# Gitea exposes /metrics anonymously on its main HTTP port. The
|
||||
@@ -106,6 +110,16 @@ spec:
|
||||
static_configs:
|
||||
- targets: ["gitea.gitea.svc.cluster.local:3000"]
|
||||
metrics_path: /metrics
|
||||
# Off-site Uptime Kuma. /metrics takes the API key as the basic-auth
|
||||
# password with an empty username.
|
||||
- job_name: uptime-kuma
|
||||
scheme: https
|
||||
metrics_path: /metrics
|
||||
basic_auth:
|
||||
username: ""
|
||||
password_file: /etc/prometheus/secrets/uptime-kuma-metrics/api_key
|
||||
static_configs:
|
||||
- targets: ["kuma.renner.uno:443"]
|
||||
|
||||
# Grafana
|
||||
grafana:
|
||||
@@ -317,9 +331,7 @@ spec:
|
||||
- matchers:
|
||||
- alertname = "InfoInhibitor"
|
||||
receiver: 'null'
|
||||
# Flux notification-controller posts info-severity events
|
||||
# here (e.g. image-automation commits). Keep them visible
|
||||
# but don't repeat-spam every 12h on the same event.
|
||||
# Flux notification-controller error events (flux-failures Alert).
|
||||
- matchers:
|
||||
- source = "flux"
|
||||
receiver: email
|
||||
|
||||
@@ -3,6 +3,7 @@ kind: Kustomization
|
||||
resources:
|
||||
- grafana-oauth-externalsecret.yaml
|
||||
- alertmanager-smtp-externalsecret.yaml
|
||||
- uptime-kuma-metrics-externalsecret.yaml
|
||||
- helmrelease-prometheus.yaml
|
||||
- helmrelease-loki.yaml
|
||||
- helmrelease-alloy-syslog.yaml
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
# Uptime Kuma /metrics API key for Prometheus; OpenBao homelab/infrastructure/uptime-kuma-metrics.
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
metadata:
|
||||
name: uptime-kuma-metrics
|
||||
namespace: monitoring
|
||||
spec:
|
||||
refreshInterval: 1h
|
||||
secretStoreRef:
|
||||
kind: ClusterSecretStore
|
||||
name: openbao
|
||||
target:
|
||||
name: uptime-kuma-metrics
|
||||
data:
|
||||
- secretKey: api_key
|
||||
remoteRef:
|
||||
key: infrastructure/uptime-kuma-metrics
|
||||
property: api_key
|
||||
@@ -32,10 +32,10 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: review-bot
|
||||
image: gitea.renner.uno/gitea_admin/review-bot:2026-10-18-0706 # {"$imagepolicy": "flux-system:review-bot"}
|
||||
image: gitea.renner.uno/gitea_admin/review-bot:2026-10-19-0540 # {"$imagepolicy": "flux-system:review-bot"}
|
||||
env:
|
||||
- name: REPOS
|
||||
value: k.renner/homelab,konairius/homeassistant,konairius/authentik-samba-bridge,konairius/shelly-manager
|
||||
value: konairius/homelab,konairius/homeassistant,konairius/authentik-samba-bridge,konairius/shelly-manager
|
||||
- name: GITEA_URL
|
||||
value: https://gitea.renner.uno
|
||||
- name: AGENT_NAME
|
||||
|
||||
@@ -24,7 +24,8 @@ spec:
|
||||
key: apps/ai/wiki-index
|
||||
property: webhook_secret
|
||||
---
|
||||
# One API key per machine identity: OpenBao apps/ai/wiki-api-keys, field = name.
|
||||
# One API key per machine identity: OpenBao apps/ai/wiki-api-keys, field = name;
|
||||
# hermes in apps/ai/wiki-api-key-hermes.
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
metadata:
|
||||
@@ -42,6 +43,10 @@ spec:
|
||||
remoteRef:
|
||||
key: apps/ai/wiki-api-keys
|
||||
property: workstation
|
||||
- secretKey: Auth__ApiKeys__hermes
|
||||
remoteRef:
|
||||
key: apps/ai/wiki-api-key-hermes
|
||||
property: api_key
|
||||
---
|
||||
# TypeSafe System One key for the judge adapter (Wiki:Judges:Models:typesafe).
|
||||
apiVersion: external-secrets.io/v1
|
||||
|
||||
@@ -25,7 +25,7 @@ spec:
|
||||
fsGroup: 33
|
||||
containers:
|
||||
- name: wordpress
|
||||
image: wordpress:7.1.3-php8.3-apache@sha256:8ecd8d8e374167ea22235acf30f80d1165b82f07ffa5bcef90c1cad8f7be251f
|
||||
image: wordpress:7.1.3-php8.5-apache@sha256:ce31904fa1e51b73543fa2e35cc8f1747589b2abf48dc747b422749b88b77e9e
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 80
|
||||
|
||||
@@ -11,7 +11,7 @@ spec:
|
||||
branch: master
|
||||
secretRef:
|
||||
name: flux-system
|
||||
url: https://gitea.renner.uno/k.renner/homelab.git
|
||||
url: https://gitea.renner.uno/konairius/homelab.git
|
||||
---
|
||||
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||
kind: Kustomization
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||
kind: Kustomization
|
||||
metadata:
|
||||
name: hermes
|
||||
namespace: flux-system
|
||||
spec:
|
||||
interval: 10m
|
||||
retryInterval: 2m
|
||||
timeout: 5m
|
||||
sourceRef:
|
||||
kind: GitRepository
|
||||
name: flux-system
|
||||
path: ./kubernetes/apps/hermes
|
||||
prune: true
|
||||
wait: true
|
||||
dependsOn:
|
||||
- name: infrastructure
|
||||
- name: infrastructure-crds
|
||||
@@ -0,0 +1,25 @@
|
||||
# kuma-sync controller: KumaMonitor -> Uptime Kuma monitors. Ships no KumaMonitor objects.
|
||||
# healthCheckExprs: Flux has no status reader for ExternalSecret; fail only on the non-retrying reasons.
|
||||
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||
kind: Kustomization
|
||||
metadata:
|
||||
name: kuma-sync
|
||||
namespace: flux-system
|
||||
spec:
|
||||
interval: 10m
|
||||
retryInterval: 2m
|
||||
timeout: 5m
|
||||
sourceRef:
|
||||
kind: GitRepository
|
||||
name: flux-system
|
||||
path: ./kubernetes/apps/kuma-sync
|
||||
prune: true
|
||||
wait: true
|
||||
healthCheckExprs:
|
||||
- apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
failed: "has(status.conditions) && status.conditions.exists(c, c.type == 'Ready' && c.status == 'False' && (c.reason == 'SecretOwnedByOther' || c.reason == 'SecretImmutable'))"
|
||||
current: "has(status.conditions) && status.conditions.exists(c, c.type == 'Ready' && c.status == 'True')"
|
||||
dependsOn:
|
||||
- name: infrastructure
|
||||
- name: infrastructure-crds
|
||||
@@ -1,25 +1,3 @@
|
||||
# Image-automation commits: informational events from the IUA when it
|
||||
# actually writes a bump commit. The 30-minute "no change since last
|
||||
# reconciliation" heartbeat is filtered out via exclusionList so we
|
||||
# don't email-spam every IUA pass that did nothing.
|
||||
apiVersion: notification.toolkit.fluxcd.io/v1beta3
|
||||
kind: Alert
|
||||
metadata:
|
||||
name: flux-image-automation
|
||||
namespace: flux-system
|
||||
spec:
|
||||
providerRef:
|
||||
name: alertmanager
|
||||
eventSeverity: info
|
||||
eventSources:
|
||||
- kind: ImageUpdateAutomation
|
||||
name: '*'
|
||||
exclusionList:
|
||||
- 'no change since last reconciliation'
|
||||
eventMetadata:
|
||||
source: flux
|
||||
category: image-automation
|
||||
---
|
||||
# Failures across Flux: any error-severity event from a source/sync
|
||||
# resource. Sends a single alert per error so cluster reconcile breakage
|
||||
# surfaces in email regardless of which controller emitted it.
|
||||
|
||||
@@ -44,6 +44,7 @@ existing `flux-system` GitRepository whose Secret was created by
|
||||
| netbird-ingress | `0.x` | `-rootless` suffix filter; pre-1.0, bounded to the `0.x` major |
|
||||
| wyoming-{whisper,openwakeword,speech-to-phrase} | `3.x` / `2.x` / `1.x` | |
|
||||
| authentik-gate-sync | `YYYY-MM-DD-HHMM` | In-house (build-ci-images.yml); newest tag wins |
|
||||
| kuma-sync | `YYYY-MM-DD-HHMM` | In-house (build-ci-images.yml); newest tag wins |
|
||||
| gatus | `5.x` | v-prefix filter |
|
||||
| velero-plugin-for-aws | `1.13.x` | Tight: must match velero server major.minor |
|
||||
| gotenberg | `8.x` | Plain-variant filter (rejects `-libreoffice`/`-chromium`/`-cloudrun`/`-aws-lambda`) |
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user