Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbeaf622ed | ||
|
|
35a387a3c1 | ||
|
|
093857c353 | ||
|
|
7f29ebb878 | ||
|
|
b07167ae1a | ||
|
|
df0b344f7f | ||
|
|
46b7d458b3 | ||
|
|
2d04575c51 | ||
|
|
97310db78c | ||
|
|
085f969f63 | ||
|
|
d00ee70b23 | ||
|
|
8ea8318c6e | ||
|
|
fbc1aaef21 | ||
|
|
be9898f7b0 | ||
|
|
3c5c04d4a5 | ||
|
|
3ed7741c68 | ||
|
|
f73cc54705 | ||
|
|
c8849b8d9c | ||
|
|
9d0707c1aa | ||
|
|
74db40710e | ||
|
|
368dc24add | ||
|
|
dfb2f71c85 | ||
|
|
6b78f4cb5b | ||
|
|
d8d356779d | ||
|
|
2b1016bd4a | ||
|
|
58695ceb9a | ||
|
|
055cc6a6e3 | ||
|
|
67372c673f | ||
|
|
ab1f004b48 | ||
|
|
afbc21edc3 | ||
|
|
91d940499e | ||
|
|
4d4ea98b7c | ||
|
|
7e1b46108a | ||
|
|
4fb3a4ba40 | ||
|
|
72927293e2 | ||
|
|
67aadbc0f9 | ||
|
|
bb992833c2 | ||
|
|
fd2f3b7be4 | ||
|
|
a35bc6b91d |
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Builds ci/images/hermes (nousresearch/hermes-agent plus the agent's CLIs) as
|
||||
# gitea.renner.uno/konairius/hermes:build.<unix-ts> and :latest.
|
||||
when:
|
||||
- event: push
|
||||
branch: master
|
||||
path:
|
||||
- "ci/images/hermes/**"
|
||||
- ".woodpecker/hermes-image.yml"
|
||||
- event: manual
|
||||
|
||||
steps:
|
||||
- name: build
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
|
||||
directory: ci/images/hermes
|
||||
environment:
|
||||
# registry-bot write:package PAT (konairius org secret).
|
||||
GITEA_TOKEN:
|
||||
from_secret: registry_bot_token
|
||||
commands:
|
||||
- mkdir -p /kaniko/.docker
|
||||
- AUTH=$(printf '%s:%s' registry-bot "$GITEA_TOKEN" | base64 | tr -d '\n')
|
||||
- printf '{"auths":{"gitea.renner.uno":{"auth":"%s"}}}' "$AUTH" > /kaniko/.docker/config.json
|
||||
- TAG="build.$(date +%s)"
|
||||
- |
|
||||
/kaniko/executor \
|
||||
--context . \
|
||||
--dockerfile Containerfile \
|
||||
--destination gitea.renner.uno/konairius/hermes:$TAG \
|
||||
--destination gitea.renner.uno/konairius/hermes:latest
|
||||
@@ -32,9 +32,9 @@
|
||||
#
|
||||
# Triggering:
|
||||
# - cron : a cron NAMED exactly "issue-fix":
|
||||
# woodpecker repo cron add --repo k.renner/homelab \
|
||||
# woodpecker repo cron add --repo konairius/homelab \
|
||||
# --name issue-fix --branch master --schedule '41 * * * *'
|
||||
# - deploy : woodpecker-cli pipeline deploy k.renner/homelab <n> issue-fix
|
||||
# - deploy : woodpecker-cli pipeline deploy konairius/homelab <n> issue-fix
|
||||
# where <n> is an existing pipeline to promote.
|
||||
when:
|
||||
- event: cron
|
||||
@@ -82,7 +82,7 @@ steps:
|
||||
set -eu
|
||||
TRUSTED_AUTHORS="k.renner" # space-separated; the trust anchor (see header)
|
||||
mkdir -p /tmp/alert-fix
|
||||
API="https://gitea.renner.uno/api/v1/repos/k.renner/homelab"
|
||||
API="https://gitea.renner.uno/api/v1/repos/konairius/homelab"
|
||||
curl -sfL "$$API/issues?state=open&type=issues&limit=100" \
|
||||
| jq '[.[] | select(.title | startswith("[alert] "))] | sort_by(.number)' \
|
||||
> /tmp/alert-fix/candidates.json
|
||||
@@ -163,7 +163,7 @@ steps:
|
||||
# run skips this issue, and stop cleanly.
|
||||
[ -f /tmp/alert-fix/issue.json ] || exit 0
|
||||
set -eu
|
||||
API="https://gitea.renner.uno/api/v1/repos/k.renner/homelab"
|
||||
API="https://gitea.renner.uno/api/v1/repos/konairius/homelab"
|
||||
NUM=$$(jq -r '.number' /tmp/alert-fix/issue.json)
|
||||
if git diff --quiet && git diff --cached --quiet \
|
||||
&& [ -z "$$(git ls-files --others --exclude-standard)" ]; then
|
||||
@@ -187,7 +187,7 @@ steps:
|
||||
[ -f /tmp/alert-fix/issue.json ] || exit 0
|
||||
if [ -f /tmp/alert-fix/no-edits ]; then exit 0; fi
|
||||
set -eu
|
||||
API="https://gitea.renner.uno/api/v1/repos/k.renner/homelab"
|
||||
API="https://gitea.renner.uno/api/v1/repos/konairius/homelab"
|
||||
NUM=$$(jq -r '.number' /tmp/alert-fix/issue.json)
|
||||
BRANCH=$$(cat /tmp/alert-fix/branch)
|
||||
TITLE="fix: $$(jq -r '.title' /tmp/alert-fix/issue.json) (#$$NUM)"
|
||||
@@ -196,7 +196,7 @@ steps:
|
||||
printf '\n\nCloses #%s\n' "$$NUM" >> /tmp/alert-fix/summary.md
|
||||
git add -A
|
||||
git commit -m "$$TITLE"
|
||||
git push "https://$$PUSH_TOKEN@gitea.renner.uno/k.renner/homelab.git" HEAD:"$$BRANCH"
|
||||
git push "https://$$PUSH_TOKEN@gitea.renner.uno/konairius/homelab.git" HEAD:"$$BRANCH"
|
||||
jq -n --arg title "$$TITLE" --rawfile body /tmp/alert-fix/summary.md \
|
||||
--arg head "$$BRANCH" \
|
||||
'{title: $$title, body: $$body, head: $$head, base: "master"}' > /tmp/gitea-pr.json
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
# pull requests. This pipeline only starts when triggered by hand and is kept for reference.
|
||||
#
|
||||
# PR review bot — per-repo STUB. All logic lives centrally in
|
||||
# k.renner/homelab: ci/agents/pr-review/run.sh (plus prompt.md,
|
||||
# konairius/homelab: ci/agents/pr-review/run.sh (plus prompt.md,
|
||||
# settings.json and scripts/ci/). This file is deployed byte-identical into every
|
||||
# reviewed repo (k.renner/homelab, shelly-manager, authentik-samba-bridge,
|
||||
# reviewed repo (konairius/homelab, shelly-manager, authentik-samba-bridge,
|
||||
# homeassistant) and holds NO logic, so the copies cannot drift apart: change the
|
||||
# review in homelab and every repo picks it up on its next PR event. Only edit this
|
||||
# stub if the trigger, the image or the secrets change — then change all copies in
|
||||
@@ -43,5 +43,5 @@ steps:
|
||||
from_secret: review_bot_token
|
||||
commands:
|
||||
- rm -rf /tmp/pr-review-central
|
||||
- git clone --quiet --depth 1 https://gitea.renner.uno/k.renner/homelab /tmp/pr-review-central
|
||||
- git clone --quiet --depth 1 https://gitea.renner.uno/konairius/homelab /tmp/pr-review-central
|
||||
- sh /tmp/pr-review-central/ci/agents/pr-review/run.sh
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# - cron : the named `daily-security-scan` cron, matched by NAME.
|
||||
# Crons cannot carry variables — `woodpecker-cli repo cron add`
|
||||
# has no --var — so a variable gate can never match a cron.
|
||||
# - deployment : woodpecker-cli pipeline deploy k.renner/homelab <n> security-scan
|
||||
# - deployment : woodpecker-cli pipeline deploy konairius/homelab <n> security-scan
|
||||
# The target rides in pipeline metadata as
|
||||
# CI_PIPELINE_DEPLOY_TARGET, which the workflow-root filter
|
||||
# does see, unlike manual-run variables on Woodpecker 3.18
|
||||
@@ -147,7 +147,7 @@ steps:
|
||||
TITLE="security: agent-proposed fixes ($$(date +%Y-%m-%d))"
|
||||
git add -A
|
||||
git commit -m "$$TITLE"
|
||||
git push "https://$$GITEA_TOKEN@gitea.renner.uno/k.renner/homelab.git" HEAD:"$$BRANCH"
|
||||
git push "https://$$GITEA_TOKEN@gitea.renner.uno/konairius/homelab.git" HEAD:"$$BRANCH"
|
||||
jq -n \
|
||||
--arg title "$$TITLE" \
|
||||
--arg body "$$BODY" \
|
||||
@@ -157,5 +157,5 @@ steps:
|
||||
-H "Authorization: token $$GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data @- \
|
||||
https://gitea.renner.uno/api/v1/repos/k.renner/homelab/pulls \
|
||||
https://gitea.renner.uno/api/v1/repos/konairius/homelab/pulls \
|
||||
| jq '{number, html_url, title}'
|
||||
|
||||
@@ -14,9 +14,9 @@
|
||||
# `repo cron add` takes name, branch, schedule and enabled — so the
|
||||
# name filter is the only thing that can select a workflow for a
|
||||
# scheduled run:
|
||||
# woodpecker repo cron add --repo k.renner/homelab \
|
||||
# woodpecker repo cron add --repo konairius/homelab \
|
||||
# --name update-audit --branch master --schedule '@weekly'
|
||||
# - deploy : woodpecker-cli pipeline deploy k.renner/homelab <n> update-audit
|
||||
# - deploy : woodpecker-cli pipeline deploy konairius/homelab <n> update-audit
|
||||
# where <n> is an existing pipeline to promote.
|
||||
#
|
||||
# NOTE: e2e.yml runs on an explicit list of cron names that includes this one,
|
||||
@@ -111,7 +111,7 @@ steps:
|
||||
set -eu
|
||||
TITLE="Infrastructure update audit"
|
||||
STAMP=$$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
API="https://gitea.renner.uno/api/v1/repos/k.renner/homelab"
|
||||
API="https://gitea.renner.uno/api/v1/repos/konairius/homelab"
|
||||
|
||||
# write <method> <url> <payload-file> -> prints HTTP code, fails non-2xx
|
||||
gitea_write() {
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
# Prerequisites:
|
||||
# - netbird-server play applied (docker, Traefik on netbird_netbird)
|
||||
# - OpenBao homelab/infrastructure/uptime-kuma with keys username, password
|
||||
# - OpenBao homelab/infrastructure/uptime-kuma/smtp with keys server, port, username, token
|
||||
# - homelab/infrastructure/uptime-kuma-metrics (key api_key) is written by the role
|
||||
# - VAULT_TOKEN in the environment (policy: uptime-kuma-vps)
|
||||
# - DNS for kuma.renner.uno pointing at the VPS
|
||||
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
---
|
||||
uptime_kuma_version: "2.5.6"
|
||||
uptime_kuma_host: kuma.renner.uno
|
||||
# Not routed yet; reserved for the public status page.
|
||||
uptime_kuma_base_url: "https://{{ uptime_kuma_host }}"
|
||||
# Custom domain of the status page; not routed by Traefik yet.
|
||||
uptime_kuma_status_host: uptime.renner.uno
|
||||
uptime_kuma_status_slug: renner
|
||||
uptime_kuma_status_title: Renner
|
||||
uptime_kuma_dir: /opt/uptime-kuma
|
||||
uptime_kuma_traefik_network: netbird_netbird
|
||||
|
||||
uptime_kuma_smtp_from: uptime@renner.uno
|
||||
# Alertmanager email receiver in kubernetes/apps/monitoring/helmrelease-prometheus.yaml.
|
||||
uptime_kuma_smtp_to: admin@renner.uno
|
||||
|
||||
uptime_kuma_openbao_url: https://openbao.renner.uno
|
||||
uptime_kuma_openbao_mount: homelab
|
||||
uptime_kuma_openbao_path: infrastructure/uptime-kuma
|
||||
uptime_kuma_openbao_smtp_path: infrastructure/uptime-kuma/smtp
|
||||
uptime_kuma_openbao_metrics_path: infrastructure/uptime-kuma-metrics
|
||||
|
||||
@@ -1,46 +1,225 @@
|
||||
// Creates the Uptime Kuma admin user on first start; exits 0 if already set up.
|
||||
// Credentials come from KUMA_SETUP_USERNAME / KUMA_SETUP_PASSWORD only.
|
||||
// Bootstraps Uptime Kuma: admin user, general settings, SMTP notification, status page,
|
||||
// and (when KUMA_METRICS_ROTATE=true) a fresh Prometheus API key written to KUMA_METRICS_KEY_FILE.
|
||||
// All input comes from environment variables; secrets are never printed.
|
||||
// Output lines: "admin user created" / "already set up", then "changed: <step>" or "ok: <step>".
|
||||
const fs = require("fs");
|
||||
const { io } = require("socket.io-client");
|
||||
|
||||
const username = process.env.KUMA_SETUP_USERNAME;
|
||||
const password = process.env.KUMA_SETUP_PASSWORD;
|
||||
if (!username || !password) {
|
||||
console.error("KUMA_SETUP_USERNAME and KUMA_SETUP_PASSWORD must be set");
|
||||
const KUMA_URL = "http://127.0.0.1:3001";
|
||||
const EMIT_TIMEOUT_MS = 30000;
|
||||
const TOTAL_TIMEOUT_MS = 180000;
|
||||
// Defaults the Kuma settings page applies to unset keys before saving (src/pages/Settings.vue).
|
||||
const SETTINGS_DEFAULTS = {
|
||||
checkUpdate: true,
|
||||
searchEngineIndex: false,
|
||||
entryPage: "dashboard",
|
||||
nscd: true,
|
||||
keepDataPeriodDays: 180,
|
||||
tlsExpiryNotifyDays: [7, 14, 21],
|
||||
domainExpiryNotifyDays: [7, 14, 21],
|
||||
trustProxy: false,
|
||||
};
|
||||
|
||||
const REQUIRED = [
|
||||
"KUMA_SETUP_USERNAME",
|
||||
"KUMA_SETUP_PASSWORD",
|
||||
"KUMA_BASE_URL",
|
||||
"KUMA_SMTP_HOST",
|
||||
"KUMA_SMTP_PORT",
|
||||
"KUMA_SMTP_USERNAME",
|
||||
"KUMA_SMTP_PASSWORD",
|
||||
"KUMA_SMTP_FROM",
|
||||
"KUMA_SMTP_TO",
|
||||
"KUMA_STATUS_SLUG",
|
||||
"KUMA_STATUS_TITLE",
|
||||
"KUMA_STATUS_DOMAIN",
|
||||
];
|
||||
const missing = REQUIRED.filter((name) => !process.env[name]);
|
||||
const rotateMetricsKey = process.env.KUMA_METRICS_ROTATE === "true";
|
||||
if (rotateMetricsKey && !process.env.KUMA_METRICS_KEY_FILE) {
|
||||
missing.push("KUMA_METRICS_KEY_FILE");
|
||||
}
|
||||
if (missing.length > 0) {
|
||||
console.error(`missing environment variables: ${missing.join(", ")}`);
|
||||
process.exit(2);
|
||||
}
|
||||
const env = process.env;
|
||||
|
||||
const socket = io("http://127.0.0.1:3001", { transports: ["websocket"], reconnection: false });
|
||||
const timer = setTimeout(() => {
|
||||
console.error("timeout: no response from Uptime Kuma within 30s");
|
||||
process.exit(1);
|
||||
}, 30000);
|
||||
const socket = io(KUMA_URL, { transports: ["websocket"], reconnection: false });
|
||||
const lists = {};
|
||||
const LIST_EVENTS = ["notificationList", "apiKeyList", "statusPageList"];
|
||||
for (const event of LIST_EVENTS) {
|
||||
socket.on(event, (data) => {
|
||||
lists[event] = data;
|
||||
});
|
||||
}
|
||||
|
||||
let finished = false;
|
||||
function done(code, msg) {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
clearTimeout(timer);
|
||||
(code === 0 ? console.log : console.error)(msg);
|
||||
clearTimeout(watchdog);
|
||||
if (msg) (code === 0 ? console.log : console.error)(msg);
|
||||
socket.close();
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
const watchdog = setTimeout(() => done(1, `timeout: bootstrap did not finish within ${TOTAL_TIMEOUT_MS / 1000}s`), TOTAL_TIMEOUT_MS);
|
||||
socket.on("connect_error", (err) => done(1, `connect_error: ${err.message}`));
|
||||
socket.on("disconnect", (reason) => done(1, `disconnected before setup finished: ${reason}`));
|
||||
socket.on("disconnect", (reason) => done(1, `disconnected before bootstrap finished: ${reason}`));
|
||||
|
||||
// Kuma registers socket handlers only after emitting info.
|
||||
socket.once("info", () => {
|
||||
socket.emit("needSetup", (needSetup) => {
|
||||
if (!needSetup) {
|
||||
done(0, "already set up");
|
||||
return;
|
||||
}
|
||||
socket.emit("setup", username, password, (res) => {
|
||||
if (res && res.ok) {
|
||||
done(0, "admin user created");
|
||||
} else {
|
||||
done(1, `setup failed: ${res && res.msg ? res.msg : JSON.stringify(res)}`);
|
||||
}
|
||||
function call(event, ...args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error(`timeout waiting for ${event}`)), EMIT_TIMEOUT_MS);
|
||||
socket.emit(event, ...args, (res) => {
|
||||
clearTimeout(timer);
|
||||
resolve(res);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function callOk(event, ...args) {
|
||||
const res = await call(event, ...args);
|
||||
if (!res || !res.ok) {
|
||||
throw new Error(`${event} failed: ${res && res.msg ? res.msg : JSON.stringify(res)}`);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
function report(step, changed) {
|
||||
console.log(`${changed ? "changed" : "ok"}: ${step}`);
|
||||
}
|
||||
|
||||
// Kuma registers socket handlers only after emitting info.
|
||||
function waitForInfo() {
|
||||
return new Promise((resolve) => socket.once("info", resolve));
|
||||
}
|
||||
|
||||
async function setupAdmin() {
|
||||
const needSetup = await call("needSetup");
|
||||
if (!needSetup) {
|
||||
console.log("already set up");
|
||||
return;
|
||||
}
|
||||
await callOk("setup", env.KUMA_SETUP_USERNAME, env.KUMA_SETUP_PASSWORD);
|
||||
console.log("admin user created");
|
||||
}
|
||||
|
||||
async function login() {
|
||||
await callOk("login", { username: env.KUMA_SETUP_USERNAME, password: env.KUMA_SETUP_PASSWORD, token: "" });
|
||||
// afterLogin sends the lists before the login ack but swallows send failures.
|
||||
for (const event of LIST_EVENTS) {
|
||||
if (lists[event] === undefined) {
|
||||
throw new Error(`${event} not received after login`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureSettings() {
|
||||
const { data } = await callOk("getSettings");
|
||||
if (data.trustProxy === true && data.primaryBaseURL === env.KUMA_BASE_URL) {
|
||||
report("settings", false);
|
||||
return;
|
||||
}
|
||||
const updated = { ...SETTINGS_DEFAULTS, ...data, trustProxy: true, primaryBaseURL: env.KUMA_BASE_URL };
|
||||
await callOk("setSettings", updated, "");
|
||||
report("settings", true);
|
||||
}
|
||||
|
||||
async function ensureNotification() {
|
||||
const port = Number(env.KUMA_SMTP_PORT);
|
||||
const desired = {
|
||||
name: "email",
|
||||
type: "smtp",
|
||||
isDefault: true,
|
||||
smtpHost: env.KUMA_SMTP_HOST,
|
||||
smtpPort: port,
|
||||
// 465 is implicit TLS; any other port uses STARTTLS.
|
||||
smtpSecure: port === 465,
|
||||
smtpIgnoreSTARTTLS: false,
|
||||
smtpUsername: env.KUMA_SMTP_USERNAME,
|
||||
smtpPassword: env.KUMA_SMTP_PASSWORD,
|
||||
smtpFrom: env.KUMA_SMTP_FROM,
|
||||
smtpTo: env.KUMA_SMTP_TO,
|
||||
};
|
||||
const existing = (lists.notificationList || []).filter((n) => n.name === desired.name).sort((a, b) => a.id - b.id)[0];
|
||||
const current = existing ? JSON.parse(existing.config || "{}") : {};
|
||||
const upToDate =
|
||||
existing && existing.isDefault === true && Object.keys(desired).every((key) => current[key] === desired[key]);
|
||||
if (upToDate) {
|
||||
report("notification", false);
|
||||
return;
|
||||
}
|
||||
await callOk("addNotification", { ...current, ...desired, applyExisting: true }, existing ? existing.id : null);
|
||||
report("notification", true);
|
||||
}
|
||||
|
||||
async function fetchPublicGroupList(slug) {
|
||||
const res = await fetch(`${KUMA_URL}/api/status-page/${encodeURIComponent(slug)}`);
|
||||
if (!res.ok) {
|
||||
throw new Error(`GET /api/status-page/${slug} returned ${res.status}`);
|
||||
}
|
||||
const body = await res.json();
|
||||
return (body.publicGroupList || []).map((group) => ({
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
monitorList: (group.monitorList || []).map((monitor) => {
|
||||
const entry = { id: monitor.id };
|
||||
if (monitor.sendUrl !== undefined) entry.sendUrl = monitor.sendUrl;
|
||||
if (monitor.url !== undefined) entry.url = monitor.url;
|
||||
return entry;
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
async function ensureStatusPage() {
|
||||
const slug = env.KUMA_STATUS_SLUG;
|
||||
let changed = false;
|
||||
const pages = Object.values(lists.statusPageList || {});
|
||||
if (!pages.some((page) => page.slug === slug)) {
|
||||
await callOk("addStatusPage", env.KUMA_STATUS_TITLE, slug);
|
||||
changed = true;
|
||||
}
|
||||
const { config } = await callOk("getStatusPage", slug);
|
||||
const domains = config.domainNameList || [];
|
||||
if (config.title !== env.KUMA_STATUS_TITLE || !domains.includes(env.KUMA_STATUS_DOMAIN)) {
|
||||
// saveStatusPage replaces all groups, so the current ones are sent back unchanged.
|
||||
const publicGroupList = await fetchPublicGroupList(slug);
|
||||
const updated = {
|
||||
...config,
|
||||
title: env.KUMA_STATUS_TITLE,
|
||||
domainNameList: domains.includes(env.KUMA_STATUS_DOMAIN) ? domains : [...domains, env.KUMA_STATUS_DOMAIN],
|
||||
};
|
||||
await callOk("saveStatusPage", slug, updated, config.icon, publicGroupList);
|
||||
changed = true;
|
||||
}
|
||||
report("status page", changed);
|
||||
}
|
||||
|
||||
async function rotateMetricsApiKey() {
|
||||
if (!rotateMetricsKey) {
|
||||
report("metrics api key", false);
|
||||
return;
|
||||
}
|
||||
for (const key of (lists.apiKeyList || []).filter((k) => k.name === "prometheus")) {
|
||||
await callOk("deleteAPIKey", key.id);
|
||||
}
|
||||
const res = await callOk("addAPIKey", { name: "prometheus", expires: null, active: 1 });
|
||||
fs.writeFileSync(env.KUMA_METRICS_KEY_FILE, res.key, { mode: 0o600 });
|
||||
report("metrics api key", true);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await waitForInfo();
|
||||
await setupAdmin();
|
||||
await login();
|
||||
await ensureSettings();
|
||||
await ensureNotification();
|
||||
await ensureStatusPage();
|
||||
await rotateMetricsApiKey();
|
||||
}
|
||||
|
||||
main().then(
|
||||
() => done(0),
|
||||
(err) => done(1, err.message)
|
||||
);
|
||||
|
||||
@@ -14,6 +14,33 @@
|
||||
register: _uptime_kuma_secret
|
||||
no_log: true
|
||||
|
||||
- name: Read SMTP credentials from OpenBao
|
||||
community.hashi_vault.vault_kv2_get:
|
||||
url: "{{ uptime_kuma_openbao_url }}"
|
||||
engine_mount_point: "{{ uptime_kuma_openbao_mount }}"
|
||||
path: "{{ uptime_kuma_openbao_smtp_path }}"
|
||||
auth_method: token
|
||||
delegate_to: localhost
|
||||
become: false
|
||||
run_once: true
|
||||
register: _uptime_kuma_smtp
|
||||
no_log: true
|
||||
|
||||
- name: Read metrics API key from OpenBao
|
||||
community.hashi_vault.vault_kv2_get:
|
||||
url: "{{ uptime_kuma_openbao_url }}"
|
||||
engine_mount_point: "{{ uptime_kuma_openbao_mount }}"
|
||||
path: "{{ uptime_kuma_openbao_metrics_path }}"
|
||||
auth_method: token
|
||||
delegate_to: localhost
|
||||
become: false
|
||||
run_once: true
|
||||
register: _uptime_kuma_metrics_secret
|
||||
failed_when:
|
||||
- _uptime_kuma_metrics_secret.failed | default(false)
|
||||
- "'Invalid or missing path' not in (_uptime_kuma_metrics_secret.msg | default(''))"
|
||||
no_log: true
|
||||
|
||||
- name: Ensure Uptime Kuma directories exist
|
||||
ansible.builtin.file:
|
||||
path: "{{ item }}"
|
||||
@@ -22,11 +49,12 @@
|
||||
loop:
|
||||
- "{{ uptime_kuma_dir }}"
|
||||
- "{{ uptime_kuma_dir }}/data"
|
||||
- "{{ uptime_kuma_dir }}/setup"
|
||||
|
||||
- name: Deploy kuma-setup.js
|
||||
ansible.builtin.copy:
|
||||
src: kuma-setup.js
|
||||
dest: "{{ uptime_kuma_dir }}/kuma-setup.js"
|
||||
dest: "{{ uptime_kuma_dir }}/setup/kuma-setup.js"
|
||||
mode: "0644"
|
||||
|
||||
- name: Check for setup marker
|
||||
@@ -54,6 +82,11 @@
|
||||
chdir: "{{ uptime_kuma_dir }}"
|
||||
changed_when: false
|
||||
|
||||
- name: Remove the script from its former single-file mount path
|
||||
ansible.builtin.file:
|
||||
path: "{{ uptime_kuma_dir }}/kuma-setup.js"
|
||||
state: absent
|
||||
|
||||
- name: Wait for Uptime Kuma on loopback
|
||||
ansible.builtin.uri:
|
||||
url: http://127.0.0.1:3001
|
||||
@@ -63,15 +96,80 @@
|
||||
retries: 30
|
||||
delay: 5
|
||||
|
||||
- name: Create the admin user if Uptime Kuma is not set up
|
||||
ansible.builtin.command: docker compose exec -T -e KUMA_SETUP_USERNAME -e KUMA_SETUP_PASSWORD uptime-kuma node /app/kuma-setup.js
|
||||
- name: Check the stored metrics API key against Uptime Kuma
|
||||
ansible.builtin.uri:
|
||||
url: http://127.0.0.1:3001/metrics
|
||||
headers:
|
||||
Authorization: "Basic {{ (':' ~ _uptime_kuma_metrics_key) | b64encode }}"
|
||||
status_code: [200, 401]
|
||||
vars:
|
||||
_uptime_kuma_metrics_key: "{{ (_uptime_kuma_metrics_secret.secret | default({})).api_key | default('') }}"
|
||||
when: _uptime_kuma_metrics_key | length > 0
|
||||
register: _uptime_kuma_metrics_check
|
||||
no_log: true
|
||||
|
||||
- name: Remove a leftover metrics API key handoff file
|
||||
ansible.builtin.file:
|
||||
path: "{{ uptime_kuma_dir }}/data/.metrics-api-key"
|
||||
state: absent
|
||||
|
||||
- name: Bootstrap Uptime Kuma
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- docker
|
||||
- compose
|
||||
- exec
|
||||
- -T
|
||||
- -e
|
||||
- KUMA_SETUP_USERNAME
|
||||
- -e
|
||||
- KUMA_SETUP_PASSWORD
|
||||
- -e
|
||||
- KUMA_BASE_URL
|
||||
- -e
|
||||
- KUMA_SMTP_HOST
|
||||
- -e
|
||||
- KUMA_SMTP_PORT
|
||||
- -e
|
||||
- KUMA_SMTP_USERNAME
|
||||
- -e
|
||||
- KUMA_SMTP_PASSWORD
|
||||
- -e
|
||||
- KUMA_SMTP_FROM
|
||||
- -e
|
||||
- KUMA_SMTP_TO
|
||||
- -e
|
||||
- KUMA_STATUS_SLUG
|
||||
- -e
|
||||
- KUMA_STATUS_TITLE
|
||||
- -e
|
||||
- KUMA_STATUS_DOMAIN
|
||||
- -e
|
||||
- KUMA_METRICS_ROTATE
|
||||
- -e
|
||||
- KUMA_METRICS_KEY_FILE
|
||||
- uptime-kuma
|
||||
- node
|
||||
- /app/setup/kuma-setup.js
|
||||
args:
|
||||
chdir: "{{ uptime_kuma_dir }}"
|
||||
environment:
|
||||
KUMA_SETUP_USERNAME: "{{ _uptime_kuma_secret.secret.username }}"
|
||||
KUMA_SETUP_PASSWORD: "{{ _uptime_kuma_secret.secret.password }}"
|
||||
KUMA_BASE_URL: "{{ uptime_kuma_base_url }}"
|
||||
KUMA_SMTP_HOST: "{{ _uptime_kuma_smtp.secret.server }}"
|
||||
KUMA_SMTP_PORT: "{{ _uptime_kuma_smtp.secret.port }}"
|
||||
KUMA_SMTP_USERNAME: "{{ _uptime_kuma_smtp.secret.username }}"
|
||||
KUMA_SMTP_PASSWORD: "{{ _uptime_kuma_smtp.secret.token }}"
|
||||
KUMA_SMTP_FROM: "{{ uptime_kuma_smtp_from }}"
|
||||
KUMA_SMTP_TO: "{{ uptime_kuma_smtp_to }}"
|
||||
KUMA_STATUS_SLUG: "{{ uptime_kuma_status_slug }}"
|
||||
KUMA_STATUS_TITLE: "{{ uptime_kuma_status_title }}"
|
||||
KUMA_STATUS_DOMAIN: "{{ uptime_kuma_status_host }}"
|
||||
KUMA_METRICS_ROTATE: "{{ 'false' if (_uptime_kuma_metrics_check.status | default(0)) == 200 else 'true' }}"
|
||||
KUMA_METRICS_KEY_FILE: /app/data/.metrics-api-key
|
||||
register: _uptime_kuma_setup
|
||||
changed_when: "'admin user created' in _uptime_kuma_setup.stdout"
|
||||
changed_when: "'admin user created' in _uptime_kuma_setup.stdout or 'changed:' in _uptime_kuma_setup.stdout"
|
||||
failed_when: false
|
||||
no_log: true
|
||||
|
||||
@@ -80,6 +178,33 @@
|
||||
msg: "kuma-setup.js failed: rc={{ _uptime_kuma_setup.rc | default('undefined') }} stdout={{ _uptime_kuma_setup.stdout | default('') }} stderr={{ _uptime_kuma_setup.stderr | default('') }}"
|
||||
when: (_uptime_kuma_setup.rc | default(1)) != 0
|
||||
|
||||
- name: Store the new metrics API key in OpenBao
|
||||
when: "'changed: metrics api key' in _uptime_kuma_setup.stdout"
|
||||
block:
|
||||
- name: Read the new metrics API key
|
||||
ansible.builtin.slurp:
|
||||
src: "{{ uptime_kuma_dir }}/data/.metrics-api-key"
|
||||
register: _uptime_kuma_new_metrics_key
|
||||
no_log: true
|
||||
|
||||
- name: Write the metrics API key to OpenBao
|
||||
community.hashi_vault.vault_kv2_write:
|
||||
url: "{{ uptime_kuma_openbao_url }}"
|
||||
engine_mount_point: "{{ uptime_kuma_openbao_mount }}"
|
||||
path: "{{ uptime_kuma_openbao_metrics_path }}"
|
||||
auth_method: token
|
||||
data:
|
||||
api_key: "{{ _uptime_kuma_new_metrics_key.content | b64decode }}"
|
||||
delegate_to: localhost
|
||||
become: false
|
||||
run_once: true
|
||||
no_log: true
|
||||
always:
|
||||
- name: Remove the metrics API key handoff file
|
||||
ansible.builtin.file:
|
||||
path: "{{ uptime_kuma_dir }}/data/.metrics-api-key"
|
||||
state: absent
|
||||
|
||||
- name: Record setup completion
|
||||
ansible.builtin.copy:
|
||||
content: ""
|
||||
|
||||
@@ -9,7 +9,8 @@ services:
|
||||
- "127.0.0.1:3001:3001"
|
||||
volumes:
|
||||
- {{ uptime_kuma_dir }}/data:/app/data
|
||||
- {{ uptime_kuma_dir }}/kuma-setup.js:/app/kuma-setup.js:ro
|
||||
# Directory mount: a single-file bind mount keeps the inode from container start.
|
||||
- {{ uptime_kuma_dir }}/setup:/app/setup:ro
|
||||
labels:
|
||||
- "traefik.enable={{ uptime_kuma_route_enabled | lower }}"
|
||||
- "traefik.http.routers.uptime-kuma.rule=Host(`{{ uptime_kuma_host }}`)"
|
||||
|
||||
@@ -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)."
|
||||
|
||||
@@ -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}"
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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,4 +1,4 @@
|
||||
module gitea.renner.uno/k.renner/homelab/kuma-sync
|
||||
module gitea.renner.uno/konairius/homelab/kuma-sync
|
||||
|
||||
go 1.27
|
||||
|
||||
|
||||
@@ -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, ¬ification.Webhook{
|
||||
Base: notification.Base{Name: "n", IsActive: true},
|
||||
nid, err := b.c.CreateNotification(context.Background(), ¬ification.Webhook{
|
||||
Base: notification.Base{Name: name, IsActive: true, IsDefault: isDefault},
|
||||
WebhookDetails: notification.WebhookDetails{
|
||||
WebhookURL: "http://example.com/hook", WebhookContentType: "application/json",
|
||||
},
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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,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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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\\./"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
@@ -1,4 +1,4 @@
|
||||
module gitea.renner.uno/k.renner/homelab/review-bot
|
||||
module gitea.renner.uno/konairius/homelab/review-bot
|
||||
|
||||
go 1.27
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user