Compare commits

...
Author SHA1 Message Date
claude-botandClaude Opus 5.5 ab1f004b48 fix(uptime-kuma): mount the bootstrap script directory, not the file
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: approve
A single-file bind mount pins the inode present at container start.
ansible.builtin.copy replaces the file by rename, so the running
container kept executing the previous kuma-setup.js: the #1358 deploy
ran the old admin-only script (host md5 8d77..., container 55a2...) and
created no notification, status page or metrics key.

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 08:23:44 +02:00
claude-bot fd2f3b7be4 fix(hermes): keep image automation off the retired date-versioned tags
review-bot reviewed: comment
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
nousresearch/hermes-agent still carries v2026.4.3..v2026.9.24 from the
old date-based scheme. As semver they sort above 0.x, so '>=0.22.2'
picked v2026.9.24 (2026-09-24) and rolled the pod back from v0.22.2.
Cap the range below 1.0.0 and pin the manifests back to v0.22.2.
2026-10-19 08:18:03 +02:00
k.renner e614c748b9 Merge pull request 'fix(kuma-sync): create when the previous monitor ID belongs to another owner' (#1354) from fix/kuma-sync-foreign-key-create into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: k.renner/homelab#1354
2026-10-19 06:14:48 +00:00
security-bot 30b0f43911 chore(image-automation): bump nousresearch/hermes-agent:v2026.9.24
Updates managed by image-automation-controller.
2026-10-19 06:13:48 +00:00
k.renner 10819fac62 Merge pull request 'feat(hermes): run Hermes Agent in the cluster' (#1352) from feat/hermes-agent into master
Reviewed-on: k.renner/homelab#1352
2026-10-19 06:13:17 +00:00
claude-bot dd954ce23a feat(hermes): run Hermes Agent in the cluster
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
review-bot reviewed: request changes
Hermes Agent as a pod in its own `hermes` namespace: gateway (messaging
platforms, cron, OpenAI-compatible API) and dashboard, supervised by the
image's s6-overlay, state on a 10Gi ceph-block PVC.

- hermes.renner.uno (LAN): dashboard and Hermes Desktop remote gateway.
  Plain Traefik plus the dashboard's own Authentik OIDC login (public PKCE
  client, Hermes Users group) — a forward-auth gate would break Desktop's
  Bearer-token calls.
- hermes-api.renner.uno (LAN): API server, Bearer key generated in-cluster
  by an ESO Password generator.
- Read-only kubectl via the built-in `view` ClusterRole.
- CNPs: world 443/80, kube-apiserver, Traefik for LAN hostnames, and
  Prometheus/Alertmanager/Loki (with the matching monitoring-side grant).
- Image automation for nousresearch/hermes-agent v-tags.

After deploy: log in to Anthropic inside the pod
(`hermes auth add anthropic --type oauth --no-browser`, then `hermes model`);
platform and Home Assistant tokens go in via the dashboard.
Not yet covered: a Velero schedule for the PVC.
2026-10-19 08:02:56 +02:00
29 changed files with 1260 additions and 63 deletions
+2
View File
@@ -5,6 +5,8 @@
# Prerequisites:
# - netbird-server play applied (docker, Traefik on netbird_netbird)
# - OpenBao homelab/infrastructure/uptime-kuma with keys username, password
# - OpenBao homelab/infrastructure/uptime-kuma/smtp with keys server, port, username, token
# - homelab/infrastructure/uptime-kuma-metrics (key api_key) is written by the role
# - VAULT_TOKEN in the environment (policy: uptime-kuma-vps)
# - DNS for kuma.renner.uno pointing at the VPS
+10 -1
View File
@@ -1,11 +1,20 @@
---
uptime_kuma_version: "2.5.6"
uptime_kuma_host: kuma.renner.uno
# Not routed yet; reserved for the public status page.
uptime_kuma_base_url: "https://{{ uptime_kuma_host }}"
# Custom domain of the status page; not routed by Traefik yet.
uptime_kuma_status_host: uptime.renner.uno
uptime_kuma_status_slug: renner
uptime_kuma_status_title: Renner
uptime_kuma_dir: /opt/uptime-kuma
uptime_kuma_traefik_network: netbird_netbird
uptime_kuma_smtp_from: uptime@renner.uno
# Alertmanager email receiver in kubernetes/apps/monitoring/helmrelease-prometheus.yaml.
uptime_kuma_smtp_to: admin@renner.uno
uptime_kuma_openbao_url: https://openbao.renner.uno
uptime_kuma_openbao_mount: homelab
uptime_kuma_openbao_path: infrastructure/uptime-kuma
uptime_kuma_openbao_smtp_path: infrastructure/uptime-kuma/smtp
uptime_kuma_openbao_metrics_path: infrastructure/uptime-kuma-metrics
+207 -28
View File
@@ -1,46 +1,225 @@
// Creates the Uptime Kuma admin user on first start; exits 0 if already set up.
// Credentials come from KUMA_SETUP_USERNAME / KUMA_SETUP_PASSWORD only.
// Bootstraps Uptime Kuma: admin user, general settings, SMTP notification, status page,
// and (when KUMA_METRICS_ROTATE=true) a fresh Prometheus API key written to KUMA_METRICS_KEY_FILE.
// All input comes from environment variables; secrets are never printed.
// Output lines: "admin user created" / "already set up", then "changed: <step>" or "ok: <step>".
const fs = require("fs");
const { io } = require("socket.io-client");
const username = process.env.KUMA_SETUP_USERNAME;
const password = process.env.KUMA_SETUP_PASSWORD;
if (!username || !password) {
console.error("KUMA_SETUP_USERNAME and KUMA_SETUP_PASSWORD must be set");
const KUMA_URL = "http://127.0.0.1:3001";
const EMIT_TIMEOUT_MS = 30000;
const TOTAL_TIMEOUT_MS = 180000;
// Defaults the Kuma settings page applies to unset keys before saving (src/pages/Settings.vue).
const SETTINGS_DEFAULTS = {
checkUpdate: true,
searchEngineIndex: false,
entryPage: "dashboard",
nscd: true,
keepDataPeriodDays: 180,
tlsExpiryNotifyDays: [7, 14, 21],
domainExpiryNotifyDays: [7, 14, 21],
trustProxy: false,
};
const REQUIRED = [
"KUMA_SETUP_USERNAME",
"KUMA_SETUP_PASSWORD",
"KUMA_BASE_URL",
"KUMA_SMTP_HOST",
"KUMA_SMTP_PORT",
"KUMA_SMTP_USERNAME",
"KUMA_SMTP_PASSWORD",
"KUMA_SMTP_FROM",
"KUMA_SMTP_TO",
"KUMA_STATUS_SLUG",
"KUMA_STATUS_TITLE",
"KUMA_STATUS_DOMAIN",
];
const missing = REQUIRED.filter((name) => !process.env[name]);
const rotateMetricsKey = process.env.KUMA_METRICS_ROTATE === "true";
if (rotateMetricsKey && !process.env.KUMA_METRICS_KEY_FILE) {
missing.push("KUMA_METRICS_KEY_FILE");
}
if (missing.length > 0) {
console.error(`missing environment variables: ${missing.join(", ")}`);
process.exit(2);
}
const env = process.env;
const socket = io("http://127.0.0.1:3001", { transports: ["websocket"], reconnection: false });
const timer = setTimeout(() => {
console.error("timeout: no response from Uptime Kuma within 30s");
process.exit(1);
}, 30000);
const socket = io(KUMA_URL, { transports: ["websocket"], reconnection: false });
const lists = {};
const LIST_EVENTS = ["notificationList", "apiKeyList", "statusPageList"];
for (const event of LIST_EVENTS) {
socket.on(event, (data) => {
lists[event] = data;
});
}
let finished = false;
function done(code, msg) {
if (finished) return;
finished = true;
clearTimeout(timer);
(code === 0 ? console.log : console.error)(msg);
clearTimeout(watchdog);
if (msg) (code === 0 ? console.log : console.error)(msg);
socket.close();
process.exit(code);
}
const watchdog = setTimeout(() => done(1, `timeout: bootstrap did not finish within ${TOTAL_TIMEOUT_MS / 1000}s`), TOTAL_TIMEOUT_MS);
socket.on("connect_error", (err) => done(1, `connect_error: ${err.message}`));
socket.on("disconnect", (reason) => done(1, `disconnected before setup finished: ${reason}`));
socket.on("disconnect", (reason) => done(1, `disconnected before bootstrap finished: ${reason}`));
// Kuma registers socket handlers only after emitting info.
socket.once("info", () => {
socket.emit("needSetup", (needSetup) => {
if (!needSetup) {
done(0, "already set up");
return;
}
socket.emit("setup", username, password, (res) => {
if (res && res.ok) {
done(0, "admin user created");
} else {
done(1, `setup failed: ${res && res.msg ? res.msg : JSON.stringify(res)}`);
}
function call(event, ...args) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`timeout waiting for ${event}`)), EMIT_TIMEOUT_MS);
socket.emit(event, ...args, (res) => {
clearTimeout(timer);
resolve(res);
});
});
});
}
async function callOk(event, ...args) {
const res = await call(event, ...args);
if (!res || !res.ok) {
throw new Error(`${event} failed: ${res && res.msg ? res.msg : JSON.stringify(res)}`);
}
return res;
}
function report(step, changed) {
console.log(`${changed ? "changed" : "ok"}: ${step}`);
}
// Kuma registers socket handlers only after emitting info.
function waitForInfo() {
return new Promise((resolve) => socket.once("info", resolve));
}
async function setupAdmin() {
const needSetup = await call("needSetup");
if (!needSetup) {
console.log("already set up");
return;
}
await callOk("setup", env.KUMA_SETUP_USERNAME, env.KUMA_SETUP_PASSWORD);
console.log("admin user created");
}
async function login() {
await callOk("login", { username: env.KUMA_SETUP_USERNAME, password: env.KUMA_SETUP_PASSWORD, token: "" });
// afterLogin sends the lists before the login ack but swallows send failures.
for (const event of LIST_EVENTS) {
if (lists[event] === undefined) {
throw new Error(`${event} not received after login`);
}
}
}
async function ensureSettings() {
const { data } = await callOk("getSettings");
if (data.trustProxy === true && data.primaryBaseURL === env.KUMA_BASE_URL) {
report("settings", false);
return;
}
const updated = { ...SETTINGS_DEFAULTS, ...data, trustProxy: true, primaryBaseURL: env.KUMA_BASE_URL };
await callOk("setSettings", updated, "");
report("settings", true);
}
async function ensureNotification() {
const port = Number(env.KUMA_SMTP_PORT);
const desired = {
name: "email",
type: "smtp",
isDefault: true,
smtpHost: env.KUMA_SMTP_HOST,
smtpPort: port,
// 465 is implicit TLS; any other port uses STARTTLS.
smtpSecure: port === 465,
smtpIgnoreSTARTTLS: false,
smtpUsername: env.KUMA_SMTP_USERNAME,
smtpPassword: env.KUMA_SMTP_PASSWORD,
smtpFrom: env.KUMA_SMTP_FROM,
smtpTo: env.KUMA_SMTP_TO,
};
const existing = (lists.notificationList || []).filter((n) => n.name === desired.name).sort((a, b) => a.id - b.id)[0];
const current = existing ? JSON.parse(existing.config || "{}") : {};
const upToDate =
existing && existing.isDefault === true && Object.keys(desired).every((key) => current[key] === desired[key]);
if (upToDate) {
report("notification", false);
return;
}
await callOk("addNotification", { ...current, ...desired, applyExisting: true }, existing ? existing.id : null);
report("notification", true);
}
async function fetchPublicGroupList(slug) {
const res = await fetch(`${KUMA_URL}/api/status-page/${encodeURIComponent(slug)}`);
if (!res.ok) {
throw new Error(`GET /api/status-page/${slug} returned ${res.status}`);
}
const body = await res.json();
return (body.publicGroupList || []).map((group) => ({
id: group.id,
name: group.name,
monitorList: (group.monitorList || []).map((monitor) => {
const entry = { id: monitor.id };
if (monitor.sendUrl !== undefined) entry.sendUrl = monitor.sendUrl;
if (monitor.url !== undefined) entry.url = monitor.url;
return entry;
}),
}));
}
async function ensureStatusPage() {
const slug = env.KUMA_STATUS_SLUG;
let changed = false;
const pages = Object.values(lists.statusPageList || {});
if (!pages.some((page) => page.slug === slug)) {
await callOk("addStatusPage", env.KUMA_STATUS_TITLE, slug);
changed = true;
}
const { config } = await callOk("getStatusPage", slug);
const domains = config.domainNameList || [];
if (config.title !== env.KUMA_STATUS_TITLE || !domains.includes(env.KUMA_STATUS_DOMAIN)) {
// saveStatusPage replaces all groups, so the current ones are sent back unchanged.
const publicGroupList = await fetchPublicGroupList(slug);
const updated = {
...config,
title: env.KUMA_STATUS_TITLE,
domainNameList: domains.includes(env.KUMA_STATUS_DOMAIN) ? domains : [...domains, env.KUMA_STATUS_DOMAIN],
};
await callOk("saveStatusPage", slug, updated, config.icon, publicGroupList);
changed = true;
}
report("status page", changed);
}
async function rotateMetricsApiKey() {
if (!rotateMetricsKey) {
report("metrics api key", false);
return;
}
for (const key of (lists.apiKeyList || []).filter((k) => k.name === "prometheus")) {
await callOk("deleteAPIKey", key.id);
}
const res = await callOk("addAPIKey", { name: "prometheus", expires: null, active: 1 });
fs.writeFileSync(env.KUMA_METRICS_KEY_FILE, res.key, { mode: 0o600 });
report("metrics api key", true);
}
async function main() {
await waitForInfo();
await setupAdmin();
await login();
await ensureSettings();
await ensureNotification();
await ensureStatusPage();
await rotateMetricsApiKey();
}
main().then(
() => done(0),
(err) => done(1, err.message)
);
+129 -4
View File
@@ -14,6 +14,33 @@
register: _uptime_kuma_secret
no_log: true
- name: Read SMTP credentials from OpenBao
community.hashi_vault.vault_kv2_get:
url: "{{ uptime_kuma_openbao_url }}"
engine_mount_point: "{{ uptime_kuma_openbao_mount }}"
path: "{{ uptime_kuma_openbao_smtp_path }}"
auth_method: token
delegate_to: localhost
become: false
run_once: true
register: _uptime_kuma_smtp
no_log: true
- name: Read metrics API key from OpenBao
community.hashi_vault.vault_kv2_get:
url: "{{ uptime_kuma_openbao_url }}"
engine_mount_point: "{{ uptime_kuma_openbao_mount }}"
path: "{{ uptime_kuma_openbao_metrics_path }}"
auth_method: token
delegate_to: localhost
become: false
run_once: true
register: _uptime_kuma_metrics_secret
failed_when:
- _uptime_kuma_metrics_secret.failed | default(false)
- "'Invalid or missing path' not in (_uptime_kuma_metrics_secret.msg | default(''))"
no_log: true
- name: Ensure Uptime Kuma directories exist
ansible.builtin.file:
path: "{{ item }}"
@@ -22,11 +49,12 @@
loop:
- "{{ uptime_kuma_dir }}"
- "{{ uptime_kuma_dir }}/data"
- "{{ uptime_kuma_dir }}/setup"
- name: Deploy kuma-setup.js
ansible.builtin.copy:
src: kuma-setup.js
dest: "{{ uptime_kuma_dir }}/kuma-setup.js"
dest: "{{ uptime_kuma_dir }}/setup/kuma-setup.js"
mode: "0644"
- name: Check for setup marker
@@ -54,6 +82,11 @@
chdir: "{{ uptime_kuma_dir }}"
changed_when: false
- name: Remove the script from its former single-file mount path
ansible.builtin.file:
path: "{{ uptime_kuma_dir }}/kuma-setup.js"
state: absent
- name: Wait for Uptime Kuma on loopback
ansible.builtin.uri:
url: http://127.0.0.1:3001
@@ -63,15 +96,80 @@
retries: 30
delay: 5
- name: Create the admin user if Uptime Kuma is not set up
ansible.builtin.command: docker compose exec -T -e KUMA_SETUP_USERNAME -e KUMA_SETUP_PASSWORD uptime-kuma node /app/kuma-setup.js
- name: Check the stored metrics API key against Uptime Kuma
ansible.builtin.uri:
url: http://127.0.0.1:3001/metrics
headers:
Authorization: "Basic {{ (':' ~ _uptime_kuma_metrics_key) | b64encode }}"
status_code: [200, 401]
vars:
_uptime_kuma_metrics_key: "{{ (_uptime_kuma_metrics_secret.secret | default({})).api_key | default('') }}"
when: _uptime_kuma_metrics_key | length > 0
register: _uptime_kuma_metrics_check
no_log: true
- name: Remove a leftover metrics API key handoff file
ansible.builtin.file:
path: "{{ uptime_kuma_dir }}/data/.metrics-api-key"
state: absent
- name: Bootstrap Uptime Kuma
ansible.builtin.command:
argv:
- docker
- compose
- exec
- -T
- -e
- KUMA_SETUP_USERNAME
- -e
- KUMA_SETUP_PASSWORD
- -e
- KUMA_BASE_URL
- -e
- KUMA_SMTP_HOST
- -e
- KUMA_SMTP_PORT
- -e
- KUMA_SMTP_USERNAME
- -e
- KUMA_SMTP_PASSWORD
- -e
- KUMA_SMTP_FROM
- -e
- KUMA_SMTP_TO
- -e
- KUMA_STATUS_SLUG
- -e
- KUMA_STATUS_TITLE
- -e
- KUMA_STATUS_DOMAIN
- -e
- KUMA_METRICS_ROTATE
- -e
- KUMA_METRICS_KEY_FILE
- uptime-kuma
- node
- /app/setup/kuma-setup.js
args:
chdir: "{{ uptime_kuma_dir }}"
environment:
KUMA_SETUP_USERNAME: "{{ _uptime_kuma_secret.secret.username }}"
KUMA_SETUP_PASSWORD: "{{ _uptime_kuma_secret.secret.password }}"
KUMA_BASE_URL: "{{ uptime_kuma_base_url }}"
KUMA_SMTP_HOST: "{{ _uptime_kuma_smtp.secret.server }}"
KUMA_SMTP_PORT: "{{ _uptime_kuma_smtp.secret.port }}"
KUMA_SMTP_USERNAME: "{{ _uptime_kuma_smtp.secret.username }}"
KUMA_SMTP_PASSWORD: "{{ _uptime_kuma_smtp.secret.token }}"
KUMA_SMTP_FROM: "{{ uptime_kuma_smtp_from }}"
KUMA_SMTP_TO: "{{ uptime_kuma_smtp_to }}"
KUMA_STATUS_SLUG: "{{ uptime_kuma_status_slug }}"
KUMA_STATUS_TITLE: "{{ uptime_kuma_status_title }}"
KUMA_STATUS_DOMAIN: "{{ uptime_kuma_status_host }}"
KUMA_METRICS_ROTATE: "{{ 'false' if (_uptime_kuma_metrics_check.status | default(0)) == 200 else 'true' }}"
KUMA_METRICS_KEY_FILE: /app/data/.metrics-api-key
register: _uptime_kuma_setup
changed_when: "'admin user created' in _uptime_kuma_setup.stdout"
changed_when: "'admin user created' in _uptime_kuma_setup.stdout or 'changed:' in _uptime_kuma_setup.stdout"
failed_when: false
no_log: true
@@ -80,6 +178,33 @@
msg: "kuma-setup.js failed: rc={{ _uptime_kuma_setup.rc | default('undefined') }} stdout={{ _uptime_kuma_setup.stdout | default('') }} stderr={{ _uptime_kuma_setup.stderr | default('') }}"
when: (_uptime_kuma_setup.rc | default(1)) != 0
- name: Store the new metrics API key in OpenBao
when: "'changed: metrics api key' in _uptime_kuma_setup.stdout"
block:
- name: Read the new metrics API key
ansible.builtin.slurp:
src: "{{ uptime_kuma_dir }}/data/.metrics-api-key"
register: _uptime_kuma_new_metrics_key
no_log: true
- name: Write the metrics API key to OpenBao
community.hashi_vault.vault_kv2_write:
url: "{{ uptime_kuma_openbao_url }}"
engine_mount_point: "{{ uptime_kuma_openbao_mount }}"
path: "{{ uptime_kuma_openbao_metrics_path }}"
auth_method: token
data:
api_key: "{{ _uptime_kuma_new_metrics_key.content | b64decode }}"
delegate_to: localhost
become: false
run_once: true
no_log: true
always:
- name: Remove the metrics API key handoff file
ansible.builtin.file:
path: "{{ uptime_kuma_dir }}/data/.metrics-api-key"
state: absent
- name: Record setup completion
ansible.builtin.copy:
content: ""
@@ -9,7 +9,8 @@ services:
- "127.0.0.1:3001:3001"
volumes:
- {{ uptime_kuma_dir }}/data:/app/data
- {{ uptime_kuma_dir }}/kuma-setup.js:/app/kuma-setup.js:ro
# Directory mount: a single-file bind mount keeps the inode from container start.
- {{ uptime_kuma_dir }}/setup:/app/setup:ro
labels:
- "traefik.enable={{ uptime_kuma_route_enabled | lower }}"
- "traefik.http.routers.uptime-kuma.rule=Host(`{{ uptime_kuma_host }}`)"
@@ -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"
@@ -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) {
@@ -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"
@@ -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)
@@ -13,6 +13,7 @@ import (
kuma "github.com/breml/go-uptime-kuma-client"
"github.com/breml/go-uptime-kuma-client/monitor"
"github.com/breml/go-uptime-kuma-client/notification"
"github.com/breml/go-uptime-kuma-client/tag"
)
@@ -373,8 +374,8 @@ func (b *Breml) update(ctx context.Context, m Monitor) error {
return fmt.Errorf("update %d: monitor carries owner key %q, not %q: %w", m.ID, key, m.OwnerKey, ErrNotOwned)
}
// Start from the full current object, also across a type change, so
// fields we do not manage (notifications, proxy, resend interval, upside
// down, auth) survive the overwrite.
// fields we do not manage (proxy, resend interval, upside down, auth)
// survive the overwrite.
wire, err := toWire(m, &cur)
if err != nil {
return err
@@ -404,18 +405,42 @@ func (b *Breml) Delete(ctx context.Context, id int64) error {
return err
}
// DefaultNotifications reads the notification list cached by the session.
// Kuma has no command to request it: it pushes the list at login and to every
// session of the user after each change.
func (b *Breml) DefaultNotifications(ctx context.Context) ([]int64, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
b.mu.Lock()
defer b.mu.Unlock()
return defaultIDs(b.c.GetNotifications(ctx)), nil
}
func defaultIDs(list []notification.Base) []int64 {
var ids []int64
for _, n := range list {
if n.IsDefault {
ids = append(ids, n.ID)
}
}
slices.Sort(ids)
return ids
}
// toDomain converts a monitor from the Kuma monitor list.
func toDomain(base monitor.Base, ownerKey string) (Monitor, error) {
m := Monitor{
ID: base.ID,
Type: base.Type(),
Name: base.Name,
Parent: base.Parent,
Active: base.IsActive,
Interval: int32(base.Interval),
RetryInterval: int32(base.RetryInterval),
MaxRetries: int32(base.MaxRetries),
OwnerKey: ownerKey,
ID: base.ID,
Type: base.Type(),
Name: base.Name,
Parent: base.Parent,
Active: base.IsActive,
Interval: int32(base.Interval),
RetryInterval: int32(base.RetryInterval),
MaxRetries: int32(base.MaxRetries),
NotificationIDs: slices.Clone(base.NotificationIDs),
OwnerKey: ownerKey,
}
if base.Description != nil {
m.Description = *base.Description
@@ -565,6 +590,7 @@ func writeBase(b *monitor.Base, m Monitor) {
b.RetryInterval = int64(m.RetryInterval)
b.MaxRetries = int64(m.MaxRetries)
b.IsActive = m.Active
b.NotificationIDs = slices.Clone(m.NotificationIDs)
}
// writeHTTP expects m to be normalized.
@@ -287,12 +287,10 @@ func TestUpdateKeepsUnchangedFields(t *testing.T) {
}
}
// setUnmanaged sets state through the raw library that Monitor does not model.
func setUnmanaged(t *testing.T, b *Breml, id int64) (notificationID int64) {
func createWebhook(t *testing.T, b *Breml, name string, isDefault bool) int64 {
t.Helper()
ctx := context.Background()
nid, err := b.c.CreateNotification(ctx, &notification.Webhook{
Base: notification.Base{Name: "n", IsActive: true},
nid, err := b.c.CreateNotification(context.Background(), &notification.Webhook{
Base: notification.Base{Name: name, IsActive: true, IsDefault: isDefault},
WebhookDetails: notification.WebhookDetails{
WebhookURL: "http://example.com/hook", WebhookContentType: "application/json",
},
@@ -300,6 +298,15 @@ func setUnmanaged(t *testing.T, b *Breml, id int64) (notificationID int64) {
if err != nil {
t.Fatalf("create notification: %v", err)
}
return nid
}
// setUnmanaged sets state through the raw library that Monitor does not
// model, and attaches a notification by hand.
func setUnmanaged(t *testing.T, b *Breml, id int64) (notificationID int64) {
t.Helper()
ctx := context.Background()
nid := createWebhook(t, b, "n", false)
var kw monitor.HTTPKeyword
if err := b.c.GetMonitorAs(ctx, id, &kw); err != nil {
t.Fatal(err)
@@ -313,16 +320,15 @@ func setUnmanaged(t *testing.T, b *Breml, id int64) (notificationID int64) {
return nid
}
func assertUnmanaged(t *testing.T, c *Breml, id, nid int64, when string) {
func assertUnmanaged(t *testing.T, c *Breml, id int64, when string) {
t.Helper()
var base monitor.Base
base, err := c.c.GetMonitor(t.Context(), id)
if err != nil {
t.Fatal(err)
}
if !base.UpsideDown || base.ResendInterval != 7 || !reflect.DeepEqual(base.NotificationIDs, []int64{nid}) {
t.Errorf("%s: unmanaged state lost: upsideDown=%v resend=%d notifications=%v",
when, base.UpsideDown, base.ResendInterval, base.NotificationIDs)
if !base.UpsideDown || base.ResendInterval != 7 {
t.Errorf("%s: unmanaged state lost: upsideDown=%v resend=%d", when, base.UpsideDown, base.ResendInterval)
}
}
@@ -362,14 +368,15 @@ func TestUpdatePreservesUnmanagedAndClearsManaged(t *testing.T) {
t.Fatal(err)
}
m := byID(got)[id]
if m.Parent != nil || m.Headers != nil || m.Keyword != "" || m.Interval != 90 {
if m.Parent != nil || m.Headers != nil || m.Keyword != "" || m.Interval != 90 || m.NotificationIDs != nil {
t.Errorf("managed fields not cleared: %+v", m)
}
assertUnmanaged(t, fresh, id, nid, "after plain update")
assertUnmanaged(t, fresh, id, "after plain update")
// Type change must not wipe unmanaged state either.
changed := cleared
changed.Type = "http"
changed.NotificationIDs = []int64{nid}
if err := b.Update(ctx, changed); err != nil {
t.Fatal(err)
}
@@ -377,10 +384,55 @@ func TestUpdatePreservesUnmanagedAndClearsManaged(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if m := byID(got)[id]; m.Type != "http" {
t.Errorf("type = %q, want http", m.Type)
if m := byID(got)[id]; m.Type != "http" || !reflect.DeepEqual(m.NotificationIDs, []int64{nid}) {
t.Errorf("type = %q, notifications = %v, want http and [%d]", m.Type, m.NotificationIDs, nid)
}
assertUnmanaged(t, fresh, id, "after type change")
}
func TestDefaultNotificationsAndWrite(t *testing.T) {
ctx := context.Background()
url := startKuma(t)
b := setupClient(t, url)
other := freshClient(t, url)
if ids, err := other.DefaultNotifications(ctx); err != nil || ids != nil {
t.Fatalf("no notifications: ids=%v err=%v", ids, err)
}
def := createWebhook(t, b, "default", true)
createWebhook(t, b, "opt-in", false)
// Kuma pushes the list to every session of the user, not only the writer.
deadline := time.Now().Add(10 * time.Second)
for {
ids, err := other.DefaultNotifications(ctx)
if err != nil {
t.Fatal(err)
}
if reflect.DeepEqual(ids, []int64{def}) {
break
}
if time.Now().After(deadline) {
t.Fatalf("other session sees defaults %v, want [%d]", ids, def)
}
time.Sleep(100 * time.Millisecond)
}
m := Monitor{
Type: "group", Name: "grp", Active: true, Interval: 60, RetryInterval: 60,
NotificationIDs: []int64{def}, OwnerKey: "default/grp",
}
id, err := b.Create(ctx, m)
if err != nil {
t.Fatal(err)
}
got, err := freshClient(t, url).ListOwned(ctx)
if err != nil {
t.Fatal(err)
}
m.ID = id
if !reflect.DeepEqual(byID(got)[id], m.Normalized()) {
t.Errorf("read-back differs:\n got %+v\n want %+v", byID(got)[id], m.Normalized())
}
assertUnmanaged(t, fresh, id, nid, "after type change")
}
func TestRestartAdoptsByTag(t *testing.T) {
@@ -63,6 +63,7 @@ func Conflicts(err error) (conflicts []*ConflictError, only bool) {
func (m Monitor) clone() Monitor {
m.Headers = maps.Clone(m.Headers)
m.AcceptedStatusCodes = slices.Clone(m.AcceptedStatusCodes)
m.NotificationIDs = slices.Clone(m.NotificationIDs)
if m.Parent != nil {
p := *m.Parent
m.Parent = &p
@@ -80,12 +81,18 @@ func isHTTPFamily(typ string) bool {
// For http, keyword and json-query: an empty Method becomes GET, empty
// AcceptedStatusCodes become ["200-299"], and Timeout 0 becomes
// round(0.8*Interval). For all other types Timeout is stored as 0 and the
// HTTP-only slices are nil. Empty Headers become nil. It is idempotent.
// HTTP-only slices are nil. Empty Headers become nil. NotificationIDs are
// sorted and deduplicated, empty becomes nil. It is idempotent.
func (m Monitor) Normalized() Monitor {
m = m.clone()
if len(m.Headers) == 0 {
m.Headers = nil
}
slices.Sort(m.NotificationIDs)
m.NotificationIDs = slices.Compact(m.NotificationIDs)
if len(m.NotificationIDs) == 0 {
m.NotificationIDs = nil
}
if len(m.AcceptedStatusCodes) == 0 {
m.AcceptedStatusCodes = nil
}
@@ -143,6 +150,9 @@ type Monitor struct {
DNSResolveType string
DNSResolveServer string
// NotificationIDs are the Kuma notifications attached to the monitor.
NotificationIDs []int64
// OwnerKey is the value of the kuma-sync tag; "" means not ours.
OwnerKey string
}
@@ -170,4 +180,7 @@ type Client interface {
// Delete removes an owned monitor. It wraps ErrNotFound for an unknown ID
// and ErrNotOwned for a monitor without the kuma-sync tag.
Delete(ctx context.Context, id int64) error
// DefaultNotifications returns the IDs of all notifications marked
// "default enabled" in Kuma, sorted. An empty result is valid.
DefaultNotifications(ctx context.Context) ([]int64, error)
}
@@ -3,6 +3,7 @@ package kumaclient
import (
"context"
"fmt"
"slices"
"sort"
"sync"
)
@@ -20,6 +21,10 @@ type Fake struct {
UpdateErr error
DeleteErr error
ProbeErr error
// NotificationsErr is returned by DefaultNotifications.
NotificationsErr error
// Defaults are the IDs DefaultNotifications returns.
Defaults []int64
// ListPartial makes ListOwned return the stored monitors together with
// ListErr, as the real client does for owner conflicts.
ListPartial bool
@@ -90,6 +95,32 @@ func (f *Fake) Probe(_ context.Context, id int64) (bool, string, error) {
return ok, m.OwnerKey, nil
}
// SetDefaultNotifications sets the IDs DefaultNotifications returns.
func (f *Fake) SetDefaultNotifications(ids ...int64) {
f.mu.Lock()
defer f.mu.Unlock()
f.Defaults = slices.Clone(ids)
}
// SetNotificationsErr makes DefaultNotifications return err.
func (f *Fake) SetNotificationsErr(err error) {
f.mu.Lock()
defer f.mu.Unlock()
f.NotificationsErr = err
}
// DefaultNotifications returns Defaults, sorted.
func (f *Fake) DefaultNotifications(_ context.Context) ([]int64, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.NotificationsErr != nil {
return nil, f.NotificationsErr
}
ids := slices.Clone(f.Defaults)
slices.Sort(ids)
return ids, nil
}
func (f *Fake) allocID() int64 {
id := f.nextID
f.nextID++
@@ -118,6 +118,21 @@ func TestFakeInjectedErrors(t *testing.T) {
}
}
func TestFakeDefaultNotifications(t *testing.T) {
f := NewFake()
if ids, err := f.DefaultNotifications(context.Background()); err != nil || ids != nil {
t.Fatalf("empty: %v %v", ids, err)
}
f.SetDefaultNotifications(4, 1)
if ids, err := f.DefaultNotifications(context.Background()); err != nil || len(ids) != 2 || ids[0] != 1 || ids[1] != 4 {
t.Fatalf("set: %v %v", ids, err)
}
f.SetNotificationsErr(errors.New("boom"))
if _, err := f.DefaultNotifications(context.Background()); err == nil {
t.Error("expected injected error")
}
}
func TestFakeUpdateRejectsOtherOwnerKey(t *testing.T) {
f := NewFake()
id, _ := f.Create(context.Background(), Monitor{Name: "a", OwnerKey: "ns/a"})
@@ -7,6 +7,7 @@ import (
"testing"
kuma "github.com/breml/go-uptime-kuma-client"
"github.com/breml/go-uptime-kuma-client/notification"
"github.com/breml/go-uptime-kuma-client/tag"
)
@@ -167,3 +168,29 @@ func TestFakeStoresNormalized(t *testing.T) {
t.Fatalf("fake did not normalize: %+v", got)
}
}
func TestNormalizedNotificationIDs(t *testing.T) {
in := Monitor{Type: "group", NotificationIDs: []int64{10, 2, 10}}
got := in.Normalized()
if !reflect.DeepEqual(got.NotificationIDs, []int64{2, 10}) {
t.Errorf("notifications = %v, want [2 10]", got.NotificationIDs)
}
if !reflect.DeepEqual(in.NotificationIDs, []int64{10, 2, 10}) {
t.Errorf("Normalized changed its receiver: %v", in.NotificationIDs)
}
if e := (Monitor{Type: "group", NotificationIDs: []int64{}}).Normalized(); e.NotificationIDs != nil {
t.Errorf("empty notifications must be nil: %v", e.NotificationIDs)
}
}
func TestDefaultIDs(t *testing.T) {
got := defaultIDs([]notification.Base{
{ID: 5, IsDefault: true}, {ID: 1}, {ID: 3, IsDefault: true},
})
if !reflect.DeepEqual(got, []int64{3, 5}) {
t.Errorf("defaultIDs = %v, want [3 5]", got)
}
if got := defaultIDs(nil); got != nil {
t.Errorf("defaultIDs(nil) = %v, want nil", got)
}
}
@@ -974,6 +974,71 @@ data:
negate: false
failure_result: false
# Hermes Agent dashboard (apps/hermes): its self-hosted OIDC login,
# also brokered for Hermes Desktop's native sign-in. Public client +
# PKCE (Hermes does not support confidential clients); RS256 so the
# dashboard can verify the ID token against the JWKS.
- id: hermes-users-group
model: authentik_core.group
identifiers:
name: Hermes Users
state: present
attrs:
is_superuser: false
# Members get the agent, which holds a read-only kubeconfig and
# the model login. Add/remove members HERE.
users:
- !Find [authentik_core.user, [username, k.renner]]
- id: hermes-provider
model: authentik_providers_oauth2.oauth2provider
identifiers:
name: Hermes Dashboard
state: present
attrs:
authentication_flow: null
authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
client_type: public
client_id: hermes-dashboard
# See the Matrix (MAS) provider: a blueprint-made provider starts
# with no grant types.
grant_types:
- authorization_code
- refresh_token
signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]]
property_mappings:
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-openid]]
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-profile]]
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-email]]
redirect_uris:
- url: https://hermes.renner.uno/auth/callback
matching_mode: strict
# The slug is the issuer path: https://auth.renner.uno/application/o/hermes/
- model: authentik_core.application
identifiers:
slug: hermes
state: present
attrs:
name: Hermes
slug: hermes
provider: !KeyOf hermes-provider
meta_launch_url: https://hermes.renner.uno
meta_description: "Hermes Agent dashboard"
# Only the Hermes Users group passes.
- model: authentik_policies.policybinding
identifiers:
target: !Find [authentik_core.application, [slug, hermes]]
group: !KeyOf hermes-users-group
order: 0
state: present
attrs:
enabled: true
negate: false
failure_result: false
# OpenProject, decommissioned. Application first: Application.provider
# is on_delete=PROTECT. Its policy binding cascades with it.
- model: authentik_core.application
+32
View File
@@ -0,0 +1,32 @@
# Bearer key for the OpenAI-compatible API server (hermes-api.renner.uno).
# Generated once in-cluster, nothing in OpenBao. Read it with
# kubectl -n hermes get secret hermes-api-key -o jsonpath='{.data.API_SERVER_KEY}' | base64 -d
apiVersion: generators.external-secrets.io/v1alpha1
kind: Password
metadata:
name: hermes-api-key
namespace: hermes
spec:
length: 64
digits: 16
symbols: 0
allowRepeat: true
noUpper: false
secretKeys:
- API_SERVER_KEY
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: hermes-api-key
namespace: hermes
spec:
refreshPolicy: CreatedOnce
target:
name: hermes-api-key
dataFrom:
- sourceRef:
generatorRef:
apiVersion: generators.external-secrets.io/v1alpha1
kind: Password
name: hermes-api-key
+170
View File
@@ -0,0 +1,170 @@
# All Hermes state: config.yaml, .env (keys entered in the dashboard), auth.json
# (the Anthropic OAuth login), sessions, memories, skills, cron. Mounted at
# /opt/data, the image's HERMES_HOME.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: hermes-data
namespace: hermes
spec:
accessModes:
- ReadWriteOnce
storageClassName: ceph-block
resources:
requests:
storage: 10Gi
---
# Hermes Agent: the gateway (messaging platforms, cron, API server on 8642) and
# the dashboard (9119, also the Hermes Desktop remote-gateway backend), both
# supervised by the image's s6-overlay. s6 must be PID 1 and starts as root,
# remapping and dropping to the `hermes` user (10000).
#
# One replica, Recreate: two gateways must never share one data directory.
apiVersion: apps/v1
kind: Deployment
metadata:
name: hermes
namespace: hermes
labels:
app.kubernetes.io/name: hermes
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: hermes
template:
metadata:
labels:
app.kubernetes.io/name: hermes
spec:
serviceAccountName: hermes
securityContext:
fsGroup: 10000
seccompProfile:
type: RuntimeDefault
initContainers:
# The dashboard trusts X-Forwarded-* only from listed peers; Traefik
# connects from the pod network. No env equivalent exists, so it is
# set in config.yaml on every start (idempotent).
- name: config
image: nousresearch/hermes-agent:v0.22.2 # {"$imagepolicy": "flux-system:hermes"}
command:
- /opt/hermes/.venv/bin/hermes
- config
- set
- dashboard.trusted_proxies
- '["10.244.0.0/16","fd10:244::/48"]'
env:
- name: HERMES_HOME
value: /opt/data
- name: HOME
value: /opt/data
securityContext:
runAsUser: 10000
runAsGroup: 10000
runAsNonRoot: true
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
volumeMounts:
- name: data
mountPath: /opt/data
# 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:v0.22.2 # {"$imagepolicy": "flux-system:hermes"}
args: ["gateway", "run"]
env:
- name: HERMES_DASHBOARD
value: "1"
- name: HERMES_DASHBOARD_PUBLIC_URL
value: https://hermes.renner.uno
# Dashboard login: Authentik OIDC, public PKCE client (blueprint
# in apps/authentik/blueprints-configmap.yaml, group Hermes Users).
- name: HERMES_DASHBOARD_OIDC_ISSUER
value: https://auth.renner.uno/application/o/hermes/
- name: HERMES_DASHBOARD_OIDC_CLIENT_ID
value: hermes-dashboard
- name: API_SERVER_ENABLED
value: "true"
- name: API_SERVER_HOST
value: 0.0.0.0
envFrom:
- secretRef:
name: hermes-api-key
ports:
- name: dashboard
containerPort: 9119
- name: api
containerPort: 8642
# The dashboard is the user-facing surface; /api/status is public.
startupProbe:
httpGet:
path: /api/status
port: dashboard
periodSeconds: 10
failureThreshold: 30
readinessProbe:
httpGet:
path: /api/status
port: dashboard
periodSeconds: 15
livenessProbe:
httpGet:
path: /api/status
port: dashboard
periodSeconds: 60
timeoutSeconds: 10
failureThreshold: 3
resources:
requests:
cpu: 100m
memory: 512Mi
limits:
memory: 4Gi
volumeMounts:
- name: data
mountPath: /opt/data
- name: tools
mountPath: /opt/data/.local/bin
volumes:
- name: data
persistentVolumeClaim:
claimName: hermes-data
- name: tools
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: hermes
namespace: hermes
labels:
app.kubernetes.io/name: hermes
spec:
selector:
app.kubernetes.io/name: hermes
ports:
- name: dashboard
port: 9119
targetPort: dashboard
- name: api
port: 8642
targetPort: api
+59
View File
@@ -0,0 +1,59 @@
# Both hosts are LAN-only (no dns.renner.uno/public or netbird.renner.uno/public
# label).
#
# hermes.renner.uno — dashboard and Hermes Desktop remote gateway. Plain
# Traefik, not the authentik-gate class: Hermes Desktop calls the API with a
# Bearer token from its native (PKCE) sign-in, which a forward-auth gate would
# redirect. Authentik still guards it: the dashboard's own OIDC login against
# the `hermes` application, restricted to the Hermes Users group.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: hermes
namespace: hermes
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
spec:
ingressClassName: traefik
tls:
- hosts:
- hermes.renner.uno
secretName: hermes-tls
rules:
- host: hermes.renner.uno
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: hermes
port:
number: 9119
---
# hermes-api.renner.uno — the OpenAI-compatible API server. Hermes checks
# `Authorization: Bearer <API_SERVER_KEY>` itself (secret hermes-api-key).
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: hermes-api
namespace: hermes
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
spec:
ingressClassName: traefik
tls:
- hosts:
- hermes-api.renner.uno
secretName: hermes-api-tls
rules:
- host: hermes-api.renner.uno
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: hermes
port:
number: 8642
@@ -0,0 +1,7 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- rbac.yaml
- api-key.yaml
- hermes.yaml
- ingress.yaml
+22
View File
@@ -0,0 +1,22 @@
# Read-only cluster access for the agent's kubectl, as for claude-workstation:
# the built-in `view` ClusterRole, which excludes Secrets. The token is the
# pod's projected ServiceAccount token; kubectl picks it up as in-cluster
# config.
apiVersion: v1
kind: ServiceAccount
metadata:
name: hermes
namespace: hermes
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: hermes-view
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: view
subjects:
- kind: ServiceAccount
name: hermes
namespace: hermes
+1 -2
View File
@@ -1,4 +1,3 @@
---
# kuma-sync: KumaMonitor -> Uptime Kuma monitors on kuma.renner.uno (ci/images/kuma-sync).
apiVersion: apps/v1
kind: Deployment
@@ -31,7 +30,7 @@ spec:
type: RuntimeDefault
containers:
- name: controller
image: gitea.renner.uno/gitea_admin/kuma-sync:2026-10-19-0545 # {"$imagepolicy": "flux-system:kuma-sync"}
image: gitea.renner.uno/gitea_admin/kuma-sync:2026-10-19-0615 # {"$imagepolicy": "flux-system:kuma-sync"}
env:
- name: KUMA_URL
value: https://kuma.renner.uno
+18
View File
@@ -0,0 +1,18 @@
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: hermes
namespace: flux-system
spec:
interval: 10m
retryInterval: 2m
timeout: 5m
sourceRef:
kind: GitRepository
name: flux-system
path: ./kubernetes/apps/hermes
prune: true
wait: true
dependsOn:
- name: infrastructure
- name: infrastructure-crds
@@ -0,0 +1,27 @@
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImageRepository
metadata:
name: hermes
namespace: flux-system
spec:
image: nousresearch/hermes-agent
interval: 1h
---
# v-prefix tags (v0.22.2). The filter skips rc.*, -desktop and per-arch tags.
# The repo also carries the retired date-versioned releases (v2026.4.3 ..
# v2026.9.24, last one 2026-09-24); as semver they sort above 0.x, so the
# range caps below 1.0.0 or the policy picks the old v2026.9.24.
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImagePolicy
metadata:
name: hermes
namespace: flux-system
spec:
imageRepositoryRef:
name: hermes
filterTags:
pattern: '^v(?P<ver>\d+\.\d+\.\d+)$'
extract: '$ver'
policy:
semver:
range: ">=0.22.2 <1.0.0"
@@ -10,6 +10,7 @@ resources:
- gatus.yaml
- gitea-mcp.yaml
- gotenberg.yaml
- hermes.yaml
- home-assistant.yaml
- immich.yaml
- kuma-sync.yaml
@@ -294,3 +294,12 @@ apiVersion: v1
kind: Namespace
metadata:
name: kuma-sync
---
# Hermes Agent (kubernetes/apps/hermes). Declared here so it exists before
# infrastructure-crds applies its CNPs. No pod-security labels: the image's
# s6-overlay init starts as root and drops to the `hermes` user, which the
# cluster's default `baseline` enforcement allows.
apiVersion: v1
kind: Namespace
metadata:
name: hermes
@@ -0,0 +1,137 @@
---
# Baseline policy for the `hermes` namespace (Hermes Agent, apps/hermes).
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: default-deny
namespace: hermes
spec:
endpointSelector: {}
ingress:
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": hermes
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": monitoring
- fromEntities:
- host
- remote-node
- fromEndpoints:
- matchExpressions:
- key: io.kubernetes.pod.namespace
operator: Exists
icmps:
- fields:
- { type: 0, family: IPv4 }
- { type: 3, family: IPv4 }
- { type: 8, family: IPv4 }
- { type: 11, family: IPv4 }
- { type: 12, family: IPv4 }
- { type: 1, family: IPv6 }
- { type: 2, family: IPv6 }
- { type: 3, family: IPv6 }
- { type: 4, family: IPv6 }
- { type: 128, family: IPv6 }
- { type: 129, family: IPv6 }
- { type: 133, family: IPv6 }
- { type: 134, family: IPv6 }
- { type: 135, family: IPv6 }
- { type: 136, family: IPv6 }
- { type: 143, family: IPv6 }
egress:
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": hermes
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": kube-system
k8s-app: kube-dns
toPorts:
- ports:
- port: "53"
protocol: UDP
- port: "53"
protocol: TCP
# Every LAN hostname (gitea, home, auth for the dashboard's OIDC
# discovery, matrix) via Traefik.
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": ingress
app.kubernetes.io/name: traefik
---
# The agent itself: model APIs (api.anthropic.com), web tools, messaging
# platforms, read-only kubectl, and the monitoring read APIs. The matching
# ingress grant on the monitoring side is monitoring-ns.yaml
# (monitoring-allow-hermes).
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: hermes-egress
namespace: hermes
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: hermes
egress:
- toEntities:
- world
toPorts:
- ports:
- port: "443"
protocol: TCP
- port: "80"
protocol: TCP
# `kube-apiserver` is its own entity in Cilium; port is the post-DNAT 6443.
- toEntities:
- kube-apiserver
toPorts:
- ports:
- port: "6443"
protocol: TCP
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": monitoring
app.kubernetes.io/name: prometheus
toPorts:
- ports:
- port: "9090"
protocol: TCP
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": monitoring
app.kubernetes.io/name: alertmanager
toPorts:
- ports:
- port: "9093"
protocol: TCP
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": monitoring
app.kubernetes.io/name: loki
toPorts:
- ports:
- port: "3100"
protocol: TCP
---
# Traefik serves the dashboard (9119) and the OpenAI-compatible API (8642).
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: hermes-allow-traefik
namespace: hermes
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: hermes
ingress:
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": ingress
app.kubernetes.io/name: traefik
toPorts:
- ports:
- port: "9119"
protocol: TCP
- port: "8642"
protocol: TCP
@@ -34,5 +34,6 @@ resources:
- uptime-monitor-ns.yaml
- kuma-sync-ns.yaml
- renovate-ns.yaml
- hermes-ns.yaml
- allow-cilium-health.yaml
- kube-system-hubble-relay.yaml
@@ -57,3 +57,30 @@ spec:
- { type: 135, family: IPv6 }
- { type: 136, family: IPv6 }
- { type: 143, family: IPv6 }
---
# Hermes Agent (hermes ns) reads metrics, alerts and logs: the Prometheus,
# Alertmanager and Loki HTTP APIs, scoped to those pods and ports.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: monitoring-allow-hermes
namespace: monitoring
spec:
endpointSelector:
matchExpressions:
- key: app.kubernetes.io/name
operator: In
values: [prometheus, alertmanager, loki]
ingress:
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": hermes
app.kubernetes.io/name: hermes
toPorts:
- ports:
- port: "9090"
protocol: TCP
- port: "9093"
protocol: TCP
- port: "3100"
protocol: TCP
@@ -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"]
}