Compare commits

..
Author SHA1 Message Date
claude-botandClaude Opus 5.5 bbeaf622ed fix(wordpress): move to PHP 8.5.12 for PHP2Shell
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
CVE-2026-109618 and CVE-2026-109619 (BSI BITS-H 2026-297981-1132) allow
unauthenticated RCE on every PHP before 8.5.12/8.4.27/8.3.36/8.2.35;
mod_php is the highest-risk variant. The running pod reports PHP 8.3.35
and www.renner.uno serves / to the internet without the Authentik gate.

Switch to the php8.5-apache variant of the same WordPress release,
pinned to the index digest rebuilt on 2026-10-22 after the PHP fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 11:57:02 +02:00
k.renner 35a387a3c1 Merge pull request 'chore: follow homelab to konairius/homelab (merge inside Cut-over 1 only)' (#1372) from claude/homelab-konairius into master
ci/woodpecker/push/ci-claude-refresh Pipeline was successful
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: #1372
2026-10-19 08:52:21 +00:00
k.renner 093857c353 Merge pull request 'feat(rotation): sync claude_code_oauth_token and review_bot_token into konairius' (#1370) from claude/konairius-org-secrets into master
Reviewed-on: k.renner/homelab#1370
2026-10-19 08:50:17 +00:00
claude-botandClaude Opus 5.5 7f29ebb878 chore: follow homelab to konairius/homelab
review-bot reviewed: comment
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/review-bot-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Flux source URL, flux-git rotator verify repo, review-bot REPOS, pipeline API,
clone and push URLs, the heating-can source label and the review-bot and
kuma-sync Go module paths. Merge right after the transfer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 10:43:23 +02:00
claude-botandClaude Opus 5.5 b07167ae1a feat(rotation): sync claude_code_oauth_token and review_bot_token into konairius
review-bot reviewed: comment
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Homelab moves to konairius/homelab; after the Woodpecker move its pipelines
resolve org secrets in konairius. The k.renner containers stay until the move
is done.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 10:42:20 +02:00
k.renner df0b344f7f Merge pull request 'fix(hermes): push the Hermes image as registry-bot' (#1369) from fix/hermes-image-registry-bot into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline failed
Reviewed-on: k.renner/homelab#1369
2026-10-19 08:31:56 +00:00
hermes-bot 46b7d458b3 fix(hermes): push the Hermes image as registry-bot
review-bot reviewed: request changes
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
registry-bot is the konairius registry identity: write:package PAT,
konairius org secret registry_bot_token, rotated by
openbao/rotation/registry-bot.yaml. That rotation syncs the secret for
the push, tag, release and manual events, so the manual trigger is
`event: manual` instead of a deployment target.

registry_bot_token is a konairius org secret. Woodpecker resolves org
secrets through the repo's org link, so this pipeline gets it only once
homelab has moved to konairius/homelab
(docs/superpowers/plans/2026-10-18-homelab-konairius-main.md, cut-over 1).
2026-10-19 08:29:53 +00:00
security-bot 2d04575c51 chore(image-automation): bump gitea.renner.uno/konairius/hermes:build.1792397861
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-10-19 08:20:50 +00:00
k.renner 97310db78c Merge pull request 'feat(hermes): run the in-house Hermes image' (#1367) from feat/hermes-use-custom-image into master
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/push/hermes-image Pipeline was successful
Reviewed-on: k.renner/homelab#1367
2026-10-19 08:16:53 +00:00
k.renner 085f969f63 Merge pull request 'feat(hermes): build a Hermes image with the agent's CLI tools' (#1366) from feat/hermes-image into master
ci/woodpecker/push/build-ci-images Pipeline is pending
ci/woodpecker/push/hermes-image Pipeline is pending
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#1366
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-10-19 08:12:26 +00:00
hermes-bot d00ee70b23 fix(hermes): run the Hermes image from the konairius org
review-bot reviewed: request changes
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
2026-10-19 08:10:18 +00:00
hermes-bot 8ea8318c6e Merge branch 'feat/hermes-image' into feat/hermes-use-custom-image 2026-10-19 08:10:13 +00:00
hermes-bot fbc1aaef21 fix(hermes): publish the Hermes image under the konairius org
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: approve
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
2026-10-19 08:10:11 +00:00
hermes-bot be9898f7b0 chore(renovate): leave gitea_admin/hermes to Flux image automation
review-bot reviewed: request changes
ci/woodpecker/pr/ci-scripts-test Pipeline was canceled
ci/woodpecker/pr/ci-manifests-test Pipeline was canceled
2026-10-19 08:06:10 +00:00
hermes-bot 3c5c04d4a5 fix(hermes): let Renovate keep the Hermes image current
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: comment
ci/woodpecker/pr/ci-manifests-test Pipeline was canceled
Drops the daily cron and the upstream-polling decide step. Every version
in ci/images/hermes/Containerfile is now an image reference (FROM for
nousresearch/hermes-agent, COPY --from for talosctl, kubectl, flux, bao
and tea), which Renovate's enabled dockerfile manager updates and
digest-pins. A merged Renovate PR pushes to ci/images/hermes/**, the
pipeline builds build.<unix-ts>, and Flux image automation rolls it out.
No checksums to maintain: the binaries come from digest-pinned images.

renovate.json: the hermes Containerfile gets a PR of its own instead of
the "CI non-major" group (that group is for changes that never reach the
cluster), and nousresearch/hermes-agent is held to v0.x because the
retired date-versioned v2026.x tags sort above it.

Verified: each COPY --from path exists in the pinned image and the
binary runs on the hermes-agent v0.22.2 base. renovate-config-validator
37.440.7 reports only the two pre-existing errors on master
(flux/kubernetes.managerFilePatterns).
2026-10-19 08:06:09 +00:00
hermes-bot 3ed7741c68 docs(hermes): tea is in the image now, still not logged in
review-bot reviewed: request changes
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was canceled
2026-10-19 07:58:27 +00:00
hermes-bot f73cc54705 feat(hermes): build a Hermes image with the agent's CLI tools
review-bot reviewed: comment
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
nousresearch/hermes-agent ships no talosctl, kubectl, flux, bao, tea or
jq. The kubectl init container dropped its binary in /opt/data/.local/bin,
which the agent's terminal never has on PATH: Hermes snapshots the env
from a bash login shell, and Debian's /etc/profile resets PATH for
non-root users. /usr/local/bin survives that reset.

Upstream's documented route for always-present tools is a derived image
(docs/user-guide/docker, "Adding tools"). ci/images/hermes builds FROM
the upstream release and adds the binaries, versions matched to the
cluster (Talos v1.13.10, Kubernetes v1.36.3, Flux v2.9.5, OpenBao 2.7.1)
and checksummed against upstream release checksums.

.woodpecker/hermes-image.yml rebuilds daily when upstream publishes a
new release (compared against the uno.renner.hermes.version label on
:latest), and on any push to the Containerfile. Tags are
build.<unix-ts>, the scheme Flux image automation already tracks for
wiki and shelly-manager.

Owner step after merge: register the cron
  woodpecker repo cron add --repo k.renner/homelab \
    --name hermes-image --branch master --schedule '@daily'

The Deployment switch follows in a separate PR once the first build
exists, so the Recreate rollout never points at a missing tag.
2026-10-19 07:58:27 +00:00
hermes-bot c8849b8d9c feat(hermes): run the in-house Hermes image
Switches apps/hermes to gitea.renner.uno/gitea_admin/hermes, which
carries talosctl, kubectl, flux, bao, tea and jq in /usr/local/bin. The
image-automation policy now tracks its build.<unix-ts> tags instead of
upstream nousresearch/hermes-agent; .woodpecker/hermes-image.yml follows
upstream releases.

Drops the kubectl init container and its emptyDir: the binary sat in
/opt/data/.local/bin, which the agent's terminal does not have on PATH.
2026-10-19 07:58:27 +00:00
k.renner 9d0707c1aa Merge pull request 'feat(kuma-sync): Uptime Kuma monitors replacing UptimeRobot' (#1365) from feat/kuma-task5-monitors into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#1365
2026-10-19 07:57:25 +00:00
claude-botandClaude Opus 5.5 74db40710e feat(kuma-sync): define Uptime Kuma monitors replacing UptimeRobot
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: comment
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
One group per own service with application-level checks (json-query on
health/API endpoints where ungated, keyword otherwise) and a Third-party
group with plain checks. Endpoints verified from the Kuma VPS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 09:54:08 +02:00
k.renner 368dc24add Merge pull request 'feat(hermes): push and open PRs on Gitea as hermes-bot' (#1364) from feat/hermes-gitea into master
Reviewed-on: k.renner/homelab#1364
2026-10-19 07:49:07 +00:00
claude-botandClaude Opus 5.5 dfb2f71c85 feat(hermes): push and open PRs on Gitea as hermes-bot
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot skipped: closed
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
The owner placed hermes-bot's API token at OpenBao apps/hermes/gitea
(api-token). ExternalSecret hermes-gitea mounts it as a file at
/var/run/secrets/gitea/api-token.

git is configured through GIT_CONFIG_* env on the container, so nothing
lives on the PVC: a credential helper for https://gitea.renner.uno that
answers with hermes-bot and the token read from the file, plus the
commit identity hermes-bot <hermes-bot@konstantin-renner.de>. Hermes'
subprocess env scrub only strips its own provider/messaging secrets, so
GIT_* reaches the terminal tool.

SOUL.md drops "no write access" and tells the agent how to use git and
the REST API (no tea in the image) without printing the token; the
existing feature-branch/PR/no-self-merge rules apply.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 09:48:05 +02:00
k.renner 6b78f4cb5b Merge pull request 'fix(hermes): put the generated ConfigMaps in the hermes namespace' (#1361) from fix/hermes-configmap-namespace into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#1361
2026-10-19 07:06:08 +00:00
claude-botandClaude Opus 5.5 d8d356779d fix(hermes): put the generated ConfigMaps in the hermes namespace
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: comment
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
apps/hermes sets namespaces per resource, not in the kustomization, so
configMapGenerator emitted hermes-soul and hermes-skills without one and
the Flux apply failed ("namespace not specified"). The earlier
server-side dry-run masked it by defaulting to the current namespace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 09:00:17 +02:00
security-bot 2b1016bd4a chore(image-automation): bump gitea.renner.uno/gitea_admin/kuma-sync:2026-10-19-0646 nousresearch/hermes-agent:v0.22.2
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-10-19 06:56:39 +00:00
k.renner 58695ceb9a Merge pull request 'feat(hermes): give the agent the homelab house rules and the wiki' (#1360) from feat/hermes-homelab-context into master
Reviewed-on: k.renner/homelab#1360
2026-10-19 06:55:15 +00:00
k.renner 055cc6a6e3 Merge pull request 'feat(monitoring): watch off-site Uptime Kuma from Prometheus' (#1357) from feat/kuma-task4-cluster into master
Reviewed-on: k.renner/homelab#1357
2026-10-19 06:47:18 +00:00
k.renner 67372c673f Merge pull request 'fix(uptime-kuma): mount the bootstrap script directory, not the file' (#1359) from fix/uptime-kuma-script-mount into master
ci/woodpecker/push/deploy-uptime-kuma Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#1359
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-10-19 06:44:07 +00:00
claude-botandClaude Opus 5.5 ab1f004b48 fix(uptime-kuma): mount the bootstrap script directory, not the file
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: approve
A single-file bind mount pins the inode present at container start.
ansible.builtin.copy replaces the file by rename, so the running
container kept executing the previous kuma-setup.js: the #1358 deploy
ran the old admin-only script (host md5 8d77..., container 55a2...) and
created no notification, status page or metrics key.

The script now lives in {{ uptime_kuma_dir }}/setup/, mounted read-only
at /app/setup; the compose change recreates the container once. The old
file path is removed.

Verified on a local 2.5.6 container: a file replaced by rename in the
mounted directory is visible inside, and the script run from
/app/setup resolves socket.io-client and converges.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 08:42:10 +02:00
k.renner afbc21edc3 Merge pull request 'feat(uptime-kuma): bootstrap settings, email notification, status page and metrics key' (#1358) from feat/kuma-task4-bootstrap into master
ci/woodpecker/push/deploy-uptime-kuma Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
Reviewed-on: k.renner/homelab#1358
2026-10-19 06:39:51 +00:00
k.renner 91d940499e Merge pull request 'feat(kuma-sync): attach Kuma's default notifications to managed monitors' (#1356) from feat/kuma-sync-default-notifications into master
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: k.renner/homelab#1356
2026-10-19 06:38:58 +00:00
k.renner 4d4ea98b7c Merge pull request 'fix(hermes): keep image automation off the retired date-versioned tags' (#1355) from fix/hermes-image-policy into master
Reviewed-on: k.renner/homelab#1355
2026-10-19 06:38:40 +00:00
claude-botandClaude Opus 5.5 7e1b46108a fix(uptime-kuma): fail when a list event is missing after login
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: comment
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Kuma 2.5.6 afterLogin (server/server.js:1839-1848) sends the
notification and API-key lists inside Promise.allSettled and acks the
login regardless. A missing notificationList was read as empty and
would create a second default "email" notification (double mails);
a missing apiKeyList would add a key without removing the old one.
The script now fails the run instead.

Verified against a local 2.5.6: fresh run creates everything, second
run reports no changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 08:34:24 +02:00
security-bot 4fb3a4ba40 chore(image-automation): bump gitea.renner.uno/gitea_admin/kuma-sync:2026-10-19-0615
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-10-19 06:26:39 +00:00
claude-botandClaude Opus 5.5 72927293e2 test(monitoring): promtool suite for Uptime Kuma rules, terse comments
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot skipped: draft
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 08:26:12 +02:00
claude-botandClaude Opus 5.5 67aadbc0f9 feat(uptime-kuma): bootstrap settings, email notification, status page and metrics key
kuma-setup.js now logs in after the admin setup and converges on every run:
- settings: Trust Proxy on, Primary Base URL https://kuma.renner.uno.
  Read-modify-write; unset keys get the same defaults the Kuma settings page
  applies before saving (a raw getSettings payload has nscd unset, which makes
  setSettings stop nscd).
- notification "email" (SMTP, default enabled, applied to existing monitors):
  created or updated in place by name, never duplicated. Credentials from
  OpenBao homelab/infrastructure/uptime-kuma/smtp; recipient is the
  Alertmanager receiver admin@renner.uno; 465 means implicit TLS, else STARTTLS.
- status page "renner" / "Renner" with custom domain uptime.renner.uno. Only
  title and domain are enforced; saveStatusPage replaces all groups, so the
  current publicGroupList from /api/status-page/<slug> is sent back unchanged.
  Steady state does not call saveStatusPage at all.
- metrics API key "prometheus": Ansible checks the key stored at
  homelab/infrastructure/uptime-kuma-metrics against /metrics; if it is
  missing or rejected the script deletes all "prometheus" keys, creates a new
  one and hands it over via a 0600 file in the data dir, which Ansible writes
  to OpenBao (vault_kv2_write, community.hashi_vault 7.1.0 in the CI image)
  and then removes. The key never reaches stdout/stderr or the Ansible log.

The script is restructured into small async steps with per-emit timeouts.
The uptime-kuma-vps policy gains read on the smtp path and
read/create/update on the metrics path (must be applied by hand).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 08:25:43 +02:00
claude-botandClaude Opus 5.5 bb992833c2 feat(kuma-sync): attach Kuma's default notifications to managed monitors
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: comment
Kuma 2.5.6 applies "default enabled" notifications only in the web UI
(src/pages/EditMonitor.vue:4128-4132); the server's add handler stores
exactly the notificationIDList the client sends (server/server.js:748,791).
Monitors created by kuma-sync therefore had no notifications.

The notification set is now a managed field: exactly the IDs of all
notifications with isDefault set, sorted, applied on create and update
(group monitors included). Monitor.NotificationIDs is part of
Normalized(), so a changed default set or a hand-made change triggers an
update on the next resync and an unchanged one does not.

Client.DefaultNotifications reads the library's cached notificationList
(go-uptime-kuma-client v0.6.2 notification.go:17). Kuma has no command to
request the list; it pushes it at login and to every session of the user
after each change (server/client.js:31, server/server.js:1573,1594,1836).
A failed read is KumaUnavailable with no create or update.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 08:23:44 +02:00
claude-bot fd2f3b7be4 fix(hermes): keep image automation off the retired date-versioned tags
review-bot reviewed: comment
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
nousresearch/hermes-agent still carries v2026.4.3..v2026.9.24 from the
old date-based scheme. As semver they sort above 0.x, so '>=0.22.2'
picked v2026.9.24 (2026-09-24) and rolled the pod back from v0.22.2.
Cap the range below 1.0.0 and pin the manifests back to v0.22.2.
2026-10-19 08:18:03 +02:00
claude-botandClaude Opus 5.5 a35bc6b91d feat(monitoring): watch off-site Uptime Kuma from Prometheus
Add a blackbox-http probe of kuma.renner.uno with a dedicated critical
UptimeKumaUnreachable rule (excluded from BlackboxProbeFailing), scrape
Kuma /metrics with the API key from OpenBao, and alert on sustained slow
monitor response times. No egress policy exists in the monitoring
namespace, so no CNP change is needed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 08:17:30 +02:00
71 changed files with 1546 additions and 209 deletions
+4 -4
View File
@@ -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
@@ -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() {
+5 -5
View File
@@ -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"
+1 -1
View File
@@ -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:
+29
View File
@@ -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
+6 -6
View File
@@ -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
@@ -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
+3 -3
View File
@@ -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
+3 -3
View File
@@ -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
@@ -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}'
+3 -3
View File
@@ -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,
@@ -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() {
+2
View File
@@ -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
+10 -1
View File
@@ -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
+207 -28
View File
@@ -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)
);
+129 -4
View File
@@ -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 }}`)"
+2 -2
View File
@@ -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)."
+1 -1
View File
@@ -63,7 +63,7 @@ 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}"
+20
View File
@@ -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
+2 -2
View File
@@ -10,8 +10,8 @@ import (
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/log/zap"
"gitea.renner.uno/k.renner/homelab/kuma-sync/internal/app"
"gitea.renner.uno/k.renner/homelab/kuma-sync/internal/kumaclient"
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/app"
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/kumaclient"
)
func main() {
+1 -1
View File
@@ -1,4 +1,4 @@
module gitea.renner.uno/k.renner/homelab/kuma-sync
module gitea.renner.uno/konairius/homelab/kuma-sync
go 1.27
+3 -3
View File
@@ -19,9 +19,9 @@ import (
"sigs.k8s.io/controller-runtime/pkg/manager"
metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
v1 "gitea.renner.uno/k.renner/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/k.renner/homelab/kuma-sync/internal/controller"
"gitea.renner.uno/k.renner/homelab/kuma-sync/internal/kumaclient"
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.
@@ -7,12 +7,14 @@ import (
"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"
@@ -23,9 +25,9 @@ import (
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/envtest"
v1 "gitea.renner.uno/k.renner/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/k.renner/homelab/kuma-sync/internal/app"
"gitea.renner.uno/k.renner/homelab/kuma-sync/internal/kumaclient"
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 (
@@ -395,6 +397,60 @@ func TestE2EDriftRepaired(t *testing.T) {
})
}
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) {
@@ -16,8 +16,8 @@ import (
"sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/manager"
v1 "gitea.renner.uno/k.renner/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/k.renner/homelab/kuma-sync/internal/kumaclient"
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
@@ -15,8 +15,8 @@ import (
"sigs.k8s.io/controller-runtime/pkg/client/fake"
"sigs.k8s.io/controller-runtime/pkg/client/interceptor"
v1 "gitea.renner.uno/k.renner/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/k.renner/homelab/kuma-sync/internal/kumaclient"
v1 "gitea.renner.uno/konairius/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/kumaclient"
)
type gcEnv struct {
@@ -20,8 +20,8 @@ import (
"sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/predicate"
v1 "gitea.renner.uno/k.renner/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/k.renner/homelab/kuma-sync/internal/kumaclient"
v1 "gitea.renner.uno/konairius/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/kumaclient"
)
const (
@@ -129,6 +129,11 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
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 {
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"slices"
"testing"
"time"
@@ -16,8 +17,8 @@ import (
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
v1 "gitea.renner.uno/k.renner/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/k.renner/homelab/kuma-sync/internal/kumaclient"
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.
@@ -310,6 +311,79 @@ func TestReconcileListErrorNoCreate(t *testing.T) {
}
}
func TestReconcileCreateGetsDefaultNotifications(t *testing.T) {
group := &v1.KumaMonitor{
ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "grp"},
Spec: v1.KumaMonitorSpec{Type: "group", DisplayName: "grp", Interval: 60, RetryInterval: 60},
}
e := newEnv(t, httpMonitor("a"), group)
e.kuma.SetDefaultNotifications(7, 3)
for _, name := range []string{"a", "grp"} {
if _, err := e.reconcile(t, name); err != nil {
t.Fatal(err)
}
got := e.kuma.Get(e.get(t, name).Status.MonitorID)
if got == nil || !slices.Equal(got.NotificationIDs, []int64{3, 7}) {
t.Errorf("%s: kuma monitor = %+v, want notifications [3 7]", name, got)
}
}
}
func TestReconcileRevertsNotificationDrift(t *testing.T) {
km := httpMonitor("a")
e := newEnv(t, km)
e.kuma.SetDefaultNotifications(1, 2)
drifted := Desired(km)
drifted.NotificationIDs = []int64{2, 9}
id, _ := e.kuma.Create(t.Context(), drifted)
if _, err := e.reconcile(t, "a"); err != nil {
t.Fatal(err)
}
if e.cnt.updates != 1 || e.cnt.creates != 0 {
t.Fatalf("updates=%d creates=%d", e.cnt.updates, e.cnt.creates)
}
if got := e.kuma.Get(id).NotificationIDs; !slices.Equal(got, []int64{1, 2}) {
t.Errorf("notifications = %v, want [1 2]", got)
}
}
func TestReconcileUnchangedNotificationsNoUpdate(t *testing.T) {
km := httpMonitor("a")
e := newEnv(t, km)
e.kuma.SetDefaultNotifications(4, 2)
existing := Desired(km)
existing.NotificationIDs = []int64{4, 2}
e.kuma.Create(t.Context(), existing)
for range 2 {
if _, err := e.reconcile(t, "a"); err != nil {
t.Fatal(err)
}
}
if e.cnt.updates != 0 || e.cnt.creates != 0 {
t.Fatalf("updates=%d creates=%d, want 0 and 0", e.cnt.updates, e.cnt.creates)
}
}
func TestReconcileNotificationListErrorNoWrite(t *testing.T) {
adopt := httpMonitor("b")
e := newEnv(t, httpMonitor("a"), adopt)
drifted := Desired(adopt)
drifted.Interval = 30
e.kuma.Create(t.Context(), drifted)
e.kuma.SetNotificationsErr(errors.New("boom"))
for _, name := range []string{"a", "b"} {
if _, err := e.reconcile(t, name); err == nil {
t.Fatalf("%s: expected error", name)
}
if c := ready(t, e.get(t, name)); c.Status != metav1.ConditionFalse || c.Reason != ReasonKumaUnavailable {
t.Errorf("%s: Ready = %+v", name, c)
}
}
if e.cnt.creates != 0 || e.cnt.updates != 0 {
t.Errorf("creates=%d updates=%d", e.cnt.creates, e.cnt.updates)
}
}
func TestReconcileOwnerTagMissing(t *testing.T) {
e := newEnv(t, httpMonitor("a"))
e.kuma.SetErrs(kumaclient.ErrOwnerTagMissing, nil, nil, nil)
@@ -4,8 +4,8 @@ import (
"maps"
"slices"
v1 "gitea.renner.uno/k.renner/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/k.renner/homelab/kuma-sync/internal/kumaclient"
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.
@@ -6,8 +6,8 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
v1 "gitea.renner.uno/k.renner/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/k.renner/homelab/kuma-sync/internal/kumaclient"
v1 "gitea.renner.uno/konairius/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/kumaclient"
)
func TestMappingAllTypes(t *testing.T) {
@@ -13,6 +13,7 @@ import (
kuma "github.com/breml/go-uptime-kuma-client"
"github.com/breml/go-uptime-kuma-client/monitor"
"github.com/breml/go-uptime-kuma-client/notification"
"github.com/breml/go-uptime-kuma-client/tag"
)
@@ -373,8 +374,8 @@ func (b *Breml) update(ctx context.Context, m Monitor) error {
return fmt.Errorf("update %d: monitor carries owner key %q, not %q: %w", m.ID, key, m.OwnerKey, ErrNotOwned)
}
// Start from the full current object, also across a type change, so
// fields we do not manage (notifications, proxy, resend interval, upside
// down, auth) survive the overwrite.
// fields we do not manage (proxy, resend interval, upside down, auth)
// survive the overwrite.
wire, err := toWire(m, &cur)
if err != nil {
return err
@@ -404,18 +405,42 @@ func (b *Breml) Delete(ctx context.Context, id int64) error {
return err
}
// DefaultNotifications reads the notification list cached by the session.
// Kuma has no command to request it: it pushes the list at login and to every
// session of the user after each change.
func (b *Breml) DefaultNotifications(ctx context.Context) ([]int64, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
b.mu.Lock()
defer b.mu.Unlock()
return defaultIDs(b.c.GetNotifications(ctx)), nil
}
func defaultIDs(list []notification.Base) []int64 {
var ids []int64
for _, n := range list {
if n.IsDefault {
ids = append(ids, n.ID)
}
}
slices.Sort(ids)
return ids
}
// toDomain converts a monitor from the Kuma monitor list.
func toDomain(base monitor.Base, ownerKey string) (Monitor, error) {
m := Monitor{
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),
OwnerKey: ownerKey,
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
@@ -565,6 +590,7 @@ func writeBase(b *monitor.Base, m Monitor) {
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.
@@ -287,12 +287,10 @@ func TestUpdateKeepsUnchangedFields(t *testing.T) {
}
}
// setUnmanaged sets state through the raw library that Monitor does not model.
func setUnmanaged(t *testing.T, b *Breml, id int64) (notificationID int64) {
func createWebhook(t *testing.T, b *Breml, name string, isDefault bool) int64 {
t.Helper()
ctx := context.Background()
nid, err := b.c.CreateNotification(ctx, &notification.Webhook{
Base: notification.Base{Name: "n", IsActive: true},
nid, err := b.c.CreateNotification(context.Background(), &notification.Webhook{
Base: notification.Base{Name: name, IsActive: true, IsDefault: isDefault},
WebhookDetails: notification.WebhookDetails{
WebhookURL: "http://example.com/hook", WebhookContentType: "application/json",
},
@@ -300,6 +298,15 @@ func setUnmanaged(t *testing.T, b *Breml, id int64) (notificationID int64) {
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)
@@ -313,16 +320,15 @@ func setUnmanaged(t *testing.T, b *Breml, id int64) (notificationID int64) {
return nid
}
func assertUnmanaged(t *testing.T, c *Breml, id, nid int64, when string) {
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 || !reflect.DeepEqual(base.NotificationIDs, []int64{nid}) {
t.Errorf("%s: unmanaged state lost: upsideDown=%v resend=%d notifications=%v",
when, base.UpsideDown, base.ResendInterval, base.NotificationIDs)
if !base.UpsideDown || base.ResendInterval != 7 {
t.Errorf("%s: unmanaged state lost: upsideDown=%v resend=%d", when, base.UpsideDown, base.ResendInterval)
}
}
@@ -362,14 +368,15 @@ func TestUpdatePreservesUnmanagedAndClearsManaged(t *testing.T) {
t.Fatal(err)
}
m := byID(got)[id]
if m.Parent != nil || m.Headers != nil || m.Keyword != "" || m.Interval != 90 {
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, nid, "after plain update")
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)
}
@@ -377,10 +384,55 @@ func TestUpdatePreservesUnmanagedAndClearsManaged(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if m := byID(got)[id]; m.Type != "http" {
t.Errorf("type = %q, want http", m.Type)
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())
}
assertUnmanaged(t, fresh, id, nid, "after type change")
}
func TestRestartAdoptsByTag(t *testing.T) {
@@ -63,6 +63,7 @@ func Conflicts(err error) (conflicts []*ConflictError, only bool) {
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
@@ -80,12 +81,18 @@ func isHTTPFamily(typ string) bool {
// 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. It is idempotent.
// 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
}
@@ -143,6 +150,9 @@ type Monitor struct {
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
}
@@ -170,4 +180,7 @@ type Client interface {
// Delete removes an owned monitor. It wraps ErrNotFound for an unknown ID
// and ErrNotOwned for a monitor without the kuma-sync tag.
Delete(ctx context.Context, id int64) error
// DefaultNotifications returns the IDs of all notifications marked
// "default enabled" in Kuma, sorted. An empty result is valid.
DefaultNotifications(ctx context.Context) ([]int64, error)
}
@@ -3,6 +3,7 @@ package kumaclient
import (
"context"
"fmt"
"slices"
"sort"
"sync"
)
@@ -20,6 +21,10 @@ type Fake struct {
UpdateErr error
DeleteErr error
ProbeErr error
// NotificationsErr is returned by DefaultNotifications.
NotificationsErr error
// Defaults are the IDs DefaultNotifications returns.
Defaults []int64
// ListPartial makes ListOwned return the stored monitors together with
// ListErr, as the real client does for owner conflicts.
ListPartial bool
@@ -90,6 +95,32 @@ func (f *Fake) Probe(_ context.Context, id int64) (bool, string, error) {
return ok, m.OwnerKey, nil
}
// SetDefaultNotifications sets the IDs DefaultNotifications returns.
func (f *Fake) SetDefaultNotifications(ids ...int64) {
f.mu.Lock()
defer f.mu.Unlock()
f.Defaults = slices.Clone(ids)
}
// SetNotificationsErr makes DefaultNotifications return err.
func (f *Fake) SetNotificationsErr(err error) {
f.mu.Lock()
defer f.mu.Unlock()
f.NotificationsErr = err
}
// DefaultNotifications returns Defaults, sorted.
func (f *Fake) DefaultNotifications(_ context.Context) ([]int64, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.NotificationsErr != nil {
return nil, f.NotificationsErr
}
ids := slices.Clone(f.Defaults)
slices.Sort(ids)
return ids, nil
}
func (f *Fake) allocID() int64 {
id := f.nextID
f.nextID++
@@ -118,6 +118,21 @@ func TestFakeInjectedErrors(t *testing.T) {
}
}
func TestFakeDefaultNotifications(t *testing.T) {
f := NewFake()
if ids, err := f.DefaultNotifications(context.Background()); err != nil || ids != nil {
t.Fatalf("empty: %v %v", ids, err)
}
f.SetDefaultNotifications(4, 1)
if ids, err := f.DefaultNotifications(context.Background()); err != nil || len(ids) != 2 || ids[0] != 1 || ids[1] != 4 {
t.Fatalf("set: %v %v", ids, err)
}
f.SetNotificationsErr(errors.New("boom"))
if _, err := f.DefaultNotifications(context.Background()); err == nil {
t.Error("expected injected error")
}
}
func TestFakeUpdateRejectsOtherOwnerKey(t *testing.T) {
f := NewFake()
id, _ := f.Create(context.Background(), Monitor{Name: "a", OwnerKey: "ns/a"})
@@ -7,6 +7,7 @@ import (
"testing"
kuma "github.com/breml/go-uptime-kuma-client"
"github.com/breml/go-uptime-kuma-client/notification"
"github.com/breml/go-uptime-kuma-client/tag"
)
@@ -167,3 +168,29 @@ func TestFakeStoresNormalized(t *testing.T) {
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)
}
}
+3 -1
View File
@@ -9,7 +9,9 @@ When you use a pronoun for someone whose pronouns haven't been stated, use they/
- The homelab wiki through the `homelab-wiki` skill (read, and deposit facts).
- The web (HTTPS) and LAN hostnames under `renner.uno` through Traefik.
All state of record lives in git (`gitea.renner.uno`): infrastructure in the `homelab` repo (Kubernetes/Flux, Ansible, OpenTofu, ESPHome), Home Assistant config in the `homeassistant` repo. You have no write access to either; when a change is needed, describe it (files, diff) for the owner or Claude Code to land as a PR.
- 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.
@@ -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
+24 -23
View File
@@ -49,7 +49,7 @@ spec:
# connects from the pod network. No env equivalent exists, so it is
# set in config.yaml on every start (idempotent).
- name: config
image: nousresearch/hermes-agent:v2026.9.24 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1792397861 # {"$imagepolicy": "flux-system:hermes"}
command:
- /opt/hermes/.venv/bin/hermes
- config
@@ -73,7 +73,7 @@ spec:
mountPath: /opt/data
# Skills from git (ConfigMap hermes-skills) beside the PVC's skills/.
- name: skills-config
image: nousresearch/hermes-agent:v2026.9.24 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1792397861 # {"$imagepolicy": "flux-system:hermes"}
command:
- /opt/hermes/.venv/bin/hermes
- config
@@ -95,25 +95,9 @@ spec:
volumeMounts:
- name: data
mountPath: /opt/data
# kubectl for the agent. /opt/data/.local/bin is on the image's PATH;
# an emptyDir there keeps the binary out of the PVC and current with
# this pin.
- name: kubectl
image: docker.io/alpine/kubectl:1.37.1@sha256:b568e8beb6a79aee269c142343078a278b85e7710f1c0e7d8d6b21d1eb566a48
command: ["cp", "/usr/local/bin/kubectl", "/tools/kubectl"]
securityContext:
runAsUser: 10000
runAsGroup: 10000
runAsNonRoot: true
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
volumeMounts:
- name: tools
mountPath: /tools
containers:
- name: hermes
image: nousresearch/hermes-agent:v2026.9.24 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1792397861 # {"$imagepolicy": "flux-system:hermes"}
args: ["gateway", "run"]
env:
- name: HERMES_DASHBOARD
@@ -130,6 +114,21 @@ spec:
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
@@ -166,8 +165,6 @@ spec:
volumeMounts:
- name: data
mountPath: /opt/data
- name: tools
mountPath: /opt/data/.local/bin
# Identity and house rules from git (SOUL.md beside this file).
- name: soul
mountPath: /opt/data/SOUL.md
@@ -179,12 +176,13 @@ spec:
- 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: tools
emptyDir: {}
- name: soul
configMap:
name: hermes-soul
@@ -200,6 +198,9 @@ spec:
secret:
secretName: hermes-wiki
optional: true
- name: gitea-token
secret:
secretName: hermes-gitea
---
apiVersion: v1
kind: Service
@@ -4,13 +4,16 @@ 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
+1 -2
View File
@@ -1,4 +1,3 @@
---
# kuma-sync: KumaMonitor -> Uptime Kuma monitors on kuma.renner.uno (ci/images/kuma-sync).
apiVersion: apps/v1
kind: Deployment
@@ -31,7 +30,7 @@ spec:
type: RuntimeDefault
containers:
- name: controller
image: gitea.renner.uno/gitea_admin/kuma-sync:2026-10-19-0545 # {"$imagepolicy": "flux-system:kuma-sync"}
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
@@ -5,3 +5,4 @@ resources:
- 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
@@ -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:
@@ -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
+1 -1
View File
@@ -35,7 +35,7 @@ spec:
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
+1 -1
View File
@@ -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
@@ -4,10 +4,10 @@ metadata:
name: hermes
namespace: flux-system
spec:
image: nousresearch/hermes-agent
interval: 1h
image: gitea.renner.uno/konairius/hermes
interval: 10m
---
# v-prefix tags (v0.22.2). The filter skips rc.*, -desktop and per-arch tags.
# .woodpecker/hermes-image.yml tags every build `build.<unix-ts>`; latest push wins.
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImagePolicy
metadata:
@@ -17,8 +17,8 @@ spec:
imageRepositoryRef:
name: hermes
filterTags:
pattern: '^v(?P<ver>\d+\.\d+\.\d+)$'
extract: '$ver'
pattern: '^build\.(?P<ts>\d+)$'
extract: '$ts'
policy:
semver:
range: ">=0.22.2"
numerical:
order: asc
@@ -1,10 +1,18 @@
# OpenBao policy: uptime-kuma-vps
# Read-only on the Uptime Kuma admin credentials; self-renewal for the CI token. NOT applied by Flux.
# Read on the Uptime Kuma admin and SMTP credentials; read/write on the metrics API key; self-renewal for the CI token. NOT applied by Flux.
path "homelab/data/infrastructure/uptime-kuma" {
capabilities = ["read"]
}
path "homelab/data/infrastructure/uptime-kuma/smtp" {
capabilities = ["read"]
}
path "homelab/data/infrastructure/uptime-kuma-metrics" {
capabilities = ["read", "create", "update"]
}
path "auth/token/renew-self" {
capabilities = ["update"]
}
@@ -112,3 +112,36 @@ spec:
memory: 16Mi
limits:
memory: 48Mi
- name: sync-woodpecker-konairius
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-07-30
args: ["woodpecker-secret"]
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
env:
- name: BAO_ADDR
value: http://openbao.openbao.svc.cluster.local:8200
- name: BAO_ROLE
value: rotator-claude-oauth
- name: BOOTSTRAP_PATH
value: homelab/infrastructure/secret-rotators-bootstrap/woodpecker-bot
- name: BAO_PATH
value: homelab/infrastructure/claude
- name: BAO_FIELD
value: setup-token
- name: WOODPECKER_URL
value: http://woodpecker.woodpecker.svc.cluster.local:8000
- name: WOODPECKER_ORG
value: konairius
- name: WOODPECKER_SECRET_NAME
value: claude_code_oauth_token
- name: WOODPECKER_EVENTS
value: cron,deployment,manual,push,release,tag,pull_request
resources:
requests:
cpu: 25m
memory: 16Mi
limits:
memory: 48Mi
@@ -78,10 +78,10 @@ spec:
- name: GITEA_URL
value: http://gitea.gitea.svc.cluster.local:3000
- name: GITEA_VERIFY_REPO
value: k.renner/homelab
value: konairius/homelab
- name: GITEA_SCOPES
value: write:repository
# Load-bearing: k.renner/homelab is public, so the pull check
# Load-bearing: konairius/homelab is public, so the pull check
# passes for ANY valid token — push (git-receive-pack, needs
# write) is the only real verification. Pinned explicitly so the
# safety property is visible here, not just a binary default.
@@ -197,3 +197,36 @@ spec:
memory: 16Mi
limits:
memory: 48Mi
- name: sync-woodpecker-konairius
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-07-30
args: ["woodpecker-secret"]
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
env:
- name: BAO_ADDR
value: http://openbao.openbao.svc.cluster.local:8200
- name: BAO_ROLE
value: rotator-review-bot
- name: BOOTSTRAP_PATH
value: homelab/infrastructure/secret-rotators-bootstrap/woodpecker-bot
- name: BAO_PATH
value: homelab/infrastructure/review-bot
- name: BAO_FIELD
value: token
- name: WOODPECKER_URL
value: http://woodpecker.woodpecker.svc.cluster.local:8000
- name: WOODPECKER_ORG
value: konairius
- name: WOODPECKER_SECRET_NAME
value: review_bot_token
- name: WOODPECKER_EVENTS
value: cron,manual,pull_request
resources:
requests:
cpu: 25m
memory: 16Mi
limits:
memory: 48Mi
+19
View File
@@ -45,6 +45,7 @@
"ghcr.io/usetrmnl/terminus",
"ghcr.io/usetrmnl/trmnl-ha-amd64",
"gitea.renner.uno/k.renner/shelly-manager",
"gitea.renner.uno/konairius/hermes",
"gitea.renner.uno/konairius/vsftpd",
"gitea.renner.uno/konairius/wiki",
"gotenberg/gotenberg",
@@ -215,6 +216,24 @@
"lscr.io/linuxserver/beets"
],
"versioning": "regex:^(?<major>\\d+)\\.(?<minor>\\d+)\\.(?<patch>\\d+)-ls(?<build>\\d+)$"
},
{
"description": "Hermes image (ci/images/hermes): its merge rolls out to the cluster via Flux image automation, so it is not CI-only. One PR of its own.",
"matchFileNames": [
"ci/images/hermes/**"
],
"groupName": "hermes image",
"groupSlug": "hermes-image"
},
{
"description": "nousresearch/hermes-agent also carries the retired date-versioned tags (v2026.x), which sort above v0.x.",
"matchDatasources": [
"docker"
],
"matchPackageNames": [
"nousresearch/hermes-agent"
],
"allowedVersions": "/^v0\\./"
}
]
}
+3 -3
View File
@@ -107,7 +107,7 @@ func TestAnthropicResumesAPausedTurnAndTakesTheFinalText(t *testing.T) {
messageStream("claude-opus-5-5", "end_turn", block(0, toolUse), block(1, toolResult),
textBlock(2, "## Summary\n\nok\n\nVerdict: comment")),
}
a, err := f.backend().Ask(context.Background(), "claude-opus-5-5", "Review pull request #7 of k.renner/homelab.")
a, err := f.backend().Ask(context.Background(), "claude-opus-5-5", "Review pull request #7 of konairius/homelab.")
if err != nil {
t.Fatal(err)
}
@@ -204,14 +204,14 @@ func TestSelectorPicksTheBackend(t *testing.T) {
f.rev.setReady("anthropic", true)
f.rev.selector = &TypeSafe{url: ts.URL, key: "ts-key", model: "jev-latest", http: &http.Client{Timeout: 5 * time.Second}}
if err := f.rev.Review(context.Background(), "k.renner/homelab", 7); err != nil {
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err != nil {
t.Fatal(err)
}
posted := f.postedReviews()
if len(posted) != 1 || !strings.Contains(posted[0]["body"], "model `"+tc.wantModel+"`") {
t.Fatalf("want one review by %s, got %v", tc.wantModel, posted)
}
if pr := state["pull_request"].(map[string]any); pr["title"] != "feat: x" || pr["repository"] != "k.renner/homelab" {
if pr := state["pull_request"].(map[string]any); pr["title"] != "feat: x" || pr["repository"] != "konairius/homelab" {
t.Errorf("selector state: %v", state)
}
})
+1 -1
View File
@@ -14,7 +14,7 @@ func noFiles(string) ([]byte, error) { return nil, errors.New("no such file") }
func baseEnv() map[string]string {
return map[string]string{
"REVIEW_TOKEN": "t", "WEBHOOK_SECRET": "s", "MISTRAL_API_KEY": "k",
"CONNECTOR_ID": "c1", "REPOS": "k.renner/homelab, konairius/shelly-manager",
"CONNECTOR_ID": "c1", "REPOS": "konairius/homelab, konairius/shelly-manager",
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
module gitea.renner.uno/k.renner/homelab/review-bot
module gitea.renner.uno/konairius/homelab/review-bot
go 1.27
+3 -3
View File
@@ -14,7 +14,7 @@ func TestBrokenStreamIsAnsweredFromTheHistory(t *testing.T) {
{"type":"message.input","content":"Review pull request #7"},
{"type":"tool.execution","name":"conn_get_file_contents"},
{"type":"message.output","content":"## Summary\n\nfine\n\n## Recommendation\n\nLooks reasonable.\n\nVerdict: comment"}]}`
if err := f.rev.Review(context.Background(), "k.renner/homelab", 7); err != nil {
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err != nil {
t.Fatal(err)
}
if f.convCalls != 1 || f.historyCalls != 1 || len(f.postedReviews()) != 1 {
@@ -26,7 +26,7 @@ func TestBrokenStreamIsAnsweredFromTheHistory(t *testing.T) {
func TestBrokenStreamWithoutHistoryIsAskedAgain(t *testing.T) {
f := newFixture(t)
f.breakStreams = 1
if err := f.rev.Review(context.Background(), "k.renner/homelab", 7); err != nil {
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err != nil {
t.Fatal(err)
}
if f.convCalls != 2 || f.historyCalls != 2 || len(f.postedReviews()) != 1 {
@@ -37,7 +37,7 @@ func TestBrokenStreamWithoutHistoryIsAskedAgain(t *testing.T) {
func TestStreamErrorEventIsNotRetried(t *testing.T) {
f := newFixture(t)
f.streamError = "Failed to generate response."
err := f.rev.Review(context.Background(), "k.renner/homelab", 7)
err := f.rev.Review(context.Background(), "konairius/homelab", 7)
if err == nil || !strings.Contains(err.Error(), "Failed to generate response.") {
t.Fatalf("want the stream error, got %v", err)
}
+35 -35
View File
@@ -109,22 +109,22 @@ func newFixture(t *testing.T) *fixture {
f.mu.Lock()
defer f.mu.Unlock()
switch {
case r.Method == "GET" && r.URL.Path == "/api/v1/repos/k.renner/homelab/pulls/7":
case r.Method == "GET" && r.URL.Path == "/api/v1/repos/konairius/homelab/pulls/7":
_, _ = io.WriteString(w, prJSON)
case r.Method == "GET" && r.URL.Path == "/api/v1/repos/k.renner/homelab/pulls/7/reviews":
case r.Method == "GET" && r.URL.Path == "/api/v1/repos/konairius/homelab/pulls/7/reviews":
_, _ = io.WriteString(w, f.reviews)
case r.Method == "GET" && r.URL.Path == "/api/v1/repos/k.renner/homelab/pulls":
case r.Method == "GET" && r.URL.Path == "/api/v1/repos/konairius/homelab/pulls":
_, _ = io.WriteString(w, "["+prJSON+"]")
case r.Method == "POST" && r.URL.Path == "/api/v1/repos/k.renner/homelab/pulls/7/reviews":
case r.Method == "POST" && r.URL.Path == "/api/v1/repos/konairius/homelab/pulls/7/reviews":
var in map[string]string
_ = json.NewDecoder(r.Body).Decode(&in)
f.posted = append(f.posted, in)
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, `{"html_url":"https://gitea/pr/7#review"}`)
case r.Method == "POST" && strings.HasPrefix(r.URL.Path, "/api/v1/repos/k.renner/homelab/statuses/"):
case r.Method == "POST" && strings.HasPrefix(r.URL.Path, "/api/v1/repos/konairius/homelab/statuses/"):
var in map[string]string
_ = json.NewDecoder(r.Body).Decode(&in)
in["sha"] = strings.TrimPrefix(r.URL.Path, "/api/v1/repos/k.renner/homelab/statuses/")
in["sha"] = strings.TrimPrefix(r.URL.Path, "/api/v1/repos/konairius/homelab/statuses/")
f.statuses = append(f.statuses, in)
w.WriteHeader(http.StatusCreated)
_, _ = io.WriteString(w, "{}")
@@ -152,7 +152,7 @@ func newFixture(t *testing.T) *fixture {
var in map[string]any
_ = json.NewDecoder(r.Body).Decode(&in)
if in["agent_id"] != "ag_1" || in["stream"] != true || in["store"] != true ||
!strings.Contains(in["inputs"].(string), "pull request #7 of k.renner/homelab") {
!strings.Contains(in["inputs"].(string), "pull request #7 of konairius/homelab") {
http.Error(w, "unexpected conversation request", http.StatusBadRequest)
return
}
@@ -197,7 +197,7 @@ func newFixture(t *testing.T) *fixture {
GiteaURL: f.gitea.URL, BotLogin: "review-bot", ReviewToken: "review-token", WebhookSecret: "hook-secret",
MistralURL: f.mistral.URL, MistralKey: "mistral-key", AgentName: "pr-review",
Models: []Model{{Backend: "mistral", Name: "mistral-large-4-0"}},
ConnectorID: "c1", Repos: []string{"k.renner/homelab"}, Workers: 1, QueueSize: 4,
ConnectorID: "c1", Repos: []string{"konairius/homelab"}, Workers: 1, QueueSize: 4,
ReviewTimeout: 5 * time.Second, RateLimitRetries: 2, RateLimitWait: time.Millisecond,
}
f.metrics = newMetrics()
@@ -245,7 +245,7 @@ func (f *fixture) postedReviews() []map[string]string {
func TestReviewPostsTheCleanedReport(t *testing.T) {
f := newFixture(t)
if err := f.rev.Review(context.Background(), "k.renner/homelab", 7); err != nil {
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err != nil {
t.Fatal(err)
}
got := f.postedReviews()
@@ -272,7 +272,7 @@ func TestReviewPostsTheCleanedReport(t *testing.T) {
func TestReviewSkipsWhatIsAlreadyReviewed(t *testing.T) {
f := newFixture(t)
f.reviews = `[{"body":"<!-- pr-review:sha=abc123 -->","user":{"login":"review-bot"}}]`
if err := f.rev.Review(context.Background(), "k.renner/homelab", 7); err != nil {
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err != nil {
t.Fatal(err)
}
if n := len(f.postedReviews()); n != 0 {
@@ -286,7 +286,7 @@ func TestPendingReviewIsReviewedAgain(t *testing.T) {
f := newFixture(t)
f.reviews = `[{"body":"<!-- pr-review:sha=abc123 -->","state":"PENDING","user":{"login":"review-bot"}}]`
f.answer = `{"outputs":[{"type":"message.output","content":"## Summary\n\nfine\n\n## Recommendation\n\nLooks reasonable.\n\nVerdict: approve"}]}`
if err := f.rev.Review(context.Background(), "k.renner/homelab", 7); err != nil {
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err != nil {
t.Fatal(err)
}
posted := f.postedReviews()
@@ -299,7 +299,7 @@ func TestReviewRefusesWriteToolsAndPostsNothing(t *testing.T) {
f := newFixture(t)
f.answer = `{"outputs":[{"type":"tool.execution","name":"conn_create_or_update_file"},
{"type":"message.output","content":"## Summary\n\nx"}]}`
err := f.rev.Review(context.Background(), "k.renner/homelab", 7)
err := f.rev.Review(context.Background(), "konairius/homelab", 7)
if err == nil || !strings.Contains(err.Error(), "conn_create_or_update_file") {
t.Fatalf("want an error naming the tool, got %v", err)
}
@@ -311,7 +311,7 @@ func TestReviewRefusesWriteToolsAndPostsNothing(t *testing.T) {
func TestReviewWithoutAReportPostsNothing(t *testing.T) {
f := newFixture(t)
f.answer = `{"outputs":[{"type":"message.output","content":"I could not do it."}]}`
if err := f.rev.Review(context.Background(), "k.renner/homelab", 7); err == nil {
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err == nil {
t.Fatal("want an error")
}
if n := len(f.postedReviews()); n != 0 {
@@ -322,7 +322,7 @@ func TestReviewWithoutAReportPostsNothing(t *testing.T) {
func TestReviewNeedsTheAgent(t *testing.T) {
f := newFixture(t)
f.rev.setReady("mistral", false)
if err := f.rev.Review(context.Background(), "k.renner/homelab", 7); err == nil {
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err == nil {
t.Fatal("want an error while the agent is not ready")
}
if f.rev.Ready() {
@@ -333,7 +333,7 @@ func TestReviewNeedsTheAgent(t *testing.T) {
func TestReviewAsksAgainAfterTheRateLimit(t *testing.T) {
f := newFixture(t)
f.convStatus = []int{429, 429}
if err := f.rev.Review(context.Background(), "k.renner/homelab", 7); err != nil {
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err != nil {
t.Fatal(err)
}
if f.convCalls != 3 || len(f.postedReviews()) != 1 {
@@ -348,7 +348,7 @@ func TestReviewHonoursRetryAfter(t *testing.T) {
f.rev.cfg.RateLimitWait = 10 * time.Minute
f.retryAfter = "0"
f.convStatus = []int{429}
if err := f.rev.Review(context.Background(), "k.renner/homelab", 7); err != nil {
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err != nil {
t.Fatal(err)
}
if f.convCalls != 2 || len(f.postedReviews()) != 1 {
@@ -371,7 +371,7 @@ func TestLimitHeaders(t *testing.T) {
func TestReviewGivesUpWhenTheRateLimitStays(t *testing.T) {
f := newFixture(t)
f.convStatus = []int{429, 429, 429, 429}
err := f.rev.Review(context.Background(), "k.renner/homelab", 7)
err := f.rev.Review(context.Background(), "konairius/homelab", 7)
if err == nil || !strings.Contains(err.Error(), "still failing") {
t.Fatalf("want a rate limit error, got %v", err)
}
@@ -383,7 +383,7 @@ func TestReviewGivesUpWhenTheRateLimitStays(t *testing.T) {
func TestOtherErrorsAreNotRetried(t *testing.T) {
f := newFixture(t)
f.convStatus = []int{500}
if err := f.rev.Review(context.Background(), "k.renner/homelab", 7); err == nil {
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err == nil {
t.Fatal("want an error")
}
if f.convCalls != 1 {
@@ -440,7 +440,7 @@ func (f *fixture) hook(event, signature string, body string) *httptest.ResponseR
func TestHook(t *testing.T) {
f := newFixture(t)
body := `{"action":"opened","number":7,"repository":{"full_name":"k.renner/homelab"}}`
body := `{"action":"opened","number":7,"repository":{"full_name":"konairius/homelab"}}`
good := sign("hook-secret", []byte(body))
cases := []struct {
name, event, sig, body string
@@ -455,10 +455,10 @@ func TestHook(t *testing.T) {
strings.Replace(body, "opened", "closed", 1), 200},
{"synchronized", "pull_request", sign("hook-secret", []byte(strings.Replace(body, "opened", "synchronized", 1))),
strings.Replace(body, "opened", "synchronized", 1), 202},
{"repository that is not configured", "pull_request", sign("hook-secret", []byte(strings.Replace(body, "k.renner/homelab", "x/y", 1))),
strings.Replace(body, "k.renner/homelab", "x/y", 1), 200},
{"payload without a number", "pull_request", sign("hook-secret", []byte(`{"action":"opened","repository":{"full_name":"k.renner/homelab"}}`)),
`{"action":"opened","repository":{"full_name":"k.renner/homelab"}}`, 400},
{"repository that is not configured", "pull_request", sign("hook-secret", []byte(strings.Replace(body, "konairius/homelab", "x/y", 1))),
strings.Replace(body, "konairius/homelab", "x/y", 1), 200},
{"payload without a number", "pull_request", sign("hook-secret", []byte(`{"action":"opened","repository":{"full_name":"konairius/homelab"}}`)),
`{"action":"opened","repository":{"full_name":"konairius/homelab"}}`, 400},
{"payload that is not json", "pull_request", sign("hook-secret", []byte("nope")), "nope", 400},
}
for _, c := range cases {
@@ -473,10 +473,10 @@ func TestHook(t *testing.T) {
func TestQueueRunsAReviewAndDeduplicates(t *testing.T) {
f := newFixture(t)
q := newQueue(f.rev, 4, f.rev.log, f.metrics)
if r := q.Enqueue("k.renner/homelab", 7, "abc123"); r != "queued" {
if r := q.Enqueue("konairius/homelab", 7, "abc123"); r != "queued" {
t.Fatalf("first enqueue: %s", r)
}
if r := q.Enqueue("k.renner/homelab", 7, "abc123"); r != "duplicate" {
if r := q.Enqueue("konairius/homelab", 7, "abc123"); r != "duplicate" {
t.Fatalf("second enqueue of the same PR: %s", r)
}
q.Start(context.Background(), 1)
@@ -484,7 +484,7 @@ func TestQueueRunsAReviewAndDeduplicates(t *testing.T) {
if n := len(f.postedReviews()); n != 1 {
t.Errorf("posted %d reviews, want 1", n)
}
if r := q.Enqueue("k.renner/homelab", 8, ""); r != "full" {
if r := q.Enqueue("konairius/homelab", 8, ""); r != "full" {
t.Errorf("a stopped queue takes nothing, got %s", r)
}
}
@@ -529,7 +529,7 @@ func TestFirstRetryWaitsTheConfiguredTime(t *testing.T) {
f.rev.cfg.RateLimitWait = 300 * time.Millisecond
f.convStatus = []int{429}
start := time.Now()
if err := f.rev.Review(context.Background(), "k.renner/homelab", 7); err != nil {
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err != nil {
t.Fatal(err)
}
if got := time.Since(start); got < 250*time.Millisecond {
@@ -566,7 +566,7 @@ func TestReviewOfAFileAboutRateLimitsIsNotDropped(t *testing.T) {
f.answer = `{"outputs":[
{"type":"tool.execution","name":"conn_get_file_contents","info":{"result":"var rateLimitText = rate limit handling"}},
{"type":"message.output","content":"## Summary\n\nreviewed\n\n## Recommendation\n\nApprove."}]}`
if err := f.rev.Review(context.Background(), "k.renner/homelab", 7); err != nil {
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err != nil {
t.Fatal(err)
}
if f.convCalls != 1 || len(f.postedReviews()) != 1 {
@@ -579,7 +579,7 @@ func TestReviewOfAFileAboutRateLimitsIsNotDropped(t *testing.T) {
func TestReviewDoesNotPostForAStaleHead(t *testing.T) {
f := newFixture(t)
f.pushAfter = "def456"
if err := f.rev.Review(context.Background(), "k.renner/homelab", 7); err != nil {
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err != nil {
t.Fatal(err)
}
posted := f.postedReviews()
@@ -595,18 +595,18 @@ func TestReviewDoesNotPostForAStaleHead(t *testing.T) {
func TestQueueReviewsAgainWhenAskedForDuringTheReview(t *testing.T) {
f := newFixture(t)
q := newQueue(f.rev, 4, f.rev.log, f.metrics)
if r := q.Enqueue("k.renner/homelab", 7, "abc123"); r != "queued" {
if r := q.Enqueue("konairius/homelab", 7, "abc123"); r != "queued" {
t.Fatalf("enqueue: %s", r)
}
q.mu.Lock()
q.running["k.renner/homelab#7"] = true // as if a worker had picked it up
q.running["konairius/homelab#7"] = true // as if a worker had picked it up
q.mu.Unlock()
if r := q.Enqueue("k.renner/homelab", 7, "abc123"); r != "duplicate" {
if r := q.Enqueue("konairius/homelab", 7, "abc123"); r != "duplicate" {
t.Fatalf("enqueue while running: %s", r)
}
q.mu.Lock()
again := q.again["k.renner/homelab#7"]
delete(q.running, "k.renner/homelab#7")
again := q.again["konairius/homelab#7"]
delete(q.running, "konairius/homelab#7")
q.mu.Unlock()
if !again {
t.Error("the request during the review was forgotten")
@@ -617,7 +617,7 @@ func TestReviewAsksAgainAfterAGatewayFailure(t *testing.T) {
for _, status := range []int{502, 503, 504} {
f := newFixture(t)
f.convStatus = []int{status}
if err := f.rev.Review(context.Background(), "k.renner/homelab", 7); err != nil {
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err != nil {
t.Fatalf("%d: %v", status, err)
}
if f.convCalls != 2 || len(f.postedReviews()) != 1 {
+17 -17
View File
@@ -13,10 +13,10 @@ import (
func TestQueueReportsQueuedRunningAndPosted(t *testing.T) {
f := newFixture(t)
q := newQueue(f.rev, 4, f.rev.log, f.metrics)
if r := q.Enqueue("k.renner/homelab", 7, "abc123"); r != "queued" {
if r := q.Enqueue("konairius/homelab", 7, "abc123"); r != "queued" {
t.Fatalf("enqueue: %s", r)
}
if st, _ := f.status.Get("k.renner/homelab", 7); st.Phase != PhaseQueued || st.Position != 1 {
if st, _ := f.status.Get("konairius/homelab", 7); st.Phase != PhaseQueued || st.Position != 1 {
t.Errorf("before the worker runs: %+v, want queued at position 1", st)
}
q.Start(context.Background(), 1)
@@ -30,7 +30,7 @@ func TestQueueReportsQueuedRunningAndPosted(t *testing.T) {
if got := f.commitStatuses(); !slices.Equal(got, want) {
t.Errorf("commit statuses\n got %q\nwant %q", got, want)
}
st, _ := f.status.Get("k.renner/homelab", 7)
st, _ := f.status.Get("konairius/homelab", 7)
if st.Phase != PhasePosted || st.URL != "https://gitea/pr/7#review" || st.Head != "abc123" {
t.Errorf("after the review: %+v", st)
}
@@ -40,7 +40,7 @@ func TestAFailedReviewIsReportedAsAnError(t *testing.T) {
f := newFixture(t)
f.answer = `{"outputs":[{"type":"message.output","content":"I could not do it."}]}`
q := newQueue(f.rev, 4, f.rev.log, f.metrics)
q.Enqueue("k.renner/homelab", 7, "abc123")
q.Enqueue("konairius/homelab", 7, "abc123")
q.Start(context.Background(), 1)
q.Stop(5 * time.Second)
@@ -48,7 +48,7 @@ func TestAFailedReviewIsReportedAsAnError(t *testing.T) {
if last := got[len(got)-1]; last != "abc123 error: review failed: the agent's answer holds no report" {
t.Errorf("last commit status %q", last)
}
if st, _ := f.status.Get("k.renner/homelab", 7); st.Phase != PhaseFailed || st.Detail != "the agent's answer holds no report" {
if st, _ := f.status.Get("konairius/homelab", 7); st.Phase != PhaseFailed || st.Detail != "the agent's answer holds no report" {
t.Errorf("after the failure: %+v", st)
}
}
@@ -56,7 +56,7 @@ func TestAFailedReviewIsReportedAsAnError(t *testing.T) {
func TestARetryIsReported(t *testing.T) {
f := newFixture(t)
f.convStatus = []int{429}
if err := f.rev.Review(context.Background(), "k.renner/homelab", 7); err != nil {
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err != nil {
t.Fatal(err)
}
got := f.commitStatuses()
@@ -70,12 +70,12 @@ func TestARetryIsReported(t *testing.T) {
func TestSkipsResolveTheQueuedStatus(t *testing.T) {
f := newFixture(t)
f.title = "WIP: x"
if err := f.rev.Review(context.Background(), "k.renner/homelab", 7); err != nil {
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err != nil {
t.Fatal(err)
}
f.title = "feat: x"
f.reviews = `[{"body":"<!-- pr-review:sha=abc123 -->","state":"APPROVED","html_url":"https://gitea/r/1","user":{"login":"review-bot"}}]`
if err := f.rev.Review(context.Background(), "k.renner/homelab", 7); err != nil {
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err != nil {
t.Fatal(err)
}
want := []string{"abc123 success: skipped: wip", "abc123 success: reviewed: approve"}
@@ -87,7 +87,7 @@ func TestSkipsResolveTheQueuedStatus(t *testing.T) {
func TestAStaleHeadIsMarkedSuperseded(t *testing.T) {
f := newFixture(t)
f.pushAfter = "def456"
if err := f.rev.Review(context.Background(), "k.renner/homelab", 7); err != nil {
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err != nil {
t.Fatal(err)
}
got := f.commitStatuses()
@@ -101,7 +101,7 @@ func TestStatusEndpoints(t *testing.T) {
f := newFixture(t)
q := newQueue(f.rev, 4, f.rev.log, f.metrics)
s := &Server{cfg: f.cfg, queue: q, reviewer: f.rev, metrics: f.metrics, log: f.rev.log}
q.Enqueue("k.renner/homelab", 7, "abc123")
q.Enqueue("konairius/homelab", 7, "abc123")
get := func(path string) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
s.routes().ServeHTTP(rec, httptest.NewRequest("GET", path, nil))
@@ -114,14 +114,14 @@ func TestStatusEndpoints(t *testing.T) {
t.Errorf("/status: %d %s", rec.Code, rec.Body)
}
var one PRStatus
if rec := get("/status/k.renner/homelab/7"); rec.Code != 200 || json.Unmarshal(rec.Body.Bytes(), &one) != nil ||
one.Repo != "k.renner/homelab" || one.Number != 7 || one.Head != "abc123" {
t.Errorf("/status/k.renner/homelab/7: %d %s", rec.Code, rec.Body)
if rec := get("/status/konairius/homelab/7"); rec.Code != 200 || json.Unmarshal(rec.Body.Bytes(), &one) != nil ||
one.Repo != "konairius/homelab" || one.Number != 7 || one.Head != "abc123" {
t.Errorf("/status/konairius/homelab/7: %d %s", rec.Code, rec.Body)
}
if rec := get("/status/k.renner/homelab/8"); rec.Code != 404 {
if rec := get("/status/konairius/homelab/8"); rec.Code != 404 {
t.Errorf("untracked PR: %d, want 404", rec.Code)
}
if rec := get("/status/k.renner/homelab/x"); rec.Code != 400 {
if rec := get("/status/konairius/homelab/x"); rec.Code != 400 {
t.Errorf("bad number: %d, want 400", rec.Code)
}
}
@@ -130,8 +130,8 @@ func TestFinishedReviewsAreForgottenAfterADay(t *testing.T) {
f := newFixture(t)
now := time.Now()
f.status.now = func() time.Time { return now }
f.status.Set("k.renner/homelab", 7, "", PhasePosted, "comment", "")
f.status.Set("k.renner/homelab", 8, "", PhaseQueued, "", "")
f.status.Set("konairius/homelab", 7, "", PhasePosted, "comment", "")
f.status.Set("konairius/homelab", 8, "", PhaseQueued, "", "")
now = now.Add(keepFinished + time.Minute)
got := f.status.List()
if len(got) != 1 || got[0].Number != 8 {