Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 09:00:17 +02:00
security-bot 2b1016bd4a chore(image-automation): bump gitea.renner.uno/gitea_admin/kuma-sync:2026-10-19-0646 nousresearch/hermes-agent:v0.22.2
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-10-19 06:56:39 +00:00
k.renner 58695ceb9a Merge pull request 'feat(hermes): give the agent the homelab house rules and the wiki' (#1360) from feat/hermes-homelab-context into master
Reviewed-on: k.renner/homelab#1360
2026-10-19 06:55:15 +00:00
k.renner 055cc6a6e3 Merge pull request 'feat(monitoring): watch off-site Uptime Kuma from Prometheus' (#1357) from feat/kuma-task4-cluster into master
Reviewed-on: k.renner/homelab#1357
2026-10-19 06:47:18 +00:00
claude-botandClaude Opus 5.5 2a0a14fb25 feat(hermes): give the agent the homelab house rules and the wiki
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: request changes
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
SOUL.md comes from git (ConfigMap hermes-soul, subPath over the PVC
copy): the stock persona plus the homelab's working rules distilled
from Claude Code's global CLAUDE.md and feedback memory — wiki first,
the verification ladder, the network/DNS/placement/secret design rules,
operational change discipline and how to work with the owner. Claude
Code harness specifics (Bash allowlisting, subagent sizing, worktrees,
claude-bot identities) are left out.

The homelab-wiki skill (ConfigMap hermes-skills, mounted at
/opt/hermes-skills and registered via skills.external_dirs) wraps the
wiki service API in a stdlib Python client, since the image has no
bash or jq. Hermes calls the in-cluster service directly with its own
API key, identity `hermes`:

- apps/hermes/wiki -> Secret hermes-wiki, mounted as a file (optional,
  so the pod starts before it is seeded; reads are anonymous)
- apps/ai/wiki-api-key-hermes -> Auth__ApiKeys__hermes on the wiki. A
  separate path rather than a field in apps/ai/wiki-api-keys, because
  claude-bot's OpenBao policy is write-only and a put on the shared path
  would drop the workstation key.
- CNPs: hermes egress to ai/wiki:8080, wiki ingress from hermes
- hermes added to the openbao ClusterSecretStore allowlist

The built-in memory store (2200/1375 chars, agent-rewritten) is left to
the agent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 08:44:57 +02:00
k.renner 67372c673f Merge pull request 'fix(uptime-kuma): mount the bootstrap script directory, not the file' (#1359) from fix/uptime-kuma-script-mount into master
ci/woodpecker/push/deploy-uptime-kuma Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#1359
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-10-19 06:44:07 +00:00
claude-botandClaude Opus 5.5 ab1f004b48 fix(uptime-kuma): mount the bootstrap script directory, not the file
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: approve
A single-file bind mount pins the inode present at container start.
ansible.builtin.copy replaces the file by rename, so the running
container kept executing the previous kuma-setup.js: the #1358 deploy
ran the old admin-only script (host md5 8d77..., container 55a2...) and
created no notification, status page or metrics key.

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 08:17:30 +02:00
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-botandClaude Opus 5.5 286564a0d7 fix(kuma-sync): create when the previous monitor ID belongs to another owner
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: comment
After a Kuma database loss or restore, IDs restart and a resource's
status.monitorID can point at a monitor that now carries another
resource's ownership tag. That proves our monitor is gone, so the
controller creates a new one instead of parking at NotOwned forever.
Only an untagged monitor at the previous ID still blocks the create
(tag removed by hand), as before.

Residual Important finding from the kuma-sync final review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 08:12:45 +02:00
k.renner 1dc5ae7816 Merge pull request 'test(kuma-sync): remove smoke-test KumaMonitor' (#1353) from chore/kuma-sync-smoke-remove into master
Reviewed-on: k.renner/homelab#1353
2026-10-19 06:04:39 +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
claude-botandClaude Opus 5.5 ada5b7ea4d test(kuma-sync): remove smoke-test KumaMonitor
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
review-bot skipped: closed
Last smoke-test step: Flux prunes the KumaMonitor and the controller's
finalizer deletes Kuma monitor 9.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 08:02:48 +02:00
k.renner 38c186d424 Merge pull request 'test(kuma-sync): live smoke-test KumaMonitor' (#1351) from feat/kuma-sync-smoke into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#1351
2026-10-19 06:00:17 +00:00
security-bot d659097b8f chore(image-automation): bump gitea.renner.uno/gitea_admin/ci-backup-verify:2026-10-19
Updates managed by image-automation-controller.
2026-10-19 06:00:13 +00:00
security-bot 4baf5aa8be chore(image-automation): bump gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-10-19-0544
Updates managed by image-automation-controller.
2026-10-19 05:58:07 +00:00
claude-botandClaude Opus 5.5 fafc73cb3f test(kuma-sync): live smoke-test KumaMonitor for www.renner.uno
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: comment
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Keyword check on https://www.renner.uno/ (page title "Familie Renner")
with certificate-expiry notification. Exercises create, tagging,
restart adoption and deletion against the live Kuma; removed from git
again as the last smoke-test step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 07:58:00 +02:00
k.renner ab876a1231 Merge pull request 'feat(kuma-sync): deploy controller (Task 7)' (#1350) from feat/kuma-sync-deploy into master
Reviewed-on: k.renner/homelab#1350
2026-10-19 05:56:20 +00:00
claude-botandClaude Opus 5.5 790e258894 feat(kuma-sync): deploy controller
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: comment
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Namespace, CRD (Flux prune disabled so a prune never cascades into
deleting every Kuma monitor via finalizers), cluster RBAC matching the
controller-gen role, Recreate Deployment of the first master build
(2026-10-19-0545) under image automation, and a default-deny
CiliumNetworkPolicy allowing only DNS, the kube-apiserver and
kuma.renner.uno:443 out, kubelet probes in.

The Kuma login comes from its own OpenBao path infrastructure/kuma-sync
(a copy of the single Kuma admin account) rather than reading
infrastructure/uptime-kuma.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 07:51:41 +02:00
security-bot d8d676b711 chore(image-automation): bump gitea.renner.uno/k.renner/shelly-manager:build.1792387459
Updates managed by image-automation-controller.
2026-10-19 05:48:49 +00:00
security-bot 3f43d5fa17 chore(image-automation): bump gitea.renner.uno/gitea_admin/review-bot:2026-10-19-0540
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-10-19 05:45:09 +00:00
security-bot 142a21883d chore(image-automation): bump gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-10-19-0518 docker.gitea.com/gitea-mcp-server:1.10.0
ci/woodpecker/cron/alert-monitor Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
Updates managed by image-automation-controller.
2026-10-19 05:28:07 +00:00
k.renner dc3a299cf6 Merge pull request 'chore(ci): bump ci-claude image to 2.1.303' (#1349) from ci-image/claude-2.1.303 into master
Reviewed-on: k.renner/homelab#1349
2026-10-19 05:24:52 +00:00
k.renner b73e1b5636 Merge pull request 'chore(deps): update helm release openbao to >=0.30.4 <0.31.0' (#1348) from renovate/openbao into master
Reviewed-on: k.renner/homelab#1348
2026-10-19 05:19:50 +00:00
k.renner c8d2d073a5 Merge pull request 'chore(deps): pin dependencies' (#1343) from renovate/pin-image-digests into master
Reviewed-on: k.renner/homelab#1343
2026-10-19 05:19:20 +00:00
claude-bot 8d3b3c4324 chore(ci): bump ci-claude image to 2.1.303
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: comment
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
2026-10-19 05:18:31 +00:00
k.renner 7b1041c188 Merge pull request 'chore(deps): update home-assistant non-major' (#1346) from renovate/home-assistant-minor-patch into master
Reviewed-on: k.renner/homelab#1346
2026-10-19 05:18:13 +00:00
k.renner 9e62fadcb1 Merge pull request 'chore(deps): update ci non-major' (#1345) from renovate/ci-minor-patch into master
ci/woodpecker/push/ci-claude-refresh Pipeline was successful
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: k.renner/homelab#1345
2026-10-19 05:17:04 +00:00
k.renner 5e99ca4370 Merge pull request 'feat(kuma-sync): in-house Uptime Kuma controller (Tasks 1-6)' (#1342) from feat/kuma-sync into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: k.renner/homelab#1342
2026-10-19 05:09:52 +00:00
renovate-bot 94090c68c3 chore(deps): update helm release openbao to >=0.30.4 <0.31.0
review-bot reviewed: comment
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
2026-10-19 02:02:09 +00:00
renovate-bot b555277634 chore(deps): update home-assistant non-major
review-bot reviewed: comment
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
2026-10-19 02:02:00 +00:00
renovate-bot f8b98907e8 chore(deps): update ci non-major
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: comment
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
2026-10-19 02:01:57 +00:00
renovate-bot d7d99365e8 chore(deps): pin dependencies
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
review-bot reviewed: request changes
2026-10-19 02:01:50 +00:00
claude-botandClaude Opus 5.5 e1c0e73f61 fix(kuma-sync): orphan grace, refuse create beside an untagged previous monitor, drop push
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: comment
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 00:47:16 +02:00
claude-botandClaude Opus 5.5 59ccd74a89 test(kuma-sync): race-free drift test, restart proves adoption
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 00:30:13 +02:00
claude-botandClaude Opus 5.5 15a4e2a0dc test(kuma-sync): end-to-end integration suite
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 00:23:14 +02:00
claude-botandClaude Opus 5.5 2af1d880e4 fix(kuma-sync): confirm orphan deletes against the API server
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 00:16:21 +02:00
claude-botandClaude Opus 5.5 18ef262d93 feat(kuma-sync): orphan GC, manager and image build
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 00:12:24 +02:00
claude-botandClaude Opus 5.5 185738bd2c fix(kuma-sync): never create on an incomplete list; typed owner conflicts; duplicate owners
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 00:02:04 +02:00
claude-botandClaude Opus 5.5 0c487656e8 feat(kuma-sync): KumaMonitor reconciler
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:54:48 +02:00
claude-botandClaude Opus 5.5 b3f68753d1 fix(kuma-sync): flag unknown-outcome creates as possible orphans
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:50:53 +02:00
claude-botandClaude Opus 5.5 1968e91516 fix(kuma-sync): never create after a failed owner lookup
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:47:54 +02:00
claude-botandClaude Opus 5.5 4f0c7c414e fix(kuma-sync): reconnect, no orphan on create timeout, safe timeout, owner-tag integrity
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:41:20 +02:00
claude-botandClaude Opus 5.5 734f546d8b feat(kuma-sync): Kuma client on go-uptime-kuma-client
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:29:36 +02:00
claude-botandClaude Opus 5.5 90534f8ce7 fix(kuma-sync): deep-copy fake state, sentinel not-found/not-owned errors
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:17:34 +02:00
claude-botandClaude Opus 5.5 3a684d5452 feat(kuma-sync): Kuma client interface and fake
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:15:34 +02:00
claude-botandClaude Opus 5.5 ee0d416b80 fix(kuma-sync): precise validation tests and tighter CRD bounds
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:13:54 +02:00
claude-botandClaude Opus 5.5 fdb381305e feat(kuma-sync): KumaMonitor API types and CRD
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:10:50 +02:00
k.renner 5ebf7a04c4 Merge pull request 'docs(plans): kuma-sync controller design and plan' (#1341) from docs/uptime-kuma-plan into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
ci/woodpecker/cron/update-audit Pipeline was successful
ci/woodpecker/cron/e2e Pipeline failed
Reviewed-on: k.renner/homelab#1341
2026-10-18 21:05:14 +00:00
claude-botandClaude Opus 5.5 7f4c9c60c3 docs(plans): kuma-sync controller design and plan
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot skipped: closed during the review
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:03:26 +02:00
k.renner fde532b6a4 Merge pull request 'chore(autokuma): remove AutoKuma after failed pilot' (#1340) from chore/remove-autokuma into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#1340
2026-10-18 20:53:19 +00:00
claude-botandClaude Opus 5.5 333f331aed chore(autokuma): remove AutoKuma after failed pilot
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: comment
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
AutoKuma 2.0.0 never applied its ownership tag against Uptime Kuma 2.5.6, so a pod restart with empty /data duplicated every managed monitor (pilot step 5). Replaced by an in-house controller under a separate plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:48:31 +02:00
k.renner 1c4f4583fa Merge pull request 'fix(uptime-kuma): dual-stack egress network for Kuma checks' (#1339) from fix/uptime-kuma-ipv6 into master
ci/woodpecker/push/deploy-uptime-kuma Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#1339
2026-10-18 20:43:42 +00:00
k.renner b3a32435a8 Merge pull request 'test(autokuma): pilot step 3 — update interval and expiry_notification' (#1338) from feat/autokuma-pilot-update into master
Reviewed-on: k.renner/homelab#1338
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-10-18 20:43:27 +00:00
k.renner b31ee3189a Merge pull request 'fix(flux-notifications): stop alerting on successful image-automation commits' (#1337) from fix/drop-flux-image-automation-alert into master
Reviewed-on: k.renner/homelab#1337
2026-10-18 20:42:53 +00:00
claude-botandClaude Opus 5.5 64a5281bd0 fix(uptime-kuma): dual-stack egress network for Kuma checks
review-bot reviewed: comment
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:40:57 +02:00
claude-botandClaude Opus 5.5 f7a0719524 test(autokuma): pilot step 3 — update interval and expiry_notification
review-bot reviewed: approve
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:40:51 +02:00
claude-botandClaude Opus 5.5 4a302c2cbe fix(flux-notifications): stop alerting on successful image-automation commits
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: comment
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
The flux-image-automation Alert forwarded every info event from
ImageUpdateAutomation (i.e. each successful bump commit) to Alertmanager,
which emailed it as FluxImageUpdateAutomationSucceeded. A success is not
a problem and must not be an alert; the commits are in git history.

Image-automation failures still alert: flux-failures covers
ImageUpdateAutomation at error severity. The Alertmanager source="flux"
route stays for those; its comment now says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:38:25 +02:00
security-bot 7617a6725b chore(image-automation): bump docker.gitea.com/gitea-mcp-server:1.10.0
ci/woodpecker/cron/issue-fix Pipeline was successful
Updates managed by image-automation-controller.
2026-10-18 20:33:45 +00:00
k.renner c863b9845c Merge pull request 'feat(autokuma): pilot KumaEntities (Task 3)' (#1336) from feat/autokuma-pilot into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#1336
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-10-18 20:25:39 +00:00
claude-botandClaude Opus 5.5 f74239bb40 feat(autokuma): pilot KumaEntities (Task 3)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: approve
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:23:52 +02:00
k.renner d43cd54cba Merge pull request 'docs(plans): replace UptimeRobot with Uptime Kuma' (#1330) from docs/uptime-kuma-plan into master
Reviewed-on: k.renner/homelab#1330
2026-10-18 20:23:00 +00:00
k.renner 81f0adce5d Merge pull request 'docs(plan): move homelab to konairius/homelab and rename master to main' (#1328) from claude/plan-homelab-konairius-main into master
Reviewed-on: k.renner/homelab#1328
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-10-18 20:22:38 +00:00
k.renner 1d99975267 Merge pull request 'feat(autokuma): KumaEntity controller for Uptime Kuma' (#1333) from feat/autokuma into master
Reviewed-on: k.renner/homelab#1333
2026-10-18 20:20:34 +00:00
claude-botandClaude Opus 5.5 ee5f4894a5 feat(autokuma): restrict egress to kuma.renner.uno
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: comment
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Replace world:443 with toFQDNs kuma.renner.uno:443; the kube-dns rule gains
rules.dns so Cilium learns the name-to-IP mapping.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:14:13 +02:00
k.renner 2503b353b7 Merge pull request 'fix(uptime-kuma): guard the setup fail check against an undefined rc' (#1335) from fix/uptime-kuma-fail-guard into master
ci/woodpecker/push/deploy-uptime-kuma Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#1335
2026-10-18 20:10:39 +00:00
claude-botandClaude Opus 5.5 b136362fa8 fix(uptime-kuma): guard the setup fail check against an undefined rc
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: comment
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:09:37 +02:00
k.renner b7d26ec869 Merge pull request 'fix(uptime-kuma): start setup after Kuma's info event; surface setup errors' (#1334) from fix/uptime-kuma-setup-diagnostics into master
ci/woodpecker/push/deploy-uptime-kuma Pipeline was successful
Reviewed-on: k.renner/homelab#1334
2026-10-18 20:07:15 +00:00
claude-botandClaude Opus 5.5 8ef8486b2e fix(uptime-kuma): start setup after Kuma's info event
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: comment
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:04:34 +02:00
claude-botandClaude Opus 5.5 811171646e fix(uptime-kuma): surface setup script output on failure
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:03:30 +02:00
k.renner 597541ed7f Merge pull request 'feat(uptime-kuma): Uptime Kuma on the NetBird VPS' (#1332) from feat/uptime-kuma-vps into master
ci/woodpecker/push/deploy-uptime-kuma Pipeline failed
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#1332
2026-10-18 20:00:28 +00:00
claude-botandClaude Opus 5.5 e4f11a1d80 ci(uptime-kuma): use ci-ansible image with hvac
review-bot skipped: draft
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:58:10 +02:00
k.renner e5100fda8e Merge pull request 'ci(ansible-image): add hvac for community.hashi_vault lookups' (#1331) from ci/ansible-image-hvac into master
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: k.renner/homelab#1331
2026-10-18 19:51:17 +00:00
claude-botandClaude Opus 5.5 4aabb1a601 docs(plans): uptime-kuma SMTP secret layout from owner
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: request changes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:49:31 +02:00
claude-botandClaude Opus 5.5 016b5d69eb feat(uptime-kuma): renew the CI OpenBao token from a cron pipeline
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot skipped: draft
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
The CI token is an orphan periodic token (period 768h) created with
-no-default-policy -policy=uptime-kuma-vps; nothing renews it. The policy
now grants auth/token/renew-self, and the pipeline gains a renew-openbao-token
step plus a cron trigger matching only the cron named uptime-kuma-token-renew.
The deploy step is restricted to push and deployment events so cron runs only
renew. -o /dev/null drops the JSON body that contains the token; -sSf keeps
errors visible and fails the step on a 403.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:48:50 +02:00
claude-botandClaude Opus 5.5 5efb1dd8c8 fix(autokuma): preserve unknown fields in spec.config, default-deny ingress
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
review-bot superseded: new head ee5f4894
additionalProperties: true makes the API server prune nested objects under
spec.config to {}; x-kubernetes-preserve-unknown-fields (as in upstream
master) keeps the free-form contract. Add ingress: [{}] so the policy
default-denies ingress as named.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:41:40 +02:00
claude-botandClaude Opus 5.5 4e5a97ae0b feat(autokuma): KumaEntity controller for Uptime Kuma
AutoKuma 2.0.0 (Kubernetes source) syncs KumaEntity objects to Uptime Kuma
at https://kuma.renner.uno. Ships no KumaEntity objects. Image pinned, not
under Flux automation: Kuma's internal API is unofficial, so Kuma and
AutoKuma are bumped together.

ClusterRole is cluster-wide because the controller lists/watches
KumaEntity across all namespaces. The openbao ClusterSecretStore
namespace allowlist gains autokuma. FILES and DOCKER sources are
disabled; /data and /tmp are emptyDirs for readOnlyRootFilesystem.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:38:55 +02:00
claude-botandClaude Opus 5.5 cddf555ba6 fix(uptime-kuma): keep the setup marker inside the data volume
review-bot skipped: draft
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
The marker next to data/ survived a wiped or replaced DB, so the first up -d
exposed a fresh unclaimed Kuma on /setup. Inside data/ it lives and dies with
the DB; it is still written only after a successful setup run and checked
before the first up -d.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:36:07 +02:00
claude-botandClaude Opus 5.5 4b4fc39f30 ci(ansible-image): add hvac for community.hashi_vault lookups
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: comment
The uptime-kuma play reads its credentials from OpenBao via community.hashi_vault, which needs the python hvac library.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:34:22 +02:00
claude-botandClaude Opus 5.5 67ce7daafa feat(uptime-kuma): Uptime Kuma on the NetBird VPS
Role, play and pipeline deploy Uptime Kuma 2.5.6 behind the VPS Traefik.
The admin user is bootstrapped over socket.io (needSetup/setup) with the
route disabled, so /setup is never exposed; a .setup-done marker keeps the
route enabled on later runs. Credentials come from OpenBao
homelab/infrastructure/uptime-kuma via community.hashi_vault; the
uptime-kuma-vps policy grants read on that one path. Only kuma.renner.uno
is routed; uptime.renner.uno stays unrouted until its DNS moves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:32:53 +02:00
claude-botandClaude Opus 5.5 8063bfb7b4 docs(plans): record owner answers for uptime-kuma plan
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: request changes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:30:09 +02:00
claude-botandClaude Opus 5.5 d8cb907aca docs(plans): uptime-kuma groups, json-query pilot checks, latency alerting
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot superseded: new head 8063bfb7
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:28:59 +02:00
claude-botandClaude Opus 5.5 3c0cb24d19 docs(plan): Flux follows the transfer redirect per the documentation
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: approve
source-controller v1.9.5 uses go-git v5.19.1, whose documented RedirectPolicy
default is "initial", mirroring git's http.followRedirects: follow the
redirect on the discovery request and use the redirected URL as the base for
follow-up requests. The break-glass stays as a fallback only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:28:25 +02:00
claude-botandClaude Opus 5.5 16686fcec3 docs(plans): Uptime Kuma replacement for UptimeRobot
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:18:59 +02:00
claude-botandClaude Opus 5.5 95f1854f3b docs(plan): fold in review-bot review 1693
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: approve
- Snapshot collaborators, konairius teams and branch-protection whitelists
  before the transfer and compare after (Gitea drops collaborators who are
  members of the new org).
- Break-glass suspends the flux-system Kustomization so it cannot re-apply the
  old URL.
- Task 1 notes the off-cycle mint of both review-bot PATs and checks the
  k.renner org holds only the two org secrets.
- Prompts move out of the branch PR into a post-rename PR to main, which
  rebuilds the review-bot image (build-ci-images would not fire otherwise).
- Expected side effects of the transfer PR listed; greps widened to the whole
  repo; wiki-maintenance cron deleted (no pipeline uses it); GitHub mirror gets
  its default branch switched before master is deleted.
- Verified facts recorded: transfer collaborator rule, repair copies the
  default branch, repo/cron ids, org secret inventory, repoRenameBranch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:16:59 +02:00
k.renner d765451e8e Merge pull request 'fix(monitoring): resolve KubeJobFailed when a later run succeeds' (#1329) from fix/kubejobfailed-resolve-on-later-success into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#1329
2026-10-18 18:58:57 +00:00
claude-botandClaude Opus 5.5 8ee980755a fix(monitoring): resolve KubeJobFailed when a later run succeeds
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: comment
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
The chart's KubeJobFailed fires while a failed Job object exists, so it
clears only on Job GC (TTL, history limit, Velero's keep-latest), not on
recovery. On 2026-10-18 this held 10 alerts open for runs that had already
been superseded: renovate recovered 2026-10-13 but alerted until its 7d
TTL; registry-bot-secret-rotate-init2 succeeded 9 min after init failed,
the alert started 7 min after that and stayed 11h.

Replace it with a rule that drops a failed Job once a later Job of the
same series completes: series = owning CronJob, or velero.io/repo-name
for kopia maintenance Jobs (exposed via KSM metricLabelsAllowlist).
Failed Jobs without a series alert as before.

KubeContainerWaiting fired for the never-started main container of a pod
whose init container failed. Exclude Failed/Succeeded pods; KubeJobFailed
covers those.

Verified against live Prometheus: the new expression returns nothing now
(chart rule: 2), returns the 30 Velero maintenance failures at 16:40Z
2026-10-18, and returns nothing at the renovate/registry-bot timestamps
after their successful re-runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:54:20 +02:00
claude-botandClaude Opus 5.5 bbd166880b docs(plan): move homelab to konairius/homelab and rename master to main
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: comment
Two cut-overs with their own windows, prep PRs and rollbacks, built on the
2026-10-18 inventory (repo references, live Flux/Woodpecker/rotator state,
workstation consumers) and the pattern proven on the five repos moved the same
day.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:52:39 +02:00
k.renner 32d16fcfc0 Merge pull request 'chore(home-assistant): follow homeassistant to konairius/homeassistant (merge right after the transfer)' (#1327) from claude/homeassistant-konairius into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#1327
2026-10-18 18:38:51 +00:00
claude-botandClaude Opus 5.5 bcb907fc92 chore(home-assistant): follow homeassistant to konairius/homeassistant
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: comment
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Merge right after the repo transfer. Gitea API writes do not survive the
old-path redirect, so the config-sync sidecar (live -> main PR) and review-bot
(posting reviews) must address the new path.

- config-sync GITEA_REPO = konairius/homeassistant (pod restarts, the remote is
  reset on start).
- ha-config-sync rotator verifies against konairius/homeassistant.
- review-bot REPOS lists konairius/homeassistant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:36:19 +02:00
k.renner edc1cccf4d Merge pull request 'feat(home-assistant): config-sync authenticates as ha-config-sync, remote from the manifest' (#1326) from claude/ha-config-sync-identity into master
Reviewed-on: k.renner/homelab#1326
2026-10-18 18:31:43 +00:00
claude-botandClaude Opus 5.5 76a82f7d28 feat(home-assistant): config-sync authenticates as ha-config-sync, remote from the manifest
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: comment
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
The sidecar took its git remote, Gitea API base and token from a URL with an
embedded PAT stored on the config PVC. That state was invisible to git,
unrotated, and would break on the repo transfer to konairius: the live -> main
PR is opened with an API POST, and Gitea API writes do not survive the old-path
redirect (seen with the wiki on notes).

- ha-config-sync: dedicated Gitea account, write collaborator on homeassistant.
  CronJob openbao/ha-config-sync-secret-rotate (quarterly) mints its
  write:repository PAT, verifies pull+push on homeassistant and stores it at
  infrastructure/ha-config-sync#token. Policy/role applied and the path seeded
  on 2026-10-18. Netpol halves gitea-allow-ha-config-sync-rotator and
  rotator-ha-config-sync-egress.
- ExternalSecret home-assistant-config-sync-gitea, mounted as a file (not
  subPath) so a rotation lands in place without restarting Home Assistant.
- config-sync.sh: GITEA_URL/GITEA_REPO/GITEA_USER/GITEA_TOKEN_FILE from the
  manifest; the remote is reset to the credential-free URL on every start; git
  gets the token as an http.extraHeader through GIT_CONFIG_* env, re-read every
  loop; the PR call uses the same token.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:28:15 +02:00
k.renner bbb21482bf Merge pull request 'fix(review-bot): review konairius/authentik-samba-bridge' (#1325) from claude/review-bot-repos-konairius into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#1325
2026-10-18 14:27:31 +00:00
k.renner 74189e7ec7 Merge pull request 'chore(authentik): run the samba-bridge images from konairius/* (window)' (#1324) from claude/authentik-konairius-images into master
Reviewed-on: k.renner/homelab#1324
2026-10-18 14:26:58 +00:00
claude-botandClaude Opus 5.5 21d7d181f4 fix(review-bot): review konairius/authentik-samba-bridge
review-bot reviewed: comment
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
The repo moved to the konairius org. Its webhooks now carry
konairius/authentik-samba-bridge, which REPOS did not list, so the bot
ignored them, and the sweep would post through the old path, where Gitea
API writes do not survive the redirect (seen with the wiki on notes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 16:23:25 +02:00
claude-botandClaude Opus 5.5 413f675bfa chore(authentik): run the samba-bridge images from konairius/*
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: comment
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
authentik-samba-bridge moved to the konairius org and publishes as
registry-bot (konairius/authentik-samba-bridge#7, pipeline #42):
gitea.renner.uno/konairius/authentik-samba-bridge-{server,ldap-outpost}
:2026.8.3-9dd92dd. Between the deployed 872f754 and 9dd92dd only CI, OCI
labels and pyproject URLs changed; the bridge code is the same.

- HelmRelease: server image repository + tag 2026.8.3-9dd92dd.
- LDAP outpost blueprint: container_image 2026.8.3-9dd92dd.
- Repo URLs in the comments follow.

Restarts the authentik server and worker pods and redeploys the LDAP outpost.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 16:22:55 +02:00
k.renner c782dca48c Merge pull request 'chore: point the wiki and Renovate at konairius/notes and konairius/renovate-config' (#1323) from claude/notes-renovate-konairius into master
Reviewed-on: k.renner/homelab#1323
2026-10-18 14:15:49 +00:00
claude-botandClaude Opus 5.5 8e930e17b1 chore: point the wiki and Renovate at konairius/notes and konairius/renovate-config
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: comment
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Both repos moved from k.renner to the konairius org on 2026-10-18. The old
paths still resolve through Gitea's redirect (the preset API read of
k.renner/renovate-config returns 200), so nothing is broken; this removes the
dependency on the redirect.

- wiki: Wiki__NoteStore__Gitea__Repository = konairius/notes.
- renovate.json extends local>konairius/renovate-config; the runner comments follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 16:13:09 +02:00
k.renner 3661dedd19 Merge pull request 'chore(paperless): pull vsftpd from konairius/vsftpd' (#1322) from claude/vsftpd-konairius-image into master
ci/woodpecker/cron/alert-monitor Pipeline failed
Reviewed-on: k.renner/homelab#1322
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-10-18 14:09:08 +00:00
claude-botandClaude Opus 5.5 27fc56278f chore(paperless): pull vsftpd from konairius/vsftpd
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: approve
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
The vsftpd repo moved to the konairius org and its pipeline now publishes as
registry-bot to gitea.renner.uno/konairius/vsftpd (konairius/vsftpd#3,
pipeline #12 pushed build.1792310564, same tags as before). The ImageRepository,
the ftp-scanner image and the renovate ignore list follow. The new path is
anonymously pullable like the old one, so no pull secret is needed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 10:03:55 +02:00
k.renner 98d2533c42 Merge pull request 'fix(openbao): give the registry-bot token read:repository for its pull check' (#1321) from claude/registry-bot-read-scope into master
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline failed
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#1321
2026-10-18 07:48:09 +00:00
claude-botandClaude Opus 5.5 65a22139f6 fix(openbao): give the registry-bot token read:repository for its pull check
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: comment
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
The first run of registry-bot-secret-rotate minted the token but failed
verification: "failed verification (pull=false push=true)". The rotator
checks a new token by git-pulling the private konairius/vsftpd, and a
write:package-only token cannot read code even though the publishers team
grants code read: token scopes cap the account's rights. read:repository is
read-only and is what the pull needs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 09:44:40 +02:00
k.renner 47e6f493d0 Merge pull request 'feat(openbao): rotate a registry-bot token for konairius image publishing' (#1320) from claude/registry-bot into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#1320
2026-10-18 07:37:09 +00:00
claude-botandClaude Opus 5.5 0898fa495a feat(openbao): rotate a registry-bot token for konairius image publishing
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: comment
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
registry-bot is a dedicated Gitea account in the konairius team "publishers"
(packages write, code read). Every konairius release pipeline publishes its
image to gitea.renner.uno/konairius/* with one shared token instead of a
per-repo k.renner PAT.

- CronJob openbao/registry-bot-secret-rotate (quarterly): secret-rotator
  gitea-token mints a write:package PAT, verifies it can pull the private
  konairius/vsftpd, stores it at infrastructure/registry-bot#token; then
  woodpecker-secret upserts the konairius org secret registry_bot_token with
  events push,tag,release,manual (no pull_request).
- OpenBao policy rotator-registry-bot. Already applied with its role, and the
  token path seeded with a placeholder, on 2026-10-18.
- Netpol halves: gitea-allow-registry-bot-rotator, woodpecker-allow-rotators,
  rotator-registry-bot-egress.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 09:32:54 +02:00
security-bot 5eeee9018f chore(image-automation): bump gitea.renner.uno/gitea_admin/review-bot:2026-10-18-0706
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-10-18 07:15:06 +00:00
k.renner ac33b0a9f2 Merge pull request 'chore(ci): bump ci-claude image to 2.1.302' (#1318) from ci-image/claude-2.1.302 into master
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: k.renner/homelab#1318
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-10-18 07:06:23 +00:00
k.renner 7090f130f8 Merge branch 'master' into ci-image/claude-2.1.302
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: approve
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
2026-10-18 06:56:43 +00:00
security-bot 462f56ab3c chore(image-automation): bump gitea.renner.uno/gitea_admin/review-bot:2026-10-18-0612
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-10-18 06:15:06 +00:00
k.renner e36e3713a7 Merge pull request 'fix(review-bot): approve with Gitea's APPROVED event' (#1319) from claude/review-bot-approved into master
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: k.renner/homelab#1319
2026-10-18 06:12:38 +00:00
claude-botandClaude Opus 5.5 04f1f1d639 fix(review-bot): approve with Gitea's APPROVED event
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: approve
ci/woodpecker/pr/review-bot-test Pipeline was successful
The bot posted approvals with event "APPROVE". Gitea knows APPROVED,
REQUEST_CHANGES and COMMENT and stores any other value as a pending review
that only its author sees (routers/api/v1/repo/pull_review.go,
preparePullReviewType, default branch). Every approval since cdba8b62 is such
a pending draft: homelab #1270, #1278, #1287, #1318, homeassistant #385.

The bot also counted its own pending review as "reviewed at this head", so
those PRs were never reviewed again. A pending review no longer counts; the
sweep reviews them again, and Gitea submits the existing pending review with
the new event.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 08:09:47 +02:00
claude-bot 4cba950ef2 chore(ci): bump ci-claude image to 2.1.302
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
review-bot reviewed: approve
2026-10-18 00:01:26 +00:00
k.renner 438d74ae49 Merge pull request 'fix(flux): prune again in the Kustomizations that handed objects over (restore-test stack 10/10)' (#1317) from split/10-prune-restore into master
ci/woodpecker/cron/ci-claude-refresh Pipeline was successful
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#1317
2026-10-17 21:24:55 +00:00
k.renner c820bf632d Merge pull request 'feat(restore-test): hcloudtest boots production's config on Hetzner, contained (restore-test stack 9/10)' (#1316) from split2/09-restore-test-harness into master
Reviewed-on: k.renner/homelab#1316
2026-10-17 21:23:26 +00:00
k.renner ee9f2ca933 Merge pull request 'feat(cnpg): every cluster incarnation archives to a new prefix; recovery reads the newest (restore-test stack 8/10)' (#1315) from split2/08-cnpg-archive-generations into master
Reviewed-on: k.renner/homelab#1315
2026-10-17 21:19:02 +00:00
k.renner 13f9f83f3a Merge pull request 'feat(velero): the cluster restores its app volumes itself, Gitea first (restore-test stack 7/10)' (#1314) from split2/07-velero-self-restore into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#1314
2026-10-17 21:11:42 +00:00
k.renner a83a179851 Merge pull request 'feat: iGPU and IoT-VLAN workloads run only where a node declares the hardware (restore-test stack 6/10)' (#1313) from split2/06-hardware-gating into master
Reviewed-on: k.renner/homelab#1313
2026-10-17 21:06:25 +00:00
k.renner 201acba44c Merge pull request 'fix(flux): services that need OpenBao secrets apply after the secret store (restore-test stack 5/10)' (#1312) from split2/05-infra-services into master
Reviewed-on: k.renner/homelab#1312
2026-10-17 21:01:06 +00:00
k.renner 5c93cc355e Merge pull request 'fix(flux): in-house-image infrastructure waits for Gitea; bird only where table 47 exists (restore-test stack 4/10)' (#1311) from split2/04-infra-inhouse into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#1311
2026-10-17 20:57:43 +00:00
k.renner 398126c2d3 Merge pull request 'fix(flux): each Kustomization depends on what its workloads need, and waits for them (restore-test stack 3/10)' (#1310) from split2/03-dependency-graph into master
Reviewed-on: k.renner/homelab#1310
2026-10-17 20:52:52 +00:00
k.renner dee454bd2e Merge pull request 'feat(talos): per-site facts in the site patches; bootstrap reads them (restore-test stack 2/10)' (#1309) from split2/02-site-facts into master
Reviewed-on: k.renner/homelab#1309
2026-10-17 20:49:56 +00:00
k.renner 17b10b11ae Merge pull request 'fix: ceph-csi-drivers chart, Gitea claim without volumeName, optional HA MQTT cert (restore-test stack 1/10)' (#1308) from split2/01-small-fixes into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#1308
2026-10-17 20:33:35 +00:00
k.renner c501cbd7b3 Merge pull request 'fix(flux): no pruning in the Kustomizations that hand objects to new owners (restore-test stack 0/10)' (#1307) from split/00-prune-handover into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#1307
2026-10-17 20:28:06 +00:00
claude-botandClaude Opus 5.5 fb953c0ed5 fix(flux): prune again in the Kustomizations that handed objects over
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
review-bot reviewed: comment
The stack's first PR turned pruning off in infrastructure,
infrastructure-crds, home-assistant, authentik, immich, matrix, paperless,
trmnl and woodpecker, so that the moved objects were dropped from their
inventories instead of deleted. Merged after every move has reconciled,
this turns it back on: the moved objects are no longer in the old
Kustomizations' inventories by then, so garbage collection has nothing of
theirs to delete.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:20:02 +02:00
claude-botandClaude Opus 5.5 c862082d0e feat(restore-test): hcloudtest boots production's config on Hetzner, contained, and Flux restores into it
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
review-bot reviewed: comment
The restore rehearsal: a throwaway three-node cluster on Hetzner Cloud that
runs the production tree and restores production's backups into itself.

hcloudtest (bootstrap.sh) runs SITE=hcloud on cpx52, and nothing stands in
for the home network:
- site-hcloud.yaml states the Hetzner site's facts, as site-homelab.yaml
  does at home: etcd advertised on the node segment 10.90.0.0/16; a
  cilium-site ConfigMap with tunnel (VXLAN) routing over the node segment
  and only the pod CIDRs exempt from masquerading (Hetzner networks are
  layer 3, IPv4-only, and drop packets with a source not assigned to the
  sending server); a ResolverConfig with k8s-gateway first and a
  k8s-gateway-site ConfigMap pointing at Hetzner's resolvers. The node
  range is read from the machine document only, now that the patch holds
  more than one (run 28).
- hcloud-1..3.yaml put a 345GiB OSD partition beside a 60GiB EPHEMERAL on
  the system disk: the project's volume quota cannot hold three OSD
  volumes. No Hetzner volume is created for a raw volume on the system disk.
- The Talos snapshot is built when missing (a rescue-mode server writes the
  Image Factory image; removed on failure), matched by version and
  schematic. This host's inbound firewall replaces earlier runs' without a
  server ever being left without one. The Hetzner token comes from OpenBao
  (homelab-backup-verify project). Access files land in
  ~/.local/state/homelab-hcloudtest instead of access-staging.
- Containment: an outbound firewall on the nodes, applied before any
  config, admits udp and icmp anywhere and tcp to the complement of
  proxy.renner.uno's addresses, resolved over DoH at run time
  (cidr_complement, in bash; fails above Hetzner's 500 effective rules;
  one tcp rule per 100 prefixes, Hetzner's undocumented per-rule limit).
  The egress check pod (restore-test/egress-check.yaml) runs at the end of
  hcloudtest and again before the restore test writes any secret.

FLUX_SOURCE=restore-test bootstrap.sh flux runs against that cluster only
(its nodes must all be hcloud-*, and KUBECONFIG/TALOSCONFIG must not be the
operator's): flux install, a per-run read-only deploy key on the GitHub
mirror, the restore-test-git Secret, and restore-test/flux-root.yaml on
FLUX_BRANCH. flux-root.yaml applies the production tree; its patches are the
test's whole deviation: the source rewrite, no Ceph cluster network, Velero
ReadOnly, no MariaDB dumps, rotators suspended, Gitea without world egress,
netbird-service-sync at zero replicas and netbird-ingress on no node (the
test cluster rewrote production NetBird's proxy services in run 28), and
backup-verify suspended: it reads the same Hetzner token as hcloudtest and
would delete production's monthly verify VM (both label
managed-by=backup-verify). The patches are labelled 3a to 3h without gaps.

The egress check resolves A and AAAA separately and fails on either error;
before, a failed A lookup went unnoticed and the check ran on IPv6 alone.
Removing a previous egress-check pod fails with a message. hcloudclean checks
for bao like hcloudtest does. site-hcloud.yaml's header says what the site
is for now and that Ceph replicates over the pod network there.

CNPG archives, backs up and prunes as in production: the flux phase writes
cnpg-paths with every archive path in renner-uno-k8s-cnpg-rtest, created
there and emptied and deleted by hcloudclean. A test incarnation with a base
backup in the production bucket would be the newest there, and the next real
recovery would read the test's data. hcloudclean also deletes the test's ssh
keys and the restore-test deploy keys. OpenBao's status wait is bounded at
30 minutes: on a fresh cluster the pod waits for Ceph to provision its
volume.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:19:40 +02:00
claude-botandClaude Opus 5.5 a17c4e8161 feat(cnpg): every cluster incarnation archives to a new prefix; recovery reads the newest
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
review-bot reviewed: comment
A CNPG cluster restored from barman archived back into the location it
recovered from. plugin-barman-cloud's restore job checks the archive target
(plugins[] ObjectStore + serverName) and stops with "Expected empty archive"
when it holds WAL, which after a disaster it always does. It has no
exception for target == recovery source (internal/cnpgi/restore/restore.go,
v0.15). Restore rehearsal run 28 hit it on wp-pg; the other six never got
that far.

The check stays. Instead, archive and source become two ObjectStores whose
paths are chosen per incarnation:

- kubernetes/cnpg-object-stores/: all 14 ObjectStores (<x>-store archives,
  <x>-store-source is read by bootstrap.recovery), in a Kustomization of their
  own with postBuild.substituteFrom: cnpg-paths. Nothing else lives there:
  substitution blanks every ${...}, which is why it is not on the app
  Kustomizations (home-assistant's scripts, the Grafana dashboards). The
  ConfigMap is not optional; without it the paths would render empty.
  woodpecker's objectstore.yaml moves there as wp-pg.yaml.
- bootstrap.sh writes cnpg-paths in the flux phase, before Flux starts, once
  per incarnation (an existing one is kept):
    CNPG_<X>_SOURCE   the prefix among <x>, <x>-g<14 digits> and the store's
                      cnpg.renner.uno/source-prefixes whose <x>/base/ holds
                      the newest completed base backup (backup.info
                      status=DONE; barman uploads it as STARTED when a
                      backup begins, barman src/barman/cloud.py
                      coordinate_backup)
    CNPG_<X>_ARCHIVE  <x>-g<UTC timestamp>, new and so empty
  Every ScheduledBackup has immediate: true, so a new incarnation has a base
  backup in its own prefix minutes after it starts, and a later recovery
  picks it. S3 access is curl --aws-sigv4 with the restore key (already in
  bootstrap.sh's secret list), so the CI image needs no new tool; the
  OpenBao restore reads the same S3 endpoint and region.
- The six clusters that recovered from their archive store now name
  <x>-store-source in externalClusters. serverName stays the cluster name
  everywhere. The app Kustomizations depend on cnpg-object-stores, which
  depends on infrastructure-cnpg-barman.

Verified against the bucket, read-only, 2026-10-16: the restore key lists
renner-uno-k8s-cnpg; the listing parser on real responses, top level and an
encoded nested prefix; for wp-pg it picks wp-pg-woodpecker (newest
20261016T024000) over wp-pg (20260818T023502); leftover test serverNames
hold no base backups and match no candidate pattern.

Production rollout, one time:
1. Create flux-system/cnpg-paths with the current prefixes as both source and
   archive (ak-pg, ha-pg, im-pg, mx-pg, pprl-pg, tr-pg; wp-pg-woodpecker), so
   the running clusters keep archiving where they do.
2. The app Kustomizations that give up their ObjectStores (authentik,
   home-assistant, immich, matrix, paperless, trmnl, woodpecker) must run
   with prune: false (the stack's first PR) when this merges;
   cnpg-object-stores adopts the objects. All 14 also carry prune: disabled.
3. Whether changing externalClusters on a live Cluster restarts its pods is
   not verified; check on one cluster first.

Older generations are not cleaned up: retention applies only to the current
archive prefix.

RECOVERY.md describes the new layout: where the current prefixes are
(cnpg-paths), the per-incarnation backup paths for the precondition check,
archiving to the new prefix after promotion, and adding a CNPG cluster to a
running cluster (its keys go into the existing cnpg-paths by hand). The
manual restore drill reads <x>-store, the current archive. The app
kustomization comments name cnpg-object-stores instead of
infrastructure-cnpg-barman. write_cnpg_paths checks for yq first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:18:14 +02:00
claude-botandClaude Opus 5.5 93cb53130c feat(velero): the cluster restores its app volumes itself, Gitea first
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
review-bot reviewed: comment
One Velero Restore per Schedule (kubernetes/restore/<schedule>/), applied
by Flux through a restore-<schedule> Kustomization
(clusters/homelab/restore.yaml), restoring the schedule's newest completed
backup. Every app Kustomization with backed-up volumes depends on its own
restore Kustomization, so on a fresh cluster no app starts on an empty
volume, and no app waits for another app's data. This reverses the
decision on #200 (option B, 952e6150): that removed a restore Job that was
never wired in, while the seed-* rsync Kustomizations were still the
bootstrap path. Both are gone; restoring from Velero on a fresh cluster is
now wanted.

Gitea goes first: it is the registry for the in-house images. The gitea
Kustomization depends on restore-gitea alone; every other restore
Kustomization depends on restore-gitea, so Velero moves Gitea's data
first. Each app depends on its own: apps, audiobookshelf (libation through
it), authentik, home-assistant (matter-server, home-assistant-iot, otbr,
whisper too, same namespace and schedule), immich, influxdb, matrix,
music-assistant, navidrome, paperless, trmnl, wordpress. Timeouts follow the
data: immich 6h, audiobookshelf 3h, the rest 1h. One shared gate made
authentik wait for immich's photo library (45 minutes in restore rehearsal
run 28) and held a kustomize-controller worker all that time.

Ordering and failure handling, from Velero release-1.17 source:
- A Restore whose schedule has no synced backup yet goes FailedValidation
  for good (restore_controller.go). The restore Kustomizations therefore
  depend on velero-backups-synced, which is Ready once the
  BackupStorageLocation has completed a sync (status.lastSyncedTime), and
  which depends on the velero Kustomization (the BackupStorageLocation and
  its credentials).
- After that sync, FailedValidation with "No backups found for schedule"
  means there is nothing to restore and counts as done; any other
  FailedValidation, Failed or PartiallyFailed fails the Kustomization.
- A claim that already exists is skipped (csi pvc_action: "PVC already
  exists"), and the Restore completes with a warning. On the running
  cluster the Restores touch nothing.
- Velero deletes a Restore with the backup it came from (30-day TTL);
  Flux recreates it, and it again skips the existing claims.
- All StorageClasses bind Immediate, so the data mover does not wait for
  a consuming pod.

The velero-schedules header points at the restores instead of the manual
runbook, and bootstrap.sh's wait-apps comment says the volumes come from
Velero. wait-apps waits for gitea, then apps, up to an hour each (the
restore-gitea timeout), since both now include restores.
RUNBOOK-cold-rebuild.md Step 5 describes the restores and how to retry a
failed one: a Restore never retries, and while it is failed every
Kustomization depending on its restore Kustomization stops applying.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:15:55 +02:00
claude-botandClaude Opus 5.5 24e969abb0 docs(gpu): the intel-gpu-plugin comment describes its node label
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
review-bot reviewed: comment
It still said the DaemonSet has no nodeSelector and runs on every node.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:15:06 +02:00
claude-botandClaude Opus 5.5 7f1d7ac777 feat(iot): v30iot workloads run only where a node declares the IoT VLAN; HA no longer waits on them
Five workloads attach a macvlan on ens19 (v30iot) with DHCP IPAM:
shelly-manager and the mDNS relay (inside the home-assistant Kustomization),
matter-server, music-assistant and otbr. On a node without that VLAN the CNI
add fails and the pod never starts.

- k8s-1..3 carry net.renner.uno/iot-vlan: "true"; each of them declares
  ens19 in its VLANConfig. The five Deployments select on it. The comments
  they replace described the retired k8s-edge taint.
- shelly-manager and the mDNS relay move to apps/home-assistant-iot, with a
  Kustomization of their own (depends on gitea, home-assistant and the
  infrastructure Kustomizations). home-assistant's wait: true no longer
  covers them, so HA and everything waiting on it (evcc, trmnl,
  zone-configurator, speech-to-phrase) are Ready without the VLAN, and a
  broken IoT bridge no longer holds them back in production either.
- shelly-manager-auth stays in apps/home-assistant: mqtt-account-controller
  mounts it too, and that Kustomization comes first. Moved with
  shelly-manager, mqtt-account-controller would wait for a Secret that only
  the later Kustomization creates.
- The probes-blackbox and authentik blueprint comments name the new paths.

On a site without the label the IoT Kustomizations fail at their timeout,
naming the Pending pods; nothing else depends on them.

Production rollout:
1. Label the nodes first (talosctl patch the nodeLabels), or the five pods go
   Pending.
2. home-assistant must run with prune: false (the stack's first PR) when
   this merges, so it drops shelly-manager and the mDNS relay from its
   inventory without deleting them and home-assistant-iot adopts them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:15:03 +02:00
claude-botandClaude Opus 5.5 5e115cde11 feat(gpu): iGPU workloads run only where a node declares the iGPU
llama-server, llama-embed, wiki-embed (gpu.intel.com/i915) and the wiki
behind them are bound to the Intel iGPU. On a site without one (Hetzner,
restore rehearsal runs 27/28) their pods stayed Pending, and the GPU plugin
and exporter DaemonSets sat on every node with nothing to serve.

The node patches k8s-1..3 label their machines
hardware.renner.uno/intel-gpu=true. The GPU plugin, the exporter, the
three llama Deployments and the wiki now select on it. Elsewhere the
DaemonSets have no pods and the Deployments stay unscheduled.

Production rollout order: the label must be on the nodes (talosctl patch,
no reboot) before these manifests reconcile, or the GPU plugin and the
llama/wiki pods are removed or left Pending.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:14:47 +02:00
claude-botandClaude Opus 5.5 ac44925baf fix(flux): services that need OpenBao secrets apply after the secret store
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
review-bot reviewed: comment
crowdsec, external-dns-unifi, netbird-ingress and velero were in the
infrastructure Kustomization, while the ExternalSecrets that create their
Secrets were in infrastructure-crds, which depends on infrastructure. On a
cold start each was therefore installed before its Secret could exist: the
pods sat in CreateContainerConfigError until OpenBao was unsealed and ESO
synced. For the HelmReleases (crowdsec, external-dns-unifi, velero) Helm's
wait timed out meanwhile; with remediation.retries: 3 they give up after
four attempts and stay failed even once the Secret appears. Velero failing
that way stops every restore.

| workload | Secrets |
|---|---|
| crowdsec | crowdsec-bouncer-key (crowdsec, ingress), crowdsec-edge-bouncer-key |
| external-dns-unifi | external-dns-unifi-secret |
| netbird-ingress | netbird-ingress-setup-key |
| velero (+ backup-verify CronJob) | cloud-credentials, cloud-credentials-hetzner, hcloud-backup-verify |

Each now has a Kustomization of its own (clusters/homelab/
infrastructure-services.yaml), depending on infrastructure and
infrastructure-crds, with its ExternalSecrets beside it. The openbao
ClusterSecretStore in infrastructure-crds turns Ready only once OpenBao is
unsealed, so the Secret exists before the consumer installs. One
Kustomization each so that a broken service holds back only itself.

Velero's Schedules need the velero.io CRDs from its HelmRelease, so they move
from post-crds to infrastructure/velero-schedules with a Kustomization after
velero.

post-crds keeps cloudflare-api-token (consumed by its own ClusterIssuer) and
flux-git. The directories themselves stay where they are, so paths such as
the CI test infrastructure/velero/backup-verify/tests/run.sh are unchanged.

netbird-ingress gets 10m to roll its DaemonSet over three nodes
(maxUnavailable 1, minReadySeconds 45). The crowdsec comments name the
Kustomization it now runs in, and flux-graph.sh colours the new nodes.

Production rollout: infrastructure and infrastructure-crds must run with
prune: false (the stack's first PR) when this merges. Pruned, the velero
HelmRelease would be uninstalled, and the moved ExternalSecrets would take
their Secrets with them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:14:41 +02:00
claude-botandClaude Opus 5.5 59ebc125fa fix(flux): in-house-image infrastructure waits for Gitea; bird runs only where table 47 exists
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: comment
bird and netbird-service-sync run images from Gitea's registry with no
copy on ghcr.io. In the infrastructure Kustomization they started long
before the restored Gitea served them and sat in ImagePullBackOff (restore
rehearsal runs 27, 28). infrastructure cannot depend on gitea, which
depends on it, so both move to a new Kustomization, infrastructure-inhouse,
that depends on infrastructure, infrastructure-crds and gitea. Their
namespace stays in infrastructure. e2e-test.sh checks the new Kustomization
for Ready.

Images published to ghcr.io too have a fallback in the nodes' registry
mirror and need no ordering; multus cni-plugins stays in infrastructure.
The secret-rotator and backup-verify CronJobs also use Gitea images but
only run on schedule; they are left in place.

bird fills routing table 47, which exists only where the site patch routes
pod traffic into it, and renders its config from node annotations only that
site sets. On a site without it the template rendered <no value> and the
init container crash-looped (run 28). The DaemonSet now selects on
net.renner.uno/pod-table=47, which site-homelab.yaml puts on its nodes.

Production rollout:
1. The pod-table label must be on the nodes (talosctl patch, no reboot)
   before the DaemonSet change reconciles, or bird is removed from every
   node and pod traffic falls through to the main table.
2. infrastructure must run with prune: false (the stack's first PR) when
   this merges. It then drops bird and netbird-service-sync from its
   inventory without deleting them, and infrastructure-inhouse adopts
   them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:13:48 +02:00
claude-botandClaude Opus 5.5 30a2d37fdc feat(scripts): flux-graph.sh draws the Kustomization dependency graph
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
review-bot reviewed: comment
Reads every kustomize.toolkit.fluxcd.io/v1 Kustomization in
kubernetes/clusters/homelab and emits Graphviz DOT, one edge per dependsOn
entry, pointing from the dependency to its dependent. Each node carries its
wait mode and timeout, drawn as Flux actually checks it: a Kustomization
with healthChecks and no wait is health-checked (as infrastructure is), one
with neither has no check at all, whatever its healthCheckExprs say.
Infrastructure is green, the Velero restore chain blue, wait: false dashed,
suspended Kustomizations grey.

With no argument or a .dot path it writes the full graph; with a .svg or .png
path it renders the transitive reduction (tred). Generated rather than
checked in, so it cannot drift from the manifests. Works with either yq
(mikefarah's or the Python wrapper): yq only converts YAML to JSON, jq builds
the DOT.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:13:24 +02:00
claude-botandClaude Opus 5.5 469abc9305 fix(authentik): its ServiceMonitor lives in monitoring-rules; the radius one is the operator's
The authentik chart renders server/servicemonitor.yaml whenever
server.metrics.serviceMonitor.enabled is set, without checking
.Capabilities for monitoring.coreos.com (verified in chart 2026.8.3). The
authentik Kustomization does not depend on monitoring, so on an empty
cluster the Helm install fails until kube-prometheus-stack has installed the
CRDs. The chart's ServiceMonitor is switched off and declared standalone in
monitoring-rules, as cilium and velero already do. The selector matches the
live Service authentik-server-metrics (port metrics, 9300); the new name
differs from the chart's authentik-server, so the two never contend for one
object while production rolls over.

authentik-outpost-servicemonitors.yaml hand-wrote a headless Service
ak-outpost-local-radius-metrics and a ServiceMonitor for it, because the
radius outpost controller used not to expose its metrics port. authentik
2026.8.3 does: the operator creates that Service (http-metrics 9300) and its
own ServiceMonitor. On a cold cluster the operator created the Service first,
with a ClusterIP; clusterIP is immutable, so the dry-run of the hand-written
clusterIP: None copy failed and took the whole monitoring-rules
Kustomization with it (restore test run 29). The file goes; the operator
owns both. No alert rule refers to the radius job.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:13:24 +02:00
claude-botandClaude Opus 5.5 6aa8e54346 fix(flux): each Kustomization depends on what its workloads need, and waits for them
On a cold cluster (restore rehearsal runs 22-28) apps started before the
things they need and sat in ImagePullBackOff or CreateContainerConfigError,
while their Kustomizations reported Ready or held kustomize-controller
workers. The dependency graph now states those needs:

- infrastructure health-checks the openbao StatefulSet, not only the
  HelmRelease (Ready as soon as the chart is installed, sealed or not). Its
  readiness probe is `bao status`, which fails while sealed, so 3/3 ready
  means every replica is unsealed; infrastructure-crds no longer applies the
  ClusterSecretStore against a sealed OpenBao, which External Secrets then
  backs off for minutes.
- gitea edges: apps whose images live only in Gitea's registry (authentik,
  home-assistant, paperless, review-bot, wiki) and apps that are front ends
  to Gitea (gitea-mcp, woodpecker, renovate). Images also published to
  ghcr.io have a fallback in the nodes' registry mirror and get no edge
  (apps' ical-proxy, trmnl's alpine-jq).
- monitoring depends on influxdb: Grafana mounts grafana-influxdb-token,
  which a Job in the influxdb app writes.
- evcc depends on home-assistant: mqtt-account-controller there delivers
  mqtt-evcc-client-tls, and mosquitto is its broker.
- trmnl depends on home-assistant (the renderer reads HA's API);
  home-assistant depends on influxdb (its InfluxDB integration writes there).
- home-assistant and wordpress wait for their workloads. With wait: false
  and no healthChecks, Ready means only that the manifests applied, and
  healthCheckExprs are never evaluated (kustomize-controller's checkHealth
  returns first). otbr is suspended: it is not in use.
- home-assistant's timeout is 30m, not 4h. The 4h was sized for a seed
  initContainer that waited up to 3h for an HAOS backup; the slowest part
  left is ha-pg recovering from barman. With wait: true the 4h would also
  hold a kustomize-controller worker that long on a failed cold start.

No cycles: gitea depends only on the infrastructure layers; influxdb does
not use monitoring's CRDs; home-assistant does not reach evcc or trmnl.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:13:24 +02:00
claude-botandClaude Opus 5.5 c7b504a3f5 fix(bootstrap): k8s-gateway values with either yq; cni-confdir step re-runs
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
review-bot reviewed: comment
- The k8s-gateway values were read with `yq -y`, a flag only the Python yq
  has. The vmtest image installs Alpine's `yq`, which is yq-go (mikefarah),
  so the talos phase would fail there. Without the flag both print a
  document helm accepts, as the Cilium values line already relies on.
- A cni-confdir pod left from an earlier run (Failed or Succeeded) made
  `kubectl apply` a no-op and the wait time out. The step deletes it first.
- The Cilium HelmRelease comment named itself the only place Cilium is
  configured; the site patch's cilium-site ConfigMap is the other.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:13:13 +02:00
claude-botandClaude Opus 5.5 10bb7c7893 feat(talos): site facts live in the site patches, and what reads them follows
What differs between sites moves out of cluster-wide config into the Talos
site patches (site-homelab.yaml, site-qemu.yaml), and the consumers read it
from there:

- nodeIP.validSubnets lists IPv4 first, as the live nodes run. Talos keeps
  the order (siderolabs/net FilterIPs) and the first entry becomes the
  primary node IP; with fd02:46::/64 first, kube-apiserver exits with
  'service IP family "10.96.0.0/12" must match public address family'.
- etcd.advertisedSubnets (VLAN 46, a fact of the home network) moves
  unchanged from cluster.yaml to site-homelab.yaml; the rendered production
  config is byte-identical.
- Pod routing (routingMode, autoDirectNodeRoutes, directRoutingDevice) and
  the native-routing CIDRs are a cilium-site ConfigMap per site patch, read
  by the Cilium HelmRelease via valuesFrom. bootstrap.sh merges it the same
  way: site values first, HelmRelease values on top. Production renders
  unchanged (helm template, TLS secrets excluded).
- Each site patch states its node resolver list (ResolverConfig: k8s-gateway
  first, then the site's resolver) and k8s-gateway's upstream as a
  k8s-gateway-site ConfigMap, read by the k8s-gateway HelmRelease via
  valuesFrom. bootstrap.sh installs k8s-gateway right after the nodes are
  Ready, from the HelmRelease under the site values, and Flux adopts it:
  until k8s-gateway answers, every lookup waits out the node's 2 s read
  timeout and pods resolve nothing, Flux included.
- bootstrap.sh creates Cilium's cni.confPath subdirectory with a
  hostNetwork pod per node (/etc/cni is read-only to the kubelet on Talos)
  and applies Multus right after Cilium; without it the kubelet reports
  "cni plugin not initialized" and nothing schedules on a cold cluster.
- site-homelab.yaml labels its nodes net.renner.uno/pod-table=47 (table 47
  exists only where the site routes pod traffic into it); k8s-1..3 label
  hardware.renner.uno/intel-gpu and net.renner.uno/iot-vlan, the hardware
  each machine has. The workloads select on these labels in later changes.

Rollout order for production: the cilium-site and k8s-gateway-site
ConfigMaps must exist before the HelmReleases reconcile (valuesFrom is not
optional). Talos creates inline manifests on a config change but never
updates them; changes go through talosctl upgrade-k8s. The node labels go
on with talosctl patch, no reboot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:12:17 +02:00
claude-botandClaude Opus 5.5 03c6013190 docs(rook): CSI ServiceAccount comments describe the ceph-csi-drivers release
review-bot reviewed: comment
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
serviceaccounts.yaml and the rook-ceph operator HelmRelease still said the
ceph-csi-drivers chart is not installed and the SAs and RBAC are only
rendered statically.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:12:12 +02:00
claude-botandClaude Opus 5.5 0aef8dce4e fix(mqtt-account-controller): start without the HA client certificate
The controller mounted mqtt-home-assistant-tls as a required volume, and is
the only creator of that Secret (it applies the cert-manager Certificates of
the register's cert accounts). On a new cluster - Velero restores volumes,
not Secrets - its pod could never start, and mosquitto, Home Assistant,
Zigbee2MQTT and evcc stayed in ContainerCreating behind the Secrets it would
have created.

Optional now. Verified in konairius/mqtt-account-controller main: the cert
dir is read only by the HA cert sync (02:00-05:00 window, errors logged);
the broker is dialled lazily; Secret.Read treats a missing Secret as empty;
Reconcile runs reconcileCerts even when the broker step fails. So the
controller starts, creates the Certificates, the consumers start, and the
next loop reconciles the broker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:10:33 +02:00
claude-botandClaude Opus 5.5 bb85e00f2f fix(gitea): no pinned volumeName on data-gitea-0
The claim named its production PV, which does not exist on a rebuilt
cluster: the claim stayed Pending there, and a restored claim bound to a
new volume made the gitea Kustomization fail on the immutable field.

volumeName leaves git and the gitea Kustomization ignores
/spec/volumeName on this claim. kustomize-controller is the field's
only manager on the live claim, so its ignore rule adopts the live value
instead of clearing it; size growth still applies.

To verify after merge: the gitea Kustomization reconciles Ready and the
live claim still names pvc-e7c56db2-481d-40cb-b94a-b264b7b4b5be.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:10:33 +02:00
claude-botandClaude Opus 5.5 9b598fab38 fix(rook): install the ceph-csi-drivers chart Rook requires
Since Rook 1.20 the CSI Driver and OperatorConfig resources come from the
ceph-csi-drivers chart (rook Documentation/Helm-Charts/csi-drivers-chart.md:
"Rook requires the installation of the Ceph-CSI Driver chart"). The chart
was never installed here: the live cluster's Drivers are the ones the
operator migrated once during the 1.19->1.20 upgrade (2026-05-09), and
PR #12 only rendered the ServiceAccounts and RBAC. A fresh cluster
therefore has no CSI driver and provisions no volume: found by the
Hetzner restore test, where every PVC stayed Pending ("Waiting for a
volume to be created either by the external provisioner
'rook-ceph.rbd.csi.ceph.com'").

Values: Rook's recommended values for 1.21.0 plus the live shape:
2 controller replicas with Recreate, the workload-class=pinned
toleration on the node plugins, a snapshotter for RBD (Velero's CSI
snapshots of ceph-block) and group snapshots for CephFS.

The chart's ServiceAccounts are named per driver
(rook-ceph-rbd-csi-ceph-com-ctrlplugin-sa, ...), so they do not collide
with the static ones; the static ServiceAccounts and RBAC stay until the
live CSI pods run on the chart's, and go in a second change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:10:33 +02:00
claude-botandClaude Opus 5.5 04b3f81e00 fix(flux): no pruning in the Kustomizations that hand objects to new owners
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: comment
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
The restore-test stack moves objects between Kustomizations:
- bird and netbird-service-sync to infrastructure-inhouse;
- crowdsec, external-dns-unifi, netbird-ingress, velero and the Velero
  Schedules, plus their ExternalSecrets, to their own Kustomizations;
- shelly-manager and the mDNS relay to home-assistant-iot;
- the CNPG ObjectStores to cnpg-object-stores.

Flux's garbage collection deletes an object dropped from a Kustomization's
source if its live owner labels (kustomize.toolkit.fluxcd.io/name and
/namespace) still name that Kustomization. Those labels belong to whichever
Kustomization applied the object last. Two phases (add to the new owner,
then remove from the old one) therefore do not guarantee anything: if the
old owner applied last before phase 2, it prunes the object
(kustomize-controller v1.9.6 deleteObjects, fluxcd/pkg/ssa v0.76.2
manager_delete.go).

With prune: false the giving Kustomizations drop the moved objects from
their inventory without deleting them, and the new owners adopt them. This
covers infrastructure, infrastructure-crds, home-assistant, authentik,
immich, matrix, paperless, trmnl and woodpecker. The last PR of the stack
switches prune back on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:10:22 +02:00
security-bot c45da43575 chore(image-automation): bump ghcr.io/music-assistant/server:2.10.7
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-10-17 13:43:00 +00:00
security-bot b5df790876 chore(image-automation): bump gitea.renner.uno/gitea_admin/review-bot:2026-10-17-1253
ci/woodpecker/cron/alert-monitor Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
Updates managed by image-automation-controller.
2026-10-17 13:02:09 +00:00
k.renner 30a082864e Merge pull request 'feat(review-bot): stream Mistral, add an Anthropic backend and a TypeSafe model selector' (#1306) from claude/review-bot-backends into master
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#1306
2026-10-17 12:53:08 +00:00
claude-botandClaude Opus 5.5 7749636a8c fix(review-bot): read all API keys from apps/review-bot
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/review-bot-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
The Mistral, Anthropic and TypeSafe keys come from the bot's own OpenBao path
apps/review-bot (minstral-api-key, antropic-api-key, typesafe-api-key) instead
of the wiki's keys. All keys and both tokens are in review-bot-secrets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 14:45:51 +02:00
claude-botandClaude Opus 5.5 16d957dff1 feat(review-bot): stream Mistral, add an Anthropic backend and a TypeSafe model selector
review-bot queued
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/review-bot-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Mistral cut every POST /conversations off with HTTP 502 exactly ten minutes
after it started (about 30 times in the last 7 days), and every retry restarted
the whole review, so a review needing more than ten minutes could never finish.

- Mistral: the conversation now streams (stream=true, SSE). A stream that
  breaks off after the conversation started is looked up in the stored history
  (store=true, GET /conversations/{id}/history) before it is asked again. A
  broken stream is retryable like 429/502/503/504; a
  conversation.response.error event is not. One agent per model, named
  <AGENT_NAME>-<model>.
- Anthropic: new backend on the Messages API (Go SDK, streaming, adaptive
  thinking, effort high). Anthropic connects to the Gitea MCP server
  (gitea-mcp.renner.uno/mcp) with a read-only token; the toolset is in
  allowlist mode with only the read tools enabled. Paused turns are resumed,
  refusals use the server-side fallback on Opus/Sonnet 5.5.
- Models: MODELS lists backend:model = description, the first is the default.
  A backend whose keys are missing is switched off with a warning instead of
  stopping the bot.
- Selector: SELECTOR=typesafe asks TypeSafe (Jev, Choice) which model reviews
  each PR, from the title, description, size and changed files. A failure or
  an unusable choice falls back to the default model.
- Secrets: the Anthropic and TypeSafe keys reuse the wiki keys
  (apps/ai/wiki-anthropic, apps/ai/wiki-typesafe), like the Mistral key does.
  The MCP token is a second review-bot PAT scoped read:repository,read:issue,
  read:user, minted by a new rotate-mint-mcp step into
  infrastructure/review-bot#mcp_token (seeded with a placeholder). Each
  optional key has its own ExternalSecret and optional env ref.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 14:08:22 +02:00
security-bot 0111139a33 chore(image-automation): bump ghcr.io/home-assistant/home-assistant:2026.10.2
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-10-16 20:49:13 +00:00
k.renner f15331a19e Merge pull request 'fix(woodpecker): recover wp-pg from the prefix it archives to' (#1295) from fix/wp-pg-recovery-source into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#1295
2026-10-16 19:25:16 +00:00
claude-botandClaude Opus 5.5 b989f3eea7 fix(woodpecker): recover wp-pg from the prefix it archives to
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
wp-pg-store-source, the recovery source of wp-pg's bootstrap.recovery,
pointed at s3://renner-uno-k8s-cnpg/wp-pg: the prefix of the retired
apps/wp-pg. Since the namespace move wp-pg archives to wp-pg-woodpecker
(wp-pg-store). A cold rebuild would therefore have restored woodpecker as of
2026-08-18 and reported success.

Bucket, listed read-only 2026-10-16:
- wp-pg/wp-pg/base: newest 20260818T023502
- wp-pg-woodpecker/wp-pg/base: 20260916T024000 .. 20261016T024000 (daily),
  wals/ through timeline 6 (00000006.history from 2026-09-23)

The source now reads wp-pg-woodpecker; serverName stays wp-pg, the name the
live cluster archives under. bootstrap.recovery is honoured only when PGDATA
is absent, so the running cluster is unaffected. The old prefix wp-pg is no
longer referenced and keeps its objects; nothing applies retention to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 21:13:36 +02:00
security-bot e733bd6f7b chore(image-automation): bump docker.gitea.com/gitea-mcp-server:1.9.0
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-10-16 09:33:08 +00:00
k.renner a742ef4e3c Merge pull request 'chore(paperclip): remove Paperclip and everything that only served it' (#1291) from chore/remove-paperclip into master
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#1291
2026-10-16 06:46:56 +00:00
security-bot 68c07963fa chore(image-automation): bump ghcr.io/paperless-ngx/paperless-ngx:3.3.2
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-10-16 06:42:45 +00:00
security-bot d15f3c526c chore(image-automation): bump gitea.renner.uno/gitea_admin/review-bot:2026-10-16-0620
ci/woodpecker/cron/alert-monitor Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
Updates managed by image-automation-controller.
2026-10-16 06:23:52 +00:00
k.renner 48bb5a9074 Merge pull request 'feat(review-bot): report queued/running/failed per PR (commit status + /status API)' (#1293) from feat/review-bot-status into master
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: k.renner/homelab#1293
2026-10-16 06:20:25 +00:00
claude-botandClaude Opus 5.5 9113cf270c feat(review-bot): serve /status on review-bot.renner.uno (LAN-only)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/review-bot-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Ingress routes only the /status prefix; /hook and /metrics stay
cluster-internal. No public label, so it is not on the NetBird proxy.
The new CNP lets Traefik reach the pod on 8080.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 08:12:54 +02:00
claude-botandClaude Opus 5.5 5ccb3ceaa6 feat(review-bot): report queued/running/retrying/failed per PR
Every phase of a pull request's review is now visible in two places:

- a commit status "review-bot" on the reviewed head: pending while
  queued, running or waiting to retry; success once reviewed (with the
  verdict) or skipped; error with the reason when the review failed;
  warning when new commits superseded the reviewed head. The statuses
  are posted one at a time in order, so a later phase never lands before
  an earlier one, and never block the queue.
- GET /status and GET /status/{owner}/{repo}/{n}: the in-memory state
  of every PR seen since start, with the queue position while queued.
  Finished entries are dropped after a day.

Until now a failed review posted nothing, so a PR whose review failed
looked the same as one still waiting.

Also gofmt report_test.go, which the CI gofmt check flags on master.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 08:12:42 +02:00
k.renner 4579fcd54b Merge pull request 'fix(talos): host DNS asks k8s-gateway first' (#1292) from node-dns-via-k8s-gateway into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#1292
2026-10-16 06:04:48 +00:00
claude-botandClaude Opus 5.5 afe009637a fix(talos): host DNS asks k8s-gateway first
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
containerd pulls in-house images from gitea.renner.uno in the host
network namespace, through Talos host DNS. Its only upstream was the
UDM (from DHCP), so every node-level pull of an in-cluster registry
depended on an external DNS.

A ResolverConfig document lists k8s-gateway (10.96.0.11) first and the
UDM second. It replaces the DHCP nameservers. Talos host DNS tries
upstreams in order and moves on after an error or SERVFAIL/REFUSED
(internal/pkg/dns/dns.go); a silent upstream costs the 2 s proxy read
timeout (coredns plugin/pkg/proxy), inside the 4.5 s per-query
deadline, so a cold node still resolves through the UDM until Cilium
and k8s-gateway are up.

Dry run on k8s-1: applies without reboot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 07:51:19 +02:00
claude-botandClaude Opus 5.5 7ada3540b5 chore(paperclip): remove Paperclip and everything that only served it
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Removes the Paperclip deployment (namespace paperclip, CNPG cluster
pclp-pg, ingresses, image automation, server image build) and the
infrastructure that existed only for its agents:

- agent sandbox: paperclip-renner namespace and CNPs, the
  kubernetes-sigs/agent-sandbox controller with its namespace, CNP and
  Renovate rules
- agent-runtime-{go,ops} images and the build-agent-runtime pipeline
- matrix bridge (Element <-> Paperclip agents) and synapse's appservice
  registration mount
- monitoring loki-read-gateway (only called by the paperclip Role)
- MQTT account agents-read and the controller's RBAC in ns paperclip
- the OpenBao secret-exists-check policy file
- agent admits in the wiki, lidarr, shelly-manager, mosquitto and
  hubble-relay CNPs; the paperclip-api Gitea webhook host; the wiki API
  key; the ClusterSecretStore namespace entry; the pclp-pg RECOVERY row
- the Paperclip design spec and plan

Authentik group "Paperclip Users" is switched to state: absent so the
blueprint deletes it. package-retention's EXCLUDE_PACKAGES becomes
optional, since its only value was the agent-runtime prefix.

The shelly-manager api-proxy sidecar stays: mqtt-account-controller
calls shelly-manager-api too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 07:50:12 +02:00
k.renner 39f8279e27 Merge pull request 'fix(k8s-gateway): port values to the chart 4.x servers schema' (#1289) from worktree-k8s-gateway-chart-4 into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#1289
2026-10-16 05:33:44 +00:00
claude-botandClaude Opus 5.5 f13069f3c7 fix(k8s-gateway): port values to the chart 4.x servers schema
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Chart 4.0.0 (installed 2026-10-08 via the open semver range) replaced
domain/watchedResources/fallthrough/extraZonePlugins with a servers:
list. The old keys were ignored and the chart default ran instead:
k8s_gateway example.com watching Service/HTTPRoute, forward to
/etc/resolv.conf, Service type LoadBalancer. No renner.uno Ingress name
has been answered in-cluster since.

The new values render the same Corefile as the last 3.x release
(helm rev 7): .:1053, k8s_gateway renner.uno on Ingress with
fallthrough renner.uno., cache with serve_stale, forward to the UDM,
Service ClusterIP 10.96.0.11 port 53, runAsUser 1000. Verified with
helm template against chart 4.0.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 07:25:07 +02:00
security-bot 104c5edf67 chore(image-automation): bump ghcr.io/usetrmnl/trmnl-ha-amd64:0.11.1
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-10-15 23:43:55 +00:00
security-bot ce7c8445c3 chore(image-automation): bump gotenberg/gotenberg:8.38.1
ci/woodpecker/cron/alert-monitor Pipeline failed
ci/woodpecker/cron/issue-fix Pipeline failed
Updates managed by image-automation-controller.
2026-10-15 15:44:47 +00:00
k.renner 704f6f4a64 Merge pull request 'chore(authentik): remove the passkey-only flows (stage 3)' (#1287) from chore/authentik-drop-passkey-flows into master
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
Reviewed-on: k.renner/homelab#1287
2026-10-15 09:40:25 +00:00
claude-botandClaude Opus 5.5 a5845f6b60 chore(authentik): remove the passkey-only flows (stage 3)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Stage 3 of unifying the auth flows. After #1285/#1286 no provider, brand
or flow references the passkey-only objects any more (checked live:
0 providers on either flow; passkey-webauthn-validation and
passkey-passwordless-identification are bound only inside the two
retired flows). They are now declared absent: both flows (their stage
bindings and the policy bindings on those cascade), the
passkey-passwordless-identification stage, the passkey-webauthn-validation
stage and the passkey-user-has-webauthn policy. The shared default
stages they bound (password, MFA validation, login) stay.

The passkey-stepup.yaml key keeps its name, so the blueprint instance
stays the same; it now only carries the OAuth2 providers and the
removals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:14:07 +02:00
security-bot a81be6a53d chore(image-automation): bump gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-10-15-0857
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-10-15 08:57:56 +00:00
k.renner 4eb0617812 Merge pull request 'fix(authentik-gate-sync): PATCH proxy providers with the full body' (#1286) from fix/gate-sync-full-patch into master
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: k.renner/homelab#1286
2026-10-15 08:57:19 +00:00
claude-botandClaude Opus 5.5 89771995b3 fix(authentik-gate-sync): PATCH proxy providers with the full body
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
A drifted proxy provider was PATCHed with only the drifted fields.
ProxyProviderSerializer.validate (providers/proxy/api.py, 2026.8.3)
checks internal_host against mode from the request attrs alone,
defaulting mode to proxy, so a flow-only PATCH fails with 400
"Internal host cannot be empty when forward auth is disabled." Seen
live after #1285: 8 gates could not move to the new flows and stayed on
their old ones.

The fake Authentik in the tests now applies the same validation, and a
test covers a flow change on an existing gate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 10:46:53 +02:00
k.renner d2514febb2 Merge pull request 'feat(authentik): move every app onto the SSO login (stage 2)' (#1285) from feat/authentik-sso-rollout into master
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#1285
2026-10-15 08:43:26 +00:00
claude-botandClaude Opus 5.5 3871013750 chore(authentik-gate-sync): drop a committed __pycache__ file
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 10:41:24 +02:00
claude-botandClaude Opus 5.5 67a1eb2ba5 feat(authentik): move every app onto the SSO login (stage 2)
Stage 2 of unifying the auth flows, after the llama and paperless trial
of stage 1 was confirmed:

- Brand authentik.srv.renner.uno: flow_authentication =
  sso-authentication (was NULL, i.e. the slug-first
  default-authentication-flow with password + MFA). Every provider
  without its own authentication_flow logs in through it, PVE and PBS
  included.
- The 7 blueprint OIDC providers (Home Assistant, NetBird,
  Audiobookshelf, Immich, Gitea, Grafana, Matrix MAS): authentication
  flow unset (brand), authorization implicit consent.
- authentik-gate-sync: default authorization flow implicit consent, via
  DEFAULT_AUTHORIZATION_FLOW on the Deployment and in the code; the
  per-gate login/authorization annotations are dropped. Paperless keeps
  paperless-authorization.

Every flow referenced here already exists, so the gate controller does
not hit the missing-flow removal seen in stage 1. Stage 3 removes the
passkey-only flows once nothing references them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 10:41:17 +02:00
k.renner 697a02e6f4 Merge pull request 'feat(authentik): add SSO login and paperless two-factor flows (stage 1)' (#1284) from feat/authentik-sso-flows into master
ci/woodpecker/cron/issue-fix Pipeline was successful
Reviewed-on: k.renner/homelab#1284
2026-10-15 08:31:03 +00:00
claude-botandClaude Opus 5.5 5e495d300a feat(authentik): add SSO login and paperless two-factor flows, trial on two gates
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Stage 1 of unifying the auth flows into two security levels on one SSO
login:

- sso-authentication: one identification stage that takes the password
  inline or a passkey via autofill (webauthn_stage); either factor alone
  logs in.
- paperless-authorization: password and passkey on every authorize.
  Authorization flows are planned without a pending user (oauth2
  authorize.py plan context), so a bare PasswordStage there fails with
  "No pending user"; the password is therefore checked inline by an
  identification stage, a deny stage rejects an identified user that
  differs from the session user, and a passkey validation with
  last_auth_threshold 0 follows (no authentik_mfa cookie, so it always
  asks).

Trial targets: llama-server (normal level: sso-authentication +
implicit consent) and paperless (high level). The brand, the OIDC
providers and the remaining gates follow once both are confirmed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 10:17:33 +02:00
k.renner c6762c6331 Merge pull request 'feat(cnpg): bootstrap all backed-up CNPG clusters from their barman backups' (#1283) from feat/cnpg-bootstrap-recovery-im-pprl into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#1283
2026-10-15 07:56:41 +00:00
k.renner cdeabf8066 Merge branch 'master' into feat/cnpg-bootstrap-recovery-im-pprl
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
2026-10-15 07:55:23 +00:00
security-bot 80f5cb0b3a chore(image-automation): bump gitea.renner.uno/gitea_admin/review-bot:2026-10-15-0721
Updates managed by image-automation-controller.
2026-10-15 07:50:44 +00:00
claude-botandClaude Opus 5.5 91be924c40 feat(cnpg): bootstrap ha-pg, mx-pg, pclp-pg and tr-pg from their backups
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was canceled
The remaining backed-up clusters still declared bootstrap.initdb, so a
cold rebuild came up with empty Home Assistant, Matrix, Paperclip and
TRMNL databases. Each now declares bootstrap.recovery from its
ObjectStore (serverName = cluster name). mx-pg drops its initdb
encoding/locale settings: the physical restore keeps the C collation.

RECOVERY.md lists all seven clusters; gf-pg has no ObjectStore and stays
on initdb.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 09:50:37 +02:00
claude-botandClaude Opus 5.5 185a16298e feat(cnpg): bootstrap im-pg and pprl-pg from their barman backups
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was canceled
Same change as ak-pg (#1281): im-pg and pprl-pg declared
bootstrap.initdb, so a cold rebuild came up with empty Immich and
Paperless databases despite 30 days of backups. Both now declare
bootstrap.recovery from their ObjectStore (serverName = cluster name).
bootstrap is honoured only when PGDATA is absent, so the live clusters
are unaffected.

RECOVERY.md drops the manual initdb->recovery swap and revert steps:
all three clusters it covers now restore on their own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 09:47:57 +02:00
k.renner 71230b18a3 Merge pull request 'fix(review-bot): let an unverified section no longer block approval' (#1282) from fix/review-bot-approve-with-unverified into master
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#1282
2026-10-15 07:21:02 +00:00
claude-botandClaude Opus 5.5 cc31d1860d fix(review-bot): let an unverified section no longer block approval
ci/woodpecker/pr/review-bot-test Pipeline failed
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
The bot has no cluster access, so any PR citing live-cluster evidence
gets a ⚪ Unverified section and could never be approved (e.g. #1281).
Approval now requires only no 🔴/🟡 section, matching the review banner
("approves only when it found nothing red or yellow"); the prompt's
verdict rule is aligned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 08:47:10 +02:00
k.renner e031f79865 Merge pull request 'feat(authentik): bootstrap ak-pg from its barman backup' (#1281) from feat/ak-pg-bootstrap-recovery into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#1281
2026-10-15 06:39:32 +00:00
claude-botandClaude Opus 5.5 778004bda3 docs(recovery): scope the unwired-backups sentence to im-pg and pprl-pg
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 08:31:43 +02:00
claude-botandClaude Opus 5.5 201990af6d feat(authentik): bootstrap ak-pg from its barman backup
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ak-pg declared bootstrap.initdb, a leftover of the retired seed-job design,
so a cold rebuild came up with an empty Authentik database: no users,
passkeys, groups or gate membership. It now declares bootstrap.recovery
from ak-pg-store (serverName ak-pg), the stanza RECOVERY.md already
documented and the pattern wp-pg uses.

bootstrap is honoured only when PGDATA is absent, so the live cluster is
unaffected. An empty object store makes bootstrap hang instead of coming
up empty. RECOVERY.md now limits the manual initdb->recovery swap to im-pg
and pprl-pg.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 08:24:54 +02:00
security-bot fb2466c531 chore(image-automation): bump gitea.renner.uno/gitea_admin/review-bot:2026-10-15-0524
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-10-15 05:50:43 +00:00
k.renner 0fc606cb14 Merge pull request 'chore(ci): remove workstation-era wiki maintenance pipeline' (#1280) from chore/remove-wiki-maintenance-ci into master
Reviewed-on: k.renner/homelab#1280
2026-10-15 05:50:05 +00:00
claude-botandClaude Opus 5.5 984ccd65a6 chore(ci): remove workstation-era wiki maintenance pipeline
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
All wiki interaction now goes through the wiki service API; CI agents that
distill and consolidate by writing the notes repo directly are retired.

Removes .woodpecker/wiki-maintenance.yml and ci/agents/wiki-maintenance/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 07:44:30 +02:00
security-bot 9644bf7e81 chore(image-automation): bump gitea.renner.uno/gitea_admin/ci-backup-verify:2026-10-15
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-10-15 05:30:02 +00:00
security-bot 34b5708f96 chore(image-automation): bump gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-10-15-0527
Updates managed by image-automation-controller.
2026-10-15 05:27:55 +00:00
k.renner 9f27272411 Merge pull request 'fix(ci): run build-ci-images steps in sequence' (#1278) from fix/ci-images-quota into master
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: k.renner/homelab#1278
2026-10-15 05:20:48 +00:00
claude-botandClaude Opus 5.5 1d0be7da0c fix(ci): run build-ci-images steps in sequence instead of per-step limits
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Drops the per-step resources from the previous commit and removes every
depends_on: [], so Woodpecker runs the kaniko steps serially in file order.
A full rebuild then holds one step pod at a time and cannot exceed the
woodpecker-ci-quota.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 06:50:04 +02:00
claude-botandClaude Opus 5.5 33007a6803 fix(ci): size build-ci-images steps to fit the woodpecker-ci quota
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
A change to build-ci-images.yml starts all 14 kaniko steps at once. With
the LimitRange default of 2 CPU each they asked for ~28 CPU in limits
against the 24-CPU woodpecker-ci-quota, so whichever pods were created last
were rejected ("exceeded quota: woodpecker-ci-quota"): alpine-jq and
cni-plugins in pipeline 13723, vmtest, tofu and secret-rotator in 13726.

Every step now sets its resources from 30 days of Prometheus data (peak
working set / CPU per step):
- ansible, k8s, vmtest, tofu (peaks <=983Mi, <=0.54 cores): 1 CPU, 2Gi
- secret-rotator, review-bot (Go builds, ~2 cores): 2 CPU, 1Gi
- the remaining eight (<=271Mi, <=0.45 cores): 1 CPU, 1Gi
Requests stay at the LimitRange defaults (100m / 256Mi).

Total 16 CPU / 18Gi in limits, leaving room in the quota for a concurrent
PR check. The quota itself is unchanged; its comment no longer claims the
workflow runs 4 pods.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:08:36 +02:00
security-bot 8df9cbab5f chore(image-automation): bump gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-10-14-1955
ci/woodpecker/cron/wiki-maintenance Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-10-14 19:57:54 +00:00
k.renner 45a579820e Merge pull request 'feat(authentik-gate-sync): publish the route's address as the source Ingress status' (#1277) from feat/authentik-gate-status into master
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: k.renner/homelab#1277
2026-10-14 19:55:09 +00:00
claude-botandClaude Opus 5.5 50b0aafdf2 feat(authentik-gate-sync): publish the route's address as the source Ingress status
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Like any ingress controller, authentik-gate-sync now owns the load-balancer
status of the Ingresses of its class: each pass it copies the address Traefik
reports for the generated route onto the source Ingress, and clears it while
the gate has no route. Previously the migrated Ingresses kept the address
Traefik wrote before their class changed, which Traefik never clears.

Status publishing is non-fatal (logged), so it never holds or removes a gate.
RBAC gains ingresses/status; the change is additive, so the order in which
Flux applies RBAC and the new image does not matter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:54:09 +02:00
k.renner ee6c7fa9ba Merge pull request 'feat(authentik-gate): migrate the remaining 12 gates' (#1276) from feat/authentik-gate-rest into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#1276
2026-10-14 19:39:19 +00:00
claude-botandClaude Opus 5.5 01213d6ee0 feat(authentik-gate): migrate the remaining 12 gates to the authentik-gate class
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
paperless, shelly-manager, zone-configurator, esphome, zigbee2mqtt,
alertmanager, prometheus, wiki, paperclip, navidrome, lidarr and wordpress
now gate through authentik-gate-sync: each source Ingress names its real
backend and carries the old provider's settings as annotations (group,
login-flow, authorization-flow where it was implicit consent, paperless'
skip-path).

Removed per gate: the ExternalName Service, the egress CNP in authentik,
the namespace CNP rule admitting the old outpost, and the blueprint
provider/application/binding/outpost (state: absent, outpost ->
application -> provider). Groups stay declared without users lists;
membership is managed in Authentik.

Per-gate specifics:
- paperless: the docs.renner.uno alias backends the generated
  paperless-gate Service (its middleware redirects before the backend); the
  auth.renner.uno/outpost.goauthentik.io route is removed, proxy outposts
  take their callback on the app host.
- alertmanager / prometheus: source Ingresses renamed to alertmanager /
  prometheus; the controller generates the *-gate route Ingresses.
- wordpress-admin: gains a tls block for www.renner.uno (wordpress-tls,
  certificate still owned by the public Ingress), so the gate's external
  host stays https.
- wiki, paperclip: only the gate peer/rule leaves their shared CNPs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:37:26 +02:00
security-bot 18a13981d7 chore(image-automation): bump gitea.renner.uno/gitea_admin/ci-backup-verify:2026-10-14
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-10-14 19:30:01 +00:00
security-bot a39efcee68 chore(image-automation): bump gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-10-14-1925
Updates managed by image-automation-controller.
2026-10-14 19:27:54 +00:00
k.renner 98d5f59966 Merge pull request 'feat(authentik-gate): named-port fix + migrate vscode (first group gate)' (#1275) from feat/authentik-gate-vscode into master
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: k.renner/homelab#1275
2026-10-14 19:24:53 +00:00
claude-botandClaude Opus 5.5 fb282baf6d feat(home-assistant): gate vscode.home.renner.uno through the authentik-gate class
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
First group gate on authentik-gate-sync. The Ingress switches to
ingressClassName authentik-gate with its real backend
(home-assistant-vscode:80) and annotations for the group (Home Assistant
Config Editors) and the code-server skip path; the default
passkey-stepup-authorization flow matches the old provider.

Removed: the ExternalName Service, the vscode-gate egress CNP, the
home-assistant-ns ingress rule for the old outpost, and the blueprint
provider/application/binding/outpost (state: absent, outpost ->
application -> provider). The group stays declared in the blueprint but
without a users list: membership is now managed in Authentik, and current
members are kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:21:52 +02:00
claude-botandClaude Opus 5.5 d549f9234f fix(authentik-gate-sync): keep named targetPorts named in the gate CNPs
A named targetPort was resolved to a number through the Service's
EndpointSlices, so with no ready endpoints (app restarting, crashlooping)
the gate counted as invalid: its route was removed and its Authentik
objects torn down, to be rebuilt once the pods came back. Cilium accepts
named ports in toPorts and resolves them against the selected pods'
container ports, so the name now goes into both CNPs as-is and the gate no
longer depends on the backend being up. The EndpointSlice read (and its
RBAC) is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:20:47 +02:00
security-bot 12d61ad7d3 chore(image-automation): bump gitea.renner.uno/gitea_admin/review-bot:2026-10-14-1906
Updates managed by image-automation-controller.
2026-10-14 19:20:42 +00:00
k.renner 88db839b84 Merge pull request 'feat(llama-server): gate llm.renner.uno through the authentik-gate class' (#1273) from feat/authentik-gate-llama into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#1273
2026-10-14 19:08:54 +00:00
claude-botandClaude Opus 5.5 406b7b61f7 feat(llama-server): gate llm.renner.uno through the authentik-gate class
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
First gate on authentik-gate-sync. The Ingress keeps its host, TLS, public
label and cert-manager annotation, switches to ingressClassName
authentik-gate and names its real backend (llama-server:8080); the
passwordless login flow and the manifest/service-worker skip path move to
auth.renner.uno/* annotations. The controller now owns the outpost, the
Traefik route and both CNPs.

Removed: the hand-written ExternalName Service, the llama-gate egress CNP,
the ai-ns ingress rule for the old outpost, and the llama-gate blueprint
objects (state: absent, outpost -> application -> provider).

Expect a short gap on llm.renner.uno at switch-over: Traefik drops the route
when the class changes and serves the new one once the controller's next pass
has started the new outpost.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:08:30 +02:00
security-bot 5d45ce47bc chore(image-automation): bump gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-10-14-1906
Updates managed by image-automation-controller.
2026-10-14 19:07:09 +00:00
k.renner 255b9b6b0c Merge pull request 'fix(ci): build authentik-gate-sync independently of other steps' (#1274) from fix/authentik-gate-sync-build into master
ci/woodpecker/push/build-ci-images Pipeline failed
Reviewed-on: k.renner/homelab#1274
2026-10-14 19:06:05 +00:00
claude-botandClaude Opus 5.5 0bb356c955 fix(ci): build authentik-gate-sync independently of other steps
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
build-authentik-gate-sync depended on a separate test step, so it only
started after the other steps had run, and Woodpecker skips not-yet-started
steps once the workflow has a failure: on the #1272 merge build-alpine-jq and
build-cni-plugins failed and the image was never built.

The unit tests now run as a stage of the image build (the final stage copies
the controller out of it), and the step starts with depends_on: [] like the
others.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:05:09 +02:00
k.renner b2d7b4ba0f Merge pull request 'feat(authentik): authentik-gate ingress class controller' (#1272) from feat/authentik-gate-sync into master
ci/woodpecker/cron/alert-monitor Pipeline failed
ci/woodpecker/push/build-ci-images Pipeline failed
Reviewed-on: k.renner/homelab#1272
2026-10-14 19:00:15 +00:00
claude-botandClaude Opus 5.5 38cd002385 fix(authentik-gate-sync): address review — lookups, ownership, fail-closed access
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
- Proxy providers are filtered by name__iexact (plain name= is ignored and
  returned the first provider alphabetically).
- Applications are retrieved by slug, never listed: the list endpoint is
  policy-filtered for the token's user and cached per user regardless of
  filters. Cleanup finds owned apps through the proxy provider list and
  confirms meta_publisher + provider one by one.
- Group gates bind an expression policy (ak_is_group_member by name, the name
  as a repr() literal) instead of the group: deleting a group cascades its
  bindings away and would open the app, the policy keeps denying. New bindings
  are created before stale ones are removed.
- Per-gate errors no longer abort the pass; a gate whose Ingress is invalid
  loses its route immediately. Network errors become ApiError.
- Gate names carry a hash of namespace/name: unambiguous and bounded to fit
  label values and ak-outpost-<name>.
- Teardown removes only an outpost serving exactly the gate's provider; a
  provider orphaned by an interrupted teardown is adopted on recreation.
- Role gains the expression-policy permissions; tests cover the Authentik
  reconcile against an in-memory API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:51:15 +02:00
security-bot 41207cdd48 chore(image-automation): bump ghcr.io/usetrmnl/terminus:0.78.0
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-10-14 18:43:44 +00:00
claude-botandClaude Opus 5.5 d1ed8685a9 fix(authentik-gate-sync): start on :latest until image automation pins a tag
Branch pushes do not build in-house images, so no dated tag exists before merge; the master build pushes :latest alongside it and the setter marker replaces it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:40:24 +02:00
claude-botandClaude Opus 5.5 bbb1582bcf feat(authentik): authentik-gate ingress class controller
Adds authentik-gate-sync, an in-house controller (ci/images/authentik-gate-sync,
stdlib Python like netbird-service-sync) that implements an `authentik-gate`
IngressClass. An Ingress of that class names its real backend; the controller
turns it into an Authentik proxy gate and wires the upstream ingress provider:

- Authentik: proxy provider, application (meta_publisher marks ownership),
  outpost on the local Kubernetes connection, optional group binding.
- App namespace: ExternalName Service <ingress>-gate -> outpost, Traefik
  Ingress <ingress>-gate (source hosts/paths/TLS/labels/annotations, backend
  swapped, cert-manager annotations dropped so only the source issues), and a
  CNP admitting only this outpost to the backend pods.
- authentik namespace: CNP letting the outpost reach only those pods.

Annotations: auth.renner.uno/group (members managed in Authentik; none = any
user), /login-flow, /authorization-flow (default passkey-stepup-authorization),
/skip-path. Fail closed: the Traefik route is applied last and removed when the
gate's config is invalid (missing group/flow/backend); transient API errors
leave the gate as the last good pass left it.

The API token is zero-touch: an ESO Password generator (CreatedOnce) feeds
both the controller and AUTHENTIK_GATE_SYNC_TOKEN on server/worker, which the
new gate-sync blueprint sets as the key of a non-expiring token for a
service account whose role covers only gate objects.

No existing gate moves yet; llama is migrated in a follow-up once this runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:39:50 +02:00
security-bot b5d624c0f2 chore(image-automation): bump ghcr.io/immich-app/immich-server:v3.3.3
ci/woodpecker/cron/alert-monitor Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
Updates managed by image-automation-controller.
2026-10-14 17:26:25 +00:00
security-bot 0efafb644d chore(image-automation): bump ghcr.io/immich-app/immich-machine-learning:v3.3.3
Updates managed by image-automation-controller.
2026-10-14 17:24:34 +00:00
security-bot 5c69a47a8d chore(image-automation): bump ghcr.io/paperless-ngx/paperless-ngx:3.3.1
ci/woodpecker/cron/wiki-maintenance Pipeline was successful
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-10-12 20:42:08 +00:00
security-bot fbd9b3ce35 chore(image-automation): bump gitea.renner.uno/gitea_admin/review-bot:2026-10-12-0927
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
Updates managed by image-automation-controller.
2026-10-12 09:50:37 +00:00
k.renner ed4fcd5ae4 Merge pull request 'fix(ci): tag each review-bot build with date and time' (#1271) from fix/review-bot-unique-tag into master
ci/woodpecker/push/build-ci-images Pipeline failed
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#1271
2026-10-12 09:27:37 +00:00
436 changed files with 16866 additions and 8902 deletions
+5 -5
View File
@@ -30,9 +30,9 @@
# Triggering:
# - cron : a cron NAMED exactly "alert-monitor" (name filter, same
# mechanism as update-audit.yml):
# woodpecker repo cron add --repo k.renner/homelab \
# woodpecker repo cron add --repo konairius/homelab \
# --name alert-monitor --branch master --schedule '*/15 * * * *'
# - deploy : woodpecker-cli pipeline deploy k.renner/homelab <n> alert-monitor
# - deploy : woodpecker-cli pipeline deploy konairius/homelab <n> alert-monitor
# where <n> is an existing pipeline to promote.
when:
- event: cron
@@ -101,7 +101,7 @@ steps:
bash scripts/ci/make-diag-kubeconfig.sh
- name: alert-triage-agent
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.296
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.303
environment:
CLAUDE_CODE_OAUTH_TOKEN:
from_secret: claude_code_oauth_token
@@ -130,7 +130,7 @@ steps:
| select([.receivers[]?.name] | (length == 0) or any(. != "null"))]' \
.ci-alert-monitor/alerts-raw.json > .ci-alert-monitor/alerts-active.json
echo "Active alerts (after heartbeat filter): $$(jq length .ci-alert-monitor/alerts-active.json)"
API="https://gitea.renner.uno/api/v1/repos/k.renner/homelab"
API="https://gitea.renner.uno/api/v1/repos/konairius/homelab"
curl -sfL "$$API/issues?state=open&type=issues&limit=100" > .ci-alert-monitor/open-issues.json
TITLES=$$(jq -c '[.[].title]' .ci-alert-monitor/open-issues.json)
NOISE_BODY=$$(jq -r 'map(select(.title == "Alert monitor: noise log")) | .[0].body // ""' \
@@ -238,7 +238,7 @@ steps:
exit 0
fi
jq . /tmp/alert-verdicts.json > /dev/null # fail loudly on malformed output
API="https://gitea.renner.uno/api/v1/repos/k.renner/homelab"
API="https://gitea.renner.uno/api/v1/repos/konairius/homelab"
STAMP=$$(date -u +%Y-%m-%d)
gitea_write() {
-66
View File
@@ -1,66 +0,0 @@
# Paperclip agent runtime images (REN-284 / REN-286): the stock
# agent-runtime-claude plus a tool set, one image per sandbox environment.
# Built with kaniko like build-ci-images.yml and pushed with the same
# ci_images_token (package read+write only, see the header there).
#
# Own workflow, not a step in build-ci-images.yml. A change to that file
# rebuilds all of its images at once and fills the 24-CPU woodpecker-ci-quota;
# on the REN-286 merge (pipeline 11306) this step then got no pod
# ("exceeded quota") and the go image was never built. Here it runs only when
# its own sources change, and it can be re-run alone with a deployment to
# target `build-agent-runtime`, without rebuilding the other images.
#
# The name looks odd on purpose. The kubernetes sandbox plugin keeps the image
# name and tag `agent-runtime-claude:v1` and swaps only the registry prefix
# (`imageRegistry`), replacing host + org. So each variant needs its own
# prefix with the same name under it:
# imageRegistry gitea.renner.uno/konairius/agent-runtime-go
# -> gitea.renner.uno/konairius/agent-runtime-go/agent-runtime-claude:v1
# `v1` is what the plugin pulls and moves on every build; the date tag is the
# rollback point. konairius/ because the ci_images_token can push there and
# it is the target of the gitea_admin/* migration (#330).
when:
- event: push
branch: master
path:
- "ci/images/agent-runtime-*/**"
- ".woodpecker/build-agent-runtime.yml"
- event: deployment
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "build-agent-runtime"'
steps:
- name: build-agent-runtime
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: ci/images
# Context is ci/images (not the variant dir) so both Containerfiles can
# COPY the shared agent-runtime-common/ files.
# One pod, both images one after the other (kaniko --cleanup between
# builds), at 1 CPU so it still fits if it overlaps another pipeline.
backend_options:
kubernetes:
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: "1"
memory: 4Gi
environment:
GITEA_USER: gitea_admin
GITEA_TOKEN:
from_secret: ci_images_token
commands:
- mkdir -p /kaniko/.docker
- AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n')
- printf '{"auths":{"gitea.renner.uno":{"auth":"%s"}}}' "$AUTH" > /kaniko/.docker/config.json
- TAG=$(date +%Y-%m-%d)
- |
for VARIANT in go ops; do
/kaniko/executor \
--cleanup \
--context . \
--dockerfile "agent-runtime-$VARIANT/Containerfile" \
--destination "gitea.renner.uno/konairius/agent-runtime-$VARIANT/agent-runtime-claude:$TAG" \
--destination "gitea.renner.uno/konairius/agent-runtime-$VARIANT/agent-runtime-claude:v1" \
|| exit 1
done
+55 -13
View File
@@ -31,6 +31,9 @@
# that touches this file started all steps at once: it pushed unreviewed
# images and used the whole woodpecker-ci-quota, so the PR checks could not
# get a pod (REN-286).
#
# Steps run in sequence (no depends_on): a change to this file rebuilds every
# image, and in parallel they exceed the woodpecker-ci-quota.
when:
- event: push
branch: master
@@ -46,7 +49,6 @@ steps:
- name: build-ansible
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: ci/images/ansible
depends_on: []
# Only rebuild when THIS image's source (or the pipeline file) changed, or
# on a manual run. Without this every step ran on any matching push, so a
# secret-rotator commit needlessly rebuilt all four ~1.3G ci-* images and
@@ -74,7 +76,6 @@ steps:
- name: build-k8s
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: ci/images/k8s
depends_on: []
when:
- event: deployment
- event: push
@@ -98,7 +99,6 @@ steps:
- name: build-vmtest
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: ci/images/vmtest
depends_on: []
when:
- event: deployment
- event: push
@@ -122,7 +122,6 @@ steps:
- name: build-tofu
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: ci/images/tofu
depends_on: []
when:
- event: deployment
- event: push
@@ -153,7 +152,6 @@ steps:
- name: build-secret-rotator
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: secret-rotator
depends_on: []
when:
- event: deployment
- event: push
@@ -179,7 +177,6 @@ steps:
- name: build-review-bot
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: review-bot
depends_on: []
when:
- event: deployment
- event: push
@@ -230,7 +227,6 @@ steps:
- name: build-alpine-jq
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: ci/images/alpine-jq
depends_on: []
when:
- event: deployment
- event: push
@@ -283,7 +279,6 @@ steps:
- name: build-cni-plugins
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: ci/images/cni-plugins
depends_on: []
when:
- event: deployment
- event: push
@@ -325,7 +320,6 @@ steps:
- name: build-alpine-openssl
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: ci/images/alpine-openssl
depends_on: []
when:
- event: deployment
- event: push
@@ -362,7 +356,6 @@ steps:
- name: build-heating-can
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: ci/images/heating-can
depends_on: []
when:
- event: deployment
- event: push
@@ -389,7 +382,6 @@ steps:
- name: build-netbird-service-sync
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: ci/images/netbird-service-sync
depends_on: []
when:
- event: deployment
- event: push
@@ -410,12 +402,63 @@ steps:
--destination gitea.renner.uno/gitea_admin/netbird-service-sync:$TAG \
--destination gitea.renner.uno/gitea_admin/netbird-service-sync:latest
# authentik-gate-sync: the authentik-gate ingress class (Ingress -> Authentik
# proxy gate + Traefik route). Its unit tests run as a stage of the image build.
- name: build-authentik-gate-sync
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: ci/images/authentik-gate-sync
when:
- event: deployment
- event: push
path: ["ci/images/authentik-gate-sync/**", ".woodpecker/build-ci-images.yml"]
environment:
GITEA_USER: gitea_admin
GITEA_TOKEN:
from_secret: ci_images_token
commands:
- mkdir -p /kaniko/.docker
- AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n')
- printf '{"auths":{"gitea.renner.uno":{"auth":"%s"}}}' "$AUTH" > /kaniko/.docker/config.json
# YYYY-MM-DD-HHMM: the image-automation ImagePolicy picks the newest.
- TAG=$(date +%Y-%m-%d-%H%M)
- |
/kaniko/executor \
--context . \
--dockerfile Containerfile \
--destination gitea.renner.uno/gitea_admin/authentik-gate-sync:$TAG \
--destination gitea.renner.uno/gitea_admin/authentik-gate-sync:latest
# kuma-sync: reconciles KumaMonitor resources into Uptime Kuma. Its unit
# tests run as a stage of the image build.
- name: build-kuma-sync
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: ci/images/kuma-sync
when:
- event: deployment
- event: push
path: ["ci/images/kuma-sync/**", ".woodpecker/build-ci-images.yml"]
environment:
GITEA_USER: gitea_admin
GITEA_TOKEN:
from_secret: ci_images_token
commands:
- mkdir -p /kaniko/.docker
- AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n')
- printf '{"auths":{"gitea.renner.uno":{"auth":"%s"}}}' "$AUTH" > /kaniko/.docker/config.json
# YYYY-MM-DD-HHMM: the image-automation ImagePolicy picks the newest.
- TAG=$(date +%Y-%m-%d-%H%M)
- |
/kaniko/executor \
--context . \
--dockerfile Containerfile \
--destination gitea.renner.uno/gitea_admin/kuma-sync:$TAG \
--destination gitea.renner.uno/gitea_admin/kuma-sync:latest
# bird: BGP daemon for the pod-network mesh over pod0 (#576). Runtime
# artifact like heating-can; same registry so the audit tracks it.
- name: build-bird
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: ci/images/bird
depends_on: []
when:
- event: deployment
- event: push
@@ -440,7 +483,6 @@ steps:
- name: build-backup-verify
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: ci/images/backup-verify
depends_on: []
when:
- event: deployment
- event: push
-69
View File
@@ -1,69 +0,0 @@
# Paperclip server image (REN-401): upstream paperclipai/paperclip at a pinned
# master commit plus the patches in ci/images/paperclip/. Needed while the k8s
# sandbox fix (upstream PR #13733) is not merged; see the README there for how
# to go back to upstream nightly.
#
# Own workflow for the reason given in build-agent-runtime.yml: a step in
# build-ci-images.yml runs on every change to that file and competes for the
# woodpecker-ci-quota. Re-run alone with a deployment to target
# `build-paperclip`.
#
# Tag `build.<unix-ts>` is what the Flux ImagePolicy `paperclip` sorts on
# (image-automation/paperclip.yaml); image automation then rolls it out.
# konairius/ and ci_images_token as for the agent-runtime images.
when:
- event: push
branch: master
path:
- "ci/images/paperclip/**"
- ".woodpecker/build-paperclip.yml"
- event: deployment
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "build-paperclip"'
steps:
- name: prepare-source
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-09-08
commands:
- sh ci/images/paperclip/prepare.sh build/paperclip-src
- name: build-paperclip
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: build/paperclip-src
depends_on: [prepare-source]
# Rust runner + UI + server build in one pod. The server build alone sets
# a 4 GiB Node heap, so the memory limit is the LimitRange max (8Gi).
backend_options:
kubernetes:
resources:
requests:
cpu: "1"
memory: 2Gi
limits:
cpu: "4"
memory: 8Gi
environment:
GITEA_USER: gitea_admin
GITEA_TOKEN:
from_secret: ci_images_token
commands:
- . ../../ci/images/paperclip/upstream.env
- mkdir -p /kaniko/.docker
- AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n')
- printf '{"auths":{"gitea.renner.uno":{"auth":"%s"}}}' "$AUTH" > /kaniko/.docker/config.json
- TAG=build.$(date +%s)
# Same target and build args as upstream's docker.yml for nightly.
# --compressed-caching=false: pipeline 11717 was killed at the 8Gi limit
# (exit 137) in the server build; kaniko otherwise holds compressed
# layer tarballs in memory next to the 4 GiB Node heap.
- |
/kaniko/executor \
--context . \
--dockerfile Dockerfile.kaniko \
--target production \
--skip-unused-stages \
--snapshot-mode=redo \
--compressed-caching=false \
--build-arg PAPERCLIP_BUILD_COMMIT=$PAPERCLIP_COMMIT \
--build-arg PAPERCLIP_BUILD_VERSION=$PAPERCLIP_VERSION \
--build-arg CLI_TOOLS_CACHE_EPOCH=$(date -u +%G-W%V) \
--destination gitea.renner.uno/konairius/paperclip:$TAG
+5 -5
View File
@@ -48,13 +48,13 @@
# OWNER SETUP — the token + secret above are already provisioned. The one
# remaining step, to be run AFTER this merges to master (a cron references a
# pipeline that must exist on the target branch), is to register the schedule:
# woodpecker repo cron add --repo k.renner/homelab \
# woodpecker repo cron add --repo konairius/homelab \
# --name ci-claude-refresh --branch master --schedule '@weekly'
# Weekly is plenty; the pipelines tolerate being a few days behind a release.
#
# Triggering:
# - cron : the cron NAMED exactly "ci-claude-refresh" (see above).
# - deploy : woodpecker-cli pipeline deploy k.renner/homelab <n> ci-claude-refresh
# - deploy : woodpecker-cli pipeline deploy konairius/homelab <n> ci-claude-refresh
# where <n> is an existing pipeline to promote.
# - push : a change to the Containerfile (or this file) ON master forces a
# rebuild of the current latest version so Containerfile fixes ship
@@ -174,9 +174,9 @@ steps:
# Both were validated non-empty by the decide step; re-assert before a
# sed that would corrupt every pin if either were blank.
[ -n "$$NEW" ] && [ -n "$$CUR" ] || { echo "empty version/current sentinel — aborting"; exit 1; }
API="https://gitea.renner.uno/api/v1/repos/k.renner/homelab"
API="https://gitea.renner.uno/api/v1/repos/konairius/homelab"
BRANCH="ci-image/claude-$$NEW"
if git ls-remote --heads "https://gitea.renner.uno/k.renner/homelab.git" "$$BRANCH" | grep -q .; then
if git ls-remote --heads "https://gitea.renner.uno/konairius/homelab.git" "$$BRANCH" | grep -q .; then
echo "Branch $$BRANCH already exists — a PR for $$NEW is already open. Nothing to do."
exit 0
fi
@@ -197,7 +197,7 @@ steps:
git checkout -b "$$BRANCH"
git add $$CONSUMERS
git commit -m "chore(ci): bump ci-claude image to $$NEW"
git push "https://$$PUSH_TOKEN@gitea.renner.uno/k.renner/homelab.git" HEAD:"$$BRANCH"
git push "https://$$PUSH_TOKEN@gitea.renner.uno/konairius/homelab.git" HEAD:"$$BRANCH"
{
printf 'Automated bump of the ci-claude agent image from `%s` to `%s`.\n\n' "$$CUR" "$$NEW"
printf 'The refresh pipeline built and pushed `ci-claude:%s` (and `:latest`) from the\n' "$$NEW"
+4 -4
View File
@@ -35,25 +35,25 @@ when:
steps:
- name: model-selection-tests
depends_on: []
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.296
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.303
commands:
- sh scripts/ci/test-model-selection.sh
- name: pr-review-mistral-tests
depends_on: []
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.296
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.303
commands:
- sh scripts/ci/test-pr-review-mistral.sh
- name: promtool-rules-guard-tests
depends_on: []
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.296
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.303
commands:
- sh scripts/ci/test-promtool-rules.sh
- name: yaml-citations-guard-tests
depends_on: []
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.296
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.303
commands:
- sh scripts/ci/test-yaml-citations.sh
+47
View File
@@ -0,0 +1,47 @@
when:
# Deploy only on master (merge) + manual — never auto-deploy from a branch.
- event: push
branch: master
path:
- "ansible/roles/uptime-kuma/**"
- "ansible/plays/install-uptime-kuma.yml"
- ".woodpecker/deploy-uptime-kuma.yml"
- event: deployment
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "deploy-uptime-kuma"'
- event: cron
cron: uptime-kuma-token-renew
timeout: 30
steps:
- name: renew-openbao-token
image: gitea.renner.uno/gitea_admin/ci-ansible:2026-10-18
environment:
VAULT_TOKEN:
from_secret: openbao_uptime_kuma_token
commands:
- 'curl -sSf -X POST -H "X-Vault-Token: $VAULT_TOKEN" https://openbao.renner.uno/v1/auth/token/renew-self -o /dev/null'
- name: deploy-uptime-kuma
image: gitea.renner.uno/gitea_admin/ci-ansible:2026-10-18
when:
- event: [push, deployment]
environment:
ANSIBLE_FORCE_COLOR: "true"
ANSIBLE_HOST_KEY_CHECKING: "false"
ANSIBLE_ROLES_PATH: "../roles:./roles"
SSH_PRIVATE_KEY:
from_secret: ssh_private_key
VAULT_PASS:
from_secret: vault_pass
VAULT_TOKEN:
from_secret: openbao_uptime_kuma_token
commands:
- mkdir -p ~/.ssh
- echo "$SSH_PRIVATE_KEY" | base64 -d > ~/.ssh/id_ed25519
- chmod 600 ~/.ssh/id_ed25519
- printf "Host *\n\tStrictHostKeyChecking no\n\tUserKnownHostsFile /dev/null\n" > ~/.ssh/config
- cd ansible
- echo "$VAULT_PASS" > .vault_pass && chmod 600 .vault_pass
- export ANSIBLE_VAULT_PASSWORD_FILE=.vault_pass
- ansible-playbook -i inventory/ plays/install-uptime-kuma.yml -v
+2 -2
View File
@@ -7,7 +7,7 @@
# was harmless at daily/weekly cadence — but alert-monitor.yml
# fires every 15 minutes, so piggybacking must be opt-in now.
# A new cron that SHOULD also run e2e gets its name added here.
# - deploy : `woodpecker-cli pipeline deploy k.renner/homelab <n> e2e`, where
# - deploy : `woodpecker-cli pipeline deploy konairius/homelab <n> e2e`, where
# <n> is an existing pipeline to promote.
when:
@@ -26,7 +26,7 @@ steps:
# fetch-models.sh response mapping. No cluster, no secrets, no network — the
# ci-claude image just provides sh + jq. Keeps the flag-injection regression
# test from rotting (it exercises the exact injection shapes).
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.296
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.303
commands:
- sh scripts/ci/test-model-selection.sh
+29
View File
@@ -0,0 +1,29 @@
# Builds ci/images/hermes (nousresearch/hermes-agent plus the agent's CLIs) as
# gitea.renner.uno/konairius/hermes:build.<unix-ts> and :latest.
when:
- event: push
branch: master
path:
- "ci/images/hermes/**"
- ".woodpecker/hermes-image.yml"
- event: manual
steps:
- name: build
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: ci/images/hermes
environment:
# registry-bot write:package PAT (konairius org secret).
GITEA_TOKEN:
from_secret: registry_bot_token
commands:
- mkdir -p /kaniko/.docker
- AUTH=$(printf '%s:%s' registry-bot "$GITEA_TOKEN" | base64 | tr -d '\n')
- printf '{"auths":{"gitea.renner.uno":{"auth":"%s"}}}' "$AUTH" > /kaniko/.docker/config.json
- TAG="build.$(date +%s)"
- |
/kaniko/executor \
--context . \
--dockerfile Containerfile \
--destination gitea.renner.uno/konairius/hermes:$TAG \
--destination gitea.renner.uno/konairius/hermes:latest
+7 -7
View File
@@ -32,9 +32,9 @@
#
# Triggering:
# - cron : a cron NAMED exactly "issue-fix":
# woodpecker repo cron add --repo k.renner/homelab \
# woodpecker repo cron add --repo konairius/homelab \
# --name issue-fix --branch master --schedule '41 * * * *'
# - deploy : woodpecker-cli pipeline deploy k.renner/homelab <n> issue-fix
# - deploy : woodpecker-cli pipeline deploy konairius/homelab <n> issue-fix
# where <n> is an existing pipeline to promote.
when:
- event: cron
@@ -62,7 +62,7 @@ steps:
- bash scripts/ci/make-diag-kubeconfig.sh
- name: issue-fix-agent
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.296
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.303
environment:
CLAUDE_CODE_OAUTH_TOKEN:
from_secret: claude_code_oauth_token
@@ -82,7 +82,7 @@ steps:
set -eu
TRUSTED_AUTHORS="k.renner" # space-separated; the trust anchor (see header)
mkdir -p /tmp/alert-fix
API="https://gitea.renner.uno/api/v1/repos/k.renner/homelab"
API="https://gitea.renner.uno/api/v1/repos/konairius/homelab"
curl -sfL "$$API/issues?state=open&type=issues&limit=100" \
| jq '[.[] | select(.title | startswith("[alert] "))] | sort_by(.number)' \
> /tmp/alert-fix/candidates.json
@@ -163,7 +163,7 @@ steps:
# run skips this issue, and stop cleanly.
[ -f /tmp/alert-fix/issue.json ] || exit 0
set -eu
API="https://gitea.renner.uno/api/v1/repos/k.renner/homelab"
API="https://gitea.renner.uno/api/v1/repos/konairius/homelab"
NUM=$$(jq -r '.number' /tmp/alert-fix/issue.json)
if git diff --quiet && git diff --cached --quiet \
&& [ -z "$$(git ls-files --others --exclude-standard)" ]; then
@@ -187,7 +187,7 @@ steps:
[ -f /tmp/alert-fix/issue.json ] || exit 0
if [ -f /tmp/alert-fix/no-edits ]; then exit 0; fi
set -eu
API="https://gitea.renner.uno/api/v1/repos/k.renner/homelab"
API="https://gitea.renner.uno/api/v1/repos/konairius/homelab"
NUM=$$(jq -r '.number' /tmp/alert-fix/issue.json)
BRANCH=$$(cat /tmp/alert-fix/branch)
TITLE="fix: $$(jq -r '.title' /tmp/alert-fix/issue.json) (#$$NUM)"
@@ -196,7 +196,7 @@ steps:
printf '\n\nCloses #%s\n' "$$NUM" >> /tmp/alert-fix/summary.md
git add -A
git commit -m "$$TITLE"
git push "https://$$PUSH_TOKEN@gitea.renner.uno/k.renner/homelab.git" HEAD:"$$BRANCH"
git push "https://$$PUSH_TOKEN@gitea.renner.uno/konairius/homelab.git" HEAD:"$$BRANCH"
jq -n --arg title "$$TITLE" --rawfile body /tmp/alert-fix/summary.md \
--arg head "$$BRANCH" \
'{title: $$title, body: $$body, head: $$head, base: "master"}' > /tmp/gitea-pr.json
+3 -3
View File
@@ -2,9 +2,9 @@
# pull requests. This pipeline only starts when triggered by hand and is kept for reference.
#
# PR review bot — per-repo STUB. All logic lives centrally in
# k.renner/homelab: ci/agents/pr-review/run.sh (plus prompt.md,
# konairius/homelab: ci/agents/pr-review/run.sh (plus prompt.md,
# settings.json and scripts/ci/). This file is deployed byte-identical into every
# reviewed repo (k.renner/homelab, shelly-manager, authentik-samba-bridge,
# reviewed repo (konairius/homelab, shelly-manager, authentik-samba-bridge,
# homeassistant) and holds NO logic, so the copies cannot drift apart: change the
# review in homelab and every repo picks it up on its next PR event. Only edit this
# stub if the trigger, the image or the secrets change — then change all copies in
@@ -43,5 +43,5 @@ steps:
from_secret: review_bot_token
commands:
- rm -rf /tmp/pr-review-central
- git clone --quiet --depth 1 https://gitea.renner.uno/k.renner/homelab /tmp/pr-review-central
- git clone --quiet --depth 1 https://gitea.renner.uno/konairius/homelab /tmp/pr-review-central
- sh /tmp/pr-review-central/ci/agents/pr-review/run.sh
+6 -6
View File
@@ -5,7 +5,7 @@
# - cron : the named `daily-security-scan` cron, matched by NAME.
# Crons cannot carry variables — `woodpecker-cli repo cron add`
# has no --var — so a variable gate can never match a cron.
# - deployment : woodpecker-cli pipeline deploy k.renner/homelab <n> security-scan
# - deployment : woodpecker-cli pipeline deploy konairius/homelab <n> security-scan
# The target rides in pipeline metadata as
# CI_PIPELINE_DEPLOY_TARGET, which the workflow-root filter
# does see, unlike manual-run variables on Woodpecker 3.18
@@ -18,12 +18,12 @@ when:
steps:
- name: iac-scan
image: aquasec/trivy:latest@sha256:af6acf9a6b85dfe389a1941505c0ce9efef52a4719635e1a962f022a3d855daa
image: aquasec/trivy:latest@sha256:2416d1f74045b57274df1a340eee6b510cacd73c66a6ccb8da092b117eb7d222
commands:
- trivy fs --format json -o trivy-iac.json --severity CRITICAL,HIGH --ignore-unfixed .
- name: image-scans
image: aquasec/trivy:latest@sha256:af6acf9a6b85dfe389a1941505c0ce9efef52a4719635e1a962f022a3d855daa
image: aquasec/trivy:latest@sha256:2416d1f74045b57274df1a340eee6b510cacd73c66a6ccb8da092b117eb7d222
commands:
- trivy image --download-db-only
- |
@@ -70,7 +70,7 @@ steps:
# this step is skipped — otherwise we'd fork-bomb PRs.
when:
- branch: master
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.296
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.303
environment:
CLAUDE_CODE_OAUTH_TOKEN:
from_secret: claude_code_oauth_token
@@ -147,7 +147,7 @@ steps:
TITLE="security: agent-proposed fixes ($$(date +%Y-%m-%d))"
git add -A
git commit -m "$$TITLE"
git push "https://$$GITEA_TOKEN@gitea.renner.uno/k.renner/homelab.git" HEAD:"$$BRANCH"
git push "https://$$GITEA_TOKEN@gitea.renner.uno/konairius/homelab.git" HEAD:"$$BRANCH"
jq -n \
--arg title "$$TITLE" \
--arg body "$$BODY" \
@@ -157,5 +157,5 @@ steps:
-H "Authorization: token $$GITEA_TOKEN" \
-H "Content-Type: application/json" \
--data @- \
https://gitea.renner.uno/api/v1/repos/k.renner/homelab/pulls \
https://gitea.renner.uno/api/v1/repos/konairius/homelab/pulls \
| jq '{number, html_url, title}'
+4 -4
View File
@@ -14,9 +14,9 @@
# `repo cron add` takes name, branch, schedule and enabled — so the
# name filter is the only thing that can select a workflow for a
# scheduled run:
# woodpecker repo cron add --repo k.renner/homelab \
# woodpecker repo cron add --repo konairius/homelab \
# --name update-audit --branch master --schedule '@weekly'
# - deploy : woodpecker-cli pipeline deploy k.renner/homelab <n> update-audit
# - deploy : woodpecker-cli pipeline deploy konairius/homelab <n> update-audit
# where <n> is an existing pipeline to promote.
#
# NOTE: e2e.yml runs on an explicit list of cron names that includes this one,
@@ -30,7 +30,7 @@ when:
steps:
- name: update-audit-agent
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.296
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.303
environment:
CLAUDE_CODE_OAUTH_TOKEN:
from_secret: claude_code_oauth_token
@@ -111,7 +111,7 @@ steps:
set -eu
TITLE="Infrastructure update audit"
STAMP=$$(date -u +%Y-%m-%dT%H:%M:%SZ)
API="https://gitea.renner.uno/api/v1/repos/k.renner/homelab"
API="https://gitea.renner.uno/api/v1/repos/konairius/homelab"
# write <method> <url> <payload-file> -> prints HTTP code, fails non-2xx
gitea_write() {
-198
View File
@@ -1,198 +0,0 @@
# Nightly wiki maintenance for the Notes vault (k.renner/notes), replacing the
# workstation's wiki-maintenance and wiki-consolidate timers: distill the raw/
# backlog into wiki/ (wiki-distill agent), audit wiki/ with the vault's own
# script (structural checks, no model), then consolidate (wiki-consolidate
# agent: verifies a cohort of notes against sources, repos and the live
# cluster and edits them). The pipeline commits and pushes as Claude Bot; the
# push webhook then refreshes the wiki service.
#
# The agents address the vault as ~/Notes and the repos as ~/source/<repo>, so
# each agent step links those into the workspace instead of rewriting them.
when:
- event: cron
cron: wiki-maintenance
# Hand-started runs: `woodpecker pipeline create k.renner/homelab --branch master`.
# The typesafe_api_key secret is scoped to cron and manual, not deployment.
- event: manual
steps:
- name: checkout-notes
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.296
pull: true
environment:
WIKI_BOT_TOKEN:
from_secret: wiki_bot_token
commands:
- |
set -eu
# Token only on the wire, never in .git/config. Secrets set from files carry
# a trailing newline (run 10220: "could not read Username"); strip CR/LF.
TOKEN=$$(printf '%s' "$$WIKI_BOT_TOKEN" | tr -d '\r\n')
git -c http.extraheader="AUTHORIZATION: token $$TOKEN" \
clone --depth 50 --branch main https://gitea.renner.uno/k.renner/notes.git notes
echo "raw backlog: $$(ls notes/raw/*.md | grep -v _index.md | wc -l) file(s)"
# Full history for the consolidate agent's `git log` checks; the
# workspace clone is shallow and tree-filtered.
mkdir -p sources
git clone --quiet https://gitea.renner.uno/k.renner/homelab.git sources/homelab
git -c http.extraheader="AUTHORIZATION: token $$TOKEN" \
clone --quiet https://gitea.renner.uno/k.renner/homeassistant.git sources/homeassistant
- name: fetch-diag
# Read-only live-cluster access for the consolidate agent, same mechanism
# as issue-fix.yml: ./.ci-diag/kubectl plus a 1h ci-diagnostics token.
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-09-08
environment:
SOPS_AGE_KEY:
from_secret: sops_age_key
S3_ACCESS_KEY_ID:
from_secret: hetzner_s3_access_key
S3_SECRET_ACCESS_KEY:
from_secret: hetzner_s3_secret_key
commands:
- bash scripts/ci/fetch-kubeconfig.sh
- bash scripts/ci/make-diag-kubeconfig.sh
- name: distill
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.296
pull: true
environment:
CLAUDE_CODE_OAUTH_TOKEN:
from_secret: claude_code_oauth_token
TYPESAFE_API_KEY:
from_secret: typesafe_api_key
WIKI_INDEX_BASIC_AUTH:
from_secret: wiki_index_basic_auth
commands:
- |
set -eu
# An empty queue must not start the agent: its startup context alone
# costs more than the whole audit.
BACKLOG=$$(ls notes/raw/*.md | grep -v _index.md | wc -l)
if [ "$$BACKLOG" -eq 0 ]; then
echo "distill: raw/ is empty, skipping the agent"
exit 0
fi
ln -sfn "$$PWD/notes" "$$HOME/Notes"
mkdir -p "$$HOME/.config/wiki-index"
printf '%s' "$$WIKI_INDEX_BASIC_AUTH" | tr -d '\r\n' > "$$HOME/.config/wiki-index/basic-auth"
chmod 600 "$$HOME/.config/wiki-index/basic-auth"
"$$HOME/Notes/bin/wiki_rank.py" --probe-index
MODEL=$$(sh scripts/ci/pick-model.sh "distill raw source notes into atomic wiki notes: read sources, check the wiki for existing coverage, write or merge notes; careful writing, moderate reasoning")
if [ -n "$$MODEL" ]; then echo "distill: selected model $$MODEL"; else echo "distill: using the CLI default model"; fi
cd notes
cat ../ci/agents/wiki-maintenance/distill.md | claude $${MODEL:+--model $$MODEL} \
--agent wiki-distill \
--permission-mode acceptEdits \
--allowedTools "Read,Edit,Write,Glob,Grep,Bash(/root/Notes/bin/wiki_rank.py:*),Bash(/root/Notes/bin/wiki_ask.py:*),Bash(mv /root/Notes/raw/:*),Bash(mkdir -p /root/Notes/:*),Bash(~/Notes/bin/wiki_rank.py:*),Bash(~/Notes/bin/wiki_ask.py:*),Bash(mv ~/Notes/raw/:*),Bash(mkdir -p ~/Notes/:*)" \
--settings ../ci/agents/wiki-maintenance/settings.json \
-p
- name: audit
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.296
pull: true
commands:
# Findings are report lines in output/, not a pipeline failure.
- python3 -B notes/bin/wiki_audit.py --vault "$$PWD/notes"
- name: consolidate
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.296
pull: true
environment:
CLAUDE_CODE_OAUTH_TOKEN:
from_secret: claude_code_oauth_token
TYPESAFE_API_KEY:
from_secret: typesafe_api_key
WIKI_INDEX_BASIC_AUTH:
from_secret: wiki_index_basic_auth
commands:
- |
set -eu
ln -sfn "$$PWD/notes" "$$HOME/Notes"
ln -sfn "$$PWD/sources" "$$HOME/source"
mkdir -p "$$HOME/.config/wiki-index"
printf '%s' "$$WIKI_INDEX_BASIC_AUTH" | tr -d '\r\n' > "$$HOME/.config/wiki-index/basic-auth"
chmod 600 "$$HOME/.config/wiki-index/basic-auth"
if [ -f .ci-diag/kubeconfig ]; then
ln -sf "$$PWD/.ci-diag/kubectl" /usr/local/bin/kubectl
export KUBECONFIG="$$PWD/.ci-diag/kubeconfig"
else
echo "consolidate: no ci-diag kubeconfig, live cluster checks unavailable"
fi
cd notes
# JSON output: the agent's report and the run's cost come out of one
# record at the end instead of a 45-minute silent log.
# One source for the Bash allowlist: the settings file (run 10608: the
# agent writes ~/Notes, the rules carried only /root/Notes).
BASH_RULES=$$(jq -r '[.permissions.allow[] | select(startswith("Bash("))] | join(",")' ../ci/agents/wiki-maintenance/consolidate-settings.json)
rc=0
cat ../ci/agents/wiki-maintenance/consolidate.md | claude \
--agent wiki-consolidate \
--permission-mode acceptEdits \
--allowedTools "Read,Edit,Write,Glob,Grep,Agent,$$BASH_RULES" \
--settings ../ci/agents/wiki-maintenance/consolidate-settings.json \
--output-format json \
-p > /tmp/consolidate.json || rc=$$?
# A killed or crashed claude leaves no valid JSON; report rc, not a jq error.
if [ -s /tmp/consolidate.json ] && jq -e . /tmp/consolidate.json > /dev/null; then
jq -r '.result // empty' /tmp/consolidate.json
jq -c '{duration_ms, num_turns, total_cost_usd, is_error}' /tmp/consolidate.json
else
echo "consolidate: no result record (exit $$rc)"; head -c 2000 /tmp/consolidate.json || true
fi
echo "consolidate: git log --oneline of the run:"
git log --oneline origin/main..HEAD
exit $$rc
- name: push-notes
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.296
pull: true
environment:
WIKI_BOT_TOKEN:
from_secret: wiki_bot_token
commands:
- |
set -eu
cd notes
git config user.email "claude-bot@renner.uno"
git config user.name "Claude Bot"
# Only the vault content is committed; anything an agent leaves elsewhere
# in the checkout (run 10362: scratch scripts in the root) stays behind.
# The consolidate agent commits its own snapshots, so HEAD may already be
# ahead of origin/main with a clean tree.
git add -A -- wiki raw output .wiki-consolidation-state.json
git diff --cached --quiet || git commit -q -m "wiki maintenance $$(date -u +%F): distill + audit + consolidate (CI run $$CI_PIPELINE_NUMBER)"
if [ "$$(git rev-list --count origin/main..HEAD)" -eq 0 ]; then
echo "wiki-maintenance: no changes"
exit 0
fi
# The workstation pushes main on its own (notes-push.path), so main moves
# while the agents run (run 10241: non-fast-forward). The run's work goes
# to a branch and a PR that this step merges through Gitea; a conflict
# leaves the PR open for a human and fails the run.
BRANCH="wiki-maintenance/$$(date -u +%F)-$$CI_PIPELINE_NUMBER"
git checkout -q -b "$$BRANCH"
TOKEN=$$(printf '%s' "$$WIKI_BOT_TOKEN" | tr -d '\r\n')
git -c http.extraheader="AUTHORIZATION: token $$TOKEN" push origin "HEAD:$$BRANCH"
API="https://gitea.renner.uno/api/v1/repos/k.renner/notes"
jq -n --arg title "wiki maintenance $$(date -u +%F) (CI run $$CI_PIPELINE_NUMBER)" \
--arg body "Nightly distill + audit + consolidate. Merged by the pipeline; open only if the merge failed." \
--arg head "$$BRANCH" '{title: $$title, body: $$body, head: $$head, base: "main"}' > /tmp/pr.json
code=$$(curl -s -o /tmp/pr-resp.json -w '%{http_code}' -X POST \
-H "Authorization: token $$TOKEN" -H "Content-Type: application/json" --data @/tmp/pr.json "$$API/pulls")
echo "POST pull -> HTTP $$code"
[ "$$code" -ge 200 ] && [ "$$code" -lt 300 ] || { cat /tmp/pr-resp.json; exit 1; }
NUM=$$(jq -r '.number' /tmp/pr-resp.json)
# Merge without delete_branch_after_merge: run 10254 got HTTP 500 from that
# call although the merge itself had landed. Verify the PR state instead of
# trusting the status code, then drop the branch as a separate, best-effort step.
code=$$(curl -s -o /tmp/merge-resp.json -w '%{http_code}' -X POST \
-H "Authorization: token $$TOKEN" -H "Content-Type: application/json" \
--data '{"Do":"merge"}' "$$API/pulls/$$NUM/merge")
echo "POST merge #$$NUM -> HTTP $$code"
MERGED=$$(curl -s -H "Authorization: token $$TOKEN" "$$API/pulls/$$NUM" | jq -r '.merged')
echo "PR #$$NUM merged: $$MERGED"
[ "$$MERGED" = "true" ] || { cat /tmp/merge-resp.json; echo "PR #$$NUM left open"; exit 1; }
code=$$(curl -s -o /tmp/del-resp.json -w '%{http_code}' -X DELETE -H "Authorization: token $$TOKEN" \
"$$API/branches/$$(printf '%s' "$$BRANCH" | sed 's|/|%2F|g')")
echo "DELETE branch $$BRANCH -> HTTP $$code (best effort)"
-22
View File
@@ -47,25 +47,3 @@ approves.
This is in addition to the normal review and merge by Argus. Argus does
not merge such a PR without the HA-impact approval.
## Agent runtime images
Pod images for the Paperclip Kubernetes sandbox (REN-284). Each is the stock
`ghcr.io/paperclipai/agent-runtime-claude` (pinned by digest) plus a tool
set. Base user (1000, non-root), entrypoint and cmd are unchanged; the
images run with a read-only root FS. Source in `ci/images/agent-runtime-*`,
built by `.woodpecker/build-agent-runtime.yml` (re-run alone: deployment to
target `build-agent-runtime`), versions bumped by Renovate. Both also
carry `claude-agent-acp` for the ACP engine, pinned and patched like
Paperclip ships it, SDK override included
(`ci/images/agent-runtime-common/`); bump it by hand
together with Paperclip. All tools are on the default `PATH`. Both add
`tzdata` (the base has no zoneinfo); the default zone stays UTC.
| Image | `imageRegistry` | Adds |
|---|---|---|
| `gitea.renner.uno/konairius/agent-runtime-go/agent-runtime-claude:v1` | `gitea.renner.uno/konairius/agent-runtime-go` | Go toolchain, golangci-lint, govulncheck |
| `gitea.renner.uno/konairius/agent-runtime-ops/agent-runtime-claude:v1` | `gitea.renner.uno/konairius/agent-runtime-ops` | kubectl, flux, kustomize, helm |
The plugin keeps the name `agent-runtime-claude:v1` and swaps only host and
org, hence the nested path. Every build also pushes a date tag
(`:YYYY-MM-DD`) as the rollback point. `konairius` is public: no pull secret.
+17
View File
@@ -0,0 +1,17 @@
---
# Deploy Uptime Kuma to the NetBird VPS
# Usage: ansible-playbook -i inventory/ plays/install-uptime-kuma.yml
#
# 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
- name: Deploy Uptime Kuma
hosts: netbird_server
become: true
roles:
- uptime-kuma
@@ -0,0 +1,20 @@
---
uptime_kuma_version: "2.5.6"
uptime_kuma_host: kuma.renner.uno
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
@@ -0,0 +1,225 @@
// 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 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(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(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 bootstrap finished: ${reason}`));
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)
);
+228
View File
@@ -0,0 +1,228 @@
---
# Admin bootstrap runs with the Traefik route disabled so /setup is never
# reachable from outside; the route is enabled once the admin exists.
- name: Read admin 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_path }}"
auth_method: token
delegate_to: localhost
become: false
run_once: true
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 }}"
state: directory
mode: "0755"
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 }}/setup/kuma-setup.js"
mode: "0644"
- name: Check for setup marker
ansible.builtin.stat:
path: "{{ uptime_kuma_dir }}/data/.setup-done"
register: _uptime_kuma_marker
- name: Deploy docker-compose.yml
ansible.builtin.template:
src: docker-compose.yml.j2
dest: "{{ uptime_kuma_dir }}/docker-compose.yml"
mode: "0644"
vars:
uptime_kuma_route_enabled: "{{ _uptime_kuma_marker.stat.exists }}"
- name: Pull Uptime Kuma image
ansible.builtin.command: docker compose pull
args:
chdir: "{{ uptime_kuma_dir }}"
changed_when: false
- name: Start Uptime Kuma
ansible.builtin.command: docker compose up -d
args:
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
status_code: 200
register: _uptime_kuma_up
until: _uptime_kuma_up.status == 200
retries: 30
delay: 5
- 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 or 'changed:' in _uptime_kuma_setup.stdout"
failed_when: false
no_log: true
- name: Fail if Uptime Kuma setup failed
ansible.builtin.fail:
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: ""
dest: "{{ uptime_kuma_dir }}/data/.setup-done"
mode: "0644"
force: false
- name: Deploy docker-compose.yml with the route enabled
ansible.builtin.template:
src: docker-compose.yml.j2
dest: "{{ uptime_kuma_dir }}/docker-compose.yml"
mode: "0644"
vars:
uptime_kuma_route_enabled: true
- name: Start Uptime Kuma with the route enabled
ansible.builtin.command: docker compose up -d
args:
chdir: "{{ uptime_kuma_dir }}"
register: _uptime_kuma_route
changed_when: "'Recreate' in _uptime_kuma_route.stderr or 'Started' in _uptime_kuma_route.stderr"
@@ -0,0 +1,39 @@
services:
uptime-kuma:
image: louislam/uptime-kuma:{{ uptime_kuma_version }}-slim
container_name: uptime-kuma
restart: unless-stopped
environment:
- UPTIME_KUMA_DB_TYPE=sqlite
ports:
- "127.0.0.1:3001:3001"
volumes:
- {{ uptime_kuma_dir }}/data:/app/data
# 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 }}`)"
- "traefik.http.routers.uptime-kuma.entrypoints=websecure"
- "traefik.http.routers.uptime-kuma.tls=true"
- "traefik.http.routers.uptime-kuma.tls.certresolver=letsencrypt"
- "traefik.http.routers.uptime-kuma.service=uptime-kuma"
- "traefik.http.services.uptime-kuma.loadbalancer.server.port=3001"
- "traefik.docker.network={{ uptime_kuma_traefik_network }}"
networks:
kuma:
gw_priority: 100
traefik:
gw_priority: 0
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "2"
networks:
kuma:
enable_ipv6: true
traefik:
external: true
name: {{ uptime_kuma_traefik_network }}
+2 -2
View File
@@ -1,6 +1,6 @@
#!/bin/sh
# PR review bot — the ONE implementation, run by every reviewed repo's
# .woodpecker/pr-review.yml stub (k.renner/homelab, shelly-manager,
# .woodpecker/pr-review.yml stub (konairius/homelab, shelly-manager,
# authentik-samba-bridge, homeassistant). Edit it here only; the stubs hold no
# logic. Design notes (event-driven trigger, dedup marker, tokens, model
# selection, posture) are in the stub and in prompt.md.
@@ -129,7 +129,7 @@ while IFS= read -r ITEM; do
# # Cross-repo context for the review agent: shallow, UNAUTHENTICATED clones of
# # the other fleet repos at ./.pr-review/context/<name>.
# CTX="$D/context"; mkdir -p "$CTX"
# for REPO in k.renner/homelab k.renner/homeassistant konairius/shelly-manager k.renner/authentik-samba-bridge; do
# for REPO in konairius/homelab konairius/homeassistant konairius/shelly-manager konairius/authentik-samba-bridge; do
# [ "$REPO" = "$SLUG" ] && continue
# GIT_TERMINAL_PROMPT=0 git clone --quiet --depth 1 "https://gitea.renner.uno/$REPO" "$CTX/${REPO#*/}" 2>/dev/null \
# || echo "No public context clone of $REPO (skipped)."
@@ -1,120 +0,0 @@
{
"//": "Permission scope for the wiki-consolidate agent in CI: read the vault and the",
"//2": "source repos cloned under /root/source, read the live cluster through the",
"//3": "ci-diagnostics kubeconfig, edit wiki/, output/ and the rotation state only.",
"//4": "Deny rules win; git and kubectl are read-only subcommands, no network tools.",
"permissions": {
"allow": [
"Read(//root/Notes/**)",
"Read(//root/source/**)",
"Edit(//root/Notes/wiki/**)",
"Edit(//root/Notes/output/**)",
"Edit(//root/Notes/.wiki-consolidation-state.json)",
"Bash(/root/Notes/bin/wiki-snapshot.sh:*)",
"Bash(/root/Notes/bin/wiki_rank.py:*)",
"Bash(/root/Notes/bin/wiki_ask.py:*)",
"Bash(~/Notes/bin/wiki-snapshot.sh:*)",
"Bash(~/Notes/bin/wiki_rank.py:*)",
"Bash(~/Notes/bin/wiki_ask.py:*)",
"Bash(~/Notes/bin/wiki-delete.sh:*)",
"Bash(git -C /root/Notes log:*)",
"Bash(git -C /root/Notes show:*)",
"Bash(git -C /root/Notes diff:*)",
"Bash(git -C /root/Notes status:*)",
"Bash(git -C /root/Notes rev-parse:*)",
"Bash(git -C /root/Notes ls-files:*)",
"Bash(git -C ~/Notes log:*)",
"Bash(git -C ~/Notes show:*)",
"Bash(git -C ~/Notes diff:*)",
"Bash(git -C ~/Notes status:*)",
"Bash(git -C ~/Notes rev-parse:*)",
"Bash(git -C ~/Notes ls-files:*)",
"Bash(kubectl get:*)",
"Bash(kubectl describe:*)",
"Bash(kubectl version:*)",
"Bash(kubectl api-resources:*)",
"Bash(kubectl explain:*)",
"Bash(git log:*)",
"Bash(git show:*)",
"Bash(git status:*)",
"Bash(git diff:*)",
"Bash(git rev-parse:*)",
"Bash(git ls-files:*)",
"Bash(git -C /root/source/homelab log:*)",
"Bash(git -C /root/source/homelab show:*)",
"Bash(git -C /root/source/homelab diff:*)",
"Bash(git -C /root/source/homelab status:*)",
"Bash(git -C /root/source/homelab rev-parse:*)",
"Bash(git -C /root/source/homelab ls-files:*)",
"Bash(git -C /root/source/homeassistant log:*)",
"Bash(git -C /root/source/homeassistant show:*)",
"Bash(git -C /root/source/homeassistant diff:*)",
"Bash(git -C /root/source/homeassistant status:*)",
"Bash(git -C /root/source/homeassistant rev-parse:*)",
"Bash(git -C /root/source/homeassistant ls-files:*)",
"Bash(git -C ~/source/homelab log:*)",
"Bash(git -C ~/source/homelab show:*)",
"Bash(git -C ~/source/homelab diff:*)",
"Bash(git -C ~/source/homelab status:*)",
"Bash(git -C ~/source/homelab rev-parse:*)",
"Bash(git -C ~/source/homelab ls-files:*)",
"Bash(git -C ~/source/homeassistant log:*)",
"Bash(git -C ~/source/homeassistant show:*)",
"Bash(git -C ~/source/homeassistant diff:*)",
"Bash(git -C ~/source/homeassistant status:*)",
"Bash(git -C ~/source/homeassistant rev-parse:*)",
"Bash(git -C ~/source/homeassistant ls-files:*)",
"Bash(/root/Notes/bin/wiki-delete.sh:*)",
"Bash(ls:*)",
"Bash(date:*)",
"Bash(wc:*)",
"Bash(stat:*)",
"Bash(head:*)",
"Bash(tail:*)"
],
"deny": [
"Read(//proc/**)",
"Read(//etc/**)",
"Read(//root/.config/**)",
"Read(//root/.kube/**)",
"Read(//woodpecker/**/.ci-diag/**)",
"Read(//root/Notes/.git/**)",
"Read(//root/source/*/.git/**)",
"Read(//root/Notes/_archive/**)",
"Edit(//root/Notes/raw/**)",
"Edit(//root/Notes/_archive/**)",
"Edit(//root/source/**)",
"Bash(kubectl apply:*)",
"Bash(kubectl delete:*)",
"Bash(kubectl patch:*)",
"Bash(kubectl edit:*)",
"Bash(kubectl scale:*)",
"Bash(kubectl rollout:*)",
"Bash(kubectl exec:*)",
"Bash(kubectl cp:*)",
"Bash(kubectl drain:*)",
"Bash(kubectl cordon:*)",
"Bash(kubectl label:*)",
"Bash(kubectl annotate:*)",
"Bash(git push:*)",
"Bash(git reset:*)",
"Bash(git checkout:*)",
"Bash(git clean:*)",
"Bash(git rebase:*)",
"Bash(git config:*)",
"Bash(git -C /root/Notes config:*)",
"Bash(git -C ~/Notes config:*)",
"Bash(git -C /root/source/homelab config:*)",
"Bash(git -C /root/source/homeassistant config:*)",
"Bash(git -C ~/source/homelab config:*)",
"Bash(git -C ~/source/homeassistant config:*)",
"Bash(rm:*)",
"Bash(rm -rf:*)",
"Bash(curl:*)",
"Bash(wget:*)",
"Bash(ssh:*)",
"Bash(tea:*)",
"Bash(woodpecker:*)"
]
}
}
@@ -1,5 +0,0 @@
You are running as the scheduled wiki-maintenance job in CI. The Notes vault is checked out at ~/Notes (the repository k.renner/notes); the homelab and homeassistant repositories are checked out under ~/source/. `kubectl` reads the live cluster with a read-only identity.
Run the wiki-consolidate agent now for today's scheduled consolidation pass. Follow its instructions exactly: snapshot the vault to git first, abort if that fails, select today's rotating cohort of 20 from the state file, verify claims against both their sources and the live systems, act on the verdicts, write the dated report to ~/Notes/output/, update the state file, and snapshot again at the end. Do not push; the pipeline pushes the run's commits.
Report the counts and the coverage progress when you are done.
-7
View File
@@ -1,7 +0,0 @@
You are running as the scheduled wiki-maintenance job in CI. The Notes vault is checked out at ~/Notes (the repository k.renner/notes). Do exactly this, nothing else:
Glob ~/Notes/raw/*.md (top level only, never ~/Notes/raw/_processed/). Distill every file you find into ~/Notes/wiki/ following your process, including the ranker check for every concept. Move each fully processed source to ~/Notes/raw/_processed/. If raw/ has no top-level files besides _index.md, say so and stop.
Do not commit or push; the pipeline does that after you finish. Do not touch anything outside ~/Notes/wiki/ and ~/Notes/raw/.
End with one line per file: which wiki notes it produced or merged into, or "nothing distillable".
-41
View File
@@ -1,41 +0,0 @@
{
"//": "Permission scope for the wiki-maintenance agent (wiki-distill) in CI.",
"//2": "The step's environment holds a Gitea push token, a TypeSafe key and the wiki-index",
"//3": "client credential, so the Read tool is kept away from /proc (env dumps), /root outside",
"//4": "the vault symlink target, /etc and the checkout's .git/. The vault itself is the work.",
"//5": "Mirrors ci/agents/issue-fix/settings.json; absolute globs because the vault is reached",
"//6": "through the ~/Notes symlink the pipeline creates.",
"permissions": {
"allow": [
"Read(//root/Notes/**)",
"Edit(//root/Notes/wiki/**)",
"Edit(//root/Notes/output/**)",
"Edit(//root/Notes/raw/**)",
"Bash(/root/Notes/bin/wiki_rank.py:*)",
"Bash(/root/Notes/bin/wiki_ask.py:*)",
"Bash(mv /root/Notes/raw/:*)",
"Bash(mkdir -p /root/Notes/:*)",
"Bash(~/Notes/bin/wiki_rank.py:*)",
"Bash(~/Notes/bin/wiki_ask.py:*)",
"Bash(mv ~/Notes/raw/:*)",
"Bash(mkdir -p ~/Notes/:*)",
"Bash(ls:*)",
"Bash(date:*)",
"Bash(wc:*)",
"Bash(head:*)",
"Bash(tail:*)"
],
"deny": [
"Read(//proc/**)",
"Read(//etc/**)",
"Read(//root/.config/**)",
"Read(//root/Notes/.git/**)",
"Read(//root/Notes/_archive/**)",
"Edit(//root/Notes/_archive/**)",
"Bash(rm:*)",
"Bash(git:*)",
"Bash(curl:*)",
"Bash(wget:*)"
]
}
}
@@ -1,66 +0,0 @@
diff --git a/dist/acp-agent.js b/dist/acp-agent.js
index 0214a509eb5e7b777a69e4e34637941c275d27b1..403be02df66e25eee29e7d5bef14627011e02b30 100644
--- a/dist/acp-agent.js
+++ b/dist/acp-agent.js
@@ -2922,9 +2922,19 @@ export class ClaudeAcpAgent {
amount: message.total_cost_usd,
currency: "USD",
},
- ...(message.origin && {
- _meta: { "_claude/origin": message.origin },
- }),
+ _meta: {
+ ...(message.origin && { "_claude/origin": message.origin }),
+ // ACP's core Usage shape exposes context occupancy and
+ // cost, but not the billable input/output split. Keep the
+ // SDK's bounded aggregate in extension metadata so ACPX can
+ // normalize it without persisting a raw provider message.
+ usage: {
+ input_tokens: message.usage.input_tokens,
+ output_tokens: message.usage.output_tokens,
+ cache_read_input_tokens: message.usage.cache_read_input_tokens,
+ cache_creation_input_tokens: message.usage.cache_creation_input_tokens,
+ },
+ },
},
});
}
@@ -5311,6 +5321,10 @@ export class ClaudeAcpAgent {
settingSources: ["user", "project", "local"],
...(thinking !== undefined && { thinking }),
...userProvidedOptions,
+ // Paperclip Runner owns the complete session context. Its isolated
+ // user root contains only assigned skills; project/local settings
+ // would reintroduce host prompts, plugins, skills, and MCP servers.
+ ...(process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT === "1" && { settingSources: ["user"] }),
...(settings && { settings }),
env,
// Override certain fields that must be controlled by ACP
@@ -5318,7 +5332,9 @@ export class ClaudeAcpAgent {
includePartialMessages: true,
forwardSubagentText,
mcpServers: {
- ...(userProvidedOptions?.mcpServers || {}),
+ ...(process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT === "1"
+ ? {}
+ : (userProvidedOptions?.mcpServers || {})),
...mcpServers,
...(fileChangeAuditSupport
? { [FILE_CHANGE_AUDIT_SERVER_NAME]: fileChangeAuditSupport.mcpServer }
@@ -5329,6 +5345,16 @@ export class ClaudeAcpAgent {
allowDangerouslySkipPermissions: ALLOW_BYPASS,
permissionMode: initialPermissionMode,
canUseTool: this.canUseTool(sessionId),
+ // The runner pins this actual MCP endpoint before provider launch.
+ // Exempt only task delivery/control tools at the SDK dispatch boundary,
+ // not arbitrary provider permission metadata or MCP name prefixes.
+ ...(process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT === "1" && {
+ allowedTools: mcpServers.paperclip?.type === "http"
+ && mcpServers.paperclip.url === process.env.PAPERCLIP_ACPX_TASK_TOOL_BRIDGE_URL
+ ? ["paperclip_finish", "paperclip_block", "read_current_wake_comments", "request_human_input"]
+ .map((tool) => `mcp__paperclip__${tool}`)
+ : [],
+ }),
// Forward MCP elicitation requests onto ACP elicitation. Only attached
// when the client advertised support, so non-supporting clients keep the
// SDK's default (auto-decline) behavior. (AskUserQuestion is handled in
@@ -1,9 +0,0 @@
{
"private": true,
"dependencies": {
"@agentclientprotocol/claude-agent-acp": "0.73.0"
},
"overrides": {
"@anthropic-ai/claude-agent-sdk": "0.3.280"
}
}
-79
View File
@@ -1,79 +0,0 @@
# Paperclip agent runtime + Go toolchain (REN-284 / REN-286). Runs as the pod
# image of the `k8s-go` sandbox environment.
#
# Every tool comes from its upstream image, pinned by tag AND digest, so the
# dockerfile manager in Renovate bumps version and digest together. A curl +
# sha256 pin (as in ci/images/k8s) would break on every Renovate bump.
#
# Both binaries are static; they run on the Ubuntu 22.04 base unchanged.
FROM golang:1.27.2-bookworm@sha256:5cf287a799e6b94384bad13d16b14904c531f51ba65792237e122ce42b392f61 AS go
# govulncheck (REN-488) has no upstream image: build it in the go stage, static.
# The version pins the module; go verifies it against sum.golang.org. Renovate
# does not track it (no regex manager here): bump by hand.
FROM go AS govulncheck
RUN CGO_ENABLED=0 GOBIN=/out go install golang.org/x/vuln/cmd/govulncheck@v1.8.0
FROM golangci/golangci-lint:v2.14.0@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS golangci-lint
# upstream publishes no `v1` tag, only git-<sha> tags. Renovate keeps the
# digest current but cannot order commit tags; a newer base is a manual bump.
FROM ghcr.io/paperclipai/agent-runtime-claude:git-38d8f371722b315d2fb3bbaa512518742e33ce2f@sha256:33ad285f9476b8637d6d940c08d577797030125675bdf4f497a8ed1f79261c8b
COPY --from=go /usr/local/go /usr/local/go
COPY --from=golangci-lint /usr/bin/golangci-lint /usr/local/bin/golangci-lint
COPY --from=govulncheck /out/govulncheck /usr/local/bin/govulncheck
# go and gofmt also in /usr/local/bin: a process started with the default PATH
# (not the image ENV) still finds them.
USER root
RUN ln -s /usr/local/go/bin/go /usr/local/bin/go \
&& ln -s /usr/local/go/bin/gofmt /usr/local/bin/gofmt
USER 1000:1000
# Time zones (REN-1196): the base has no /usr/share/zoneinfo, so
# TZ=Europe/Berlin silently prints UTC and Python ZoneInfo() raises. The
# default zone stays UTC; agents set TZ where they need local time.
USER root
RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends tzdata \
&& rm -rf /var/lib/apt/lists/*
USER 1000:1000
# ACP server the Paperclip ACP engine starts in the pod (REN-302). Version and
# patch match what Paperclip ships: packages/adapters/claude-local/package.json
# pins 0.73.0 and applies patches/@agentclientprotocol__claude-agent-acp@0.73.0.patch
# (isolated context, task-tool allow-list, usage metadata). Not bumped by
# Renovate on purpose: bump only together with Paperclip, and replace the patch.
# Paperclip also overrides the SDK (package.json pnpm overrides:
# claude-agent-sdk 0.3.280); a plain `npm install -g` ignores that and pulls an
# older SDK. So install from a package.json with the same override into a
# fixed prefix, and link the binary into /usr/local/bin.
COPY agent-runtime-common/claude-agent-acp.package.json /opt/claude-agent-acp/package.json
COPY agent-runtime-common/claude-agent-acp-0.73.0.patch /tmp/claude-agent-acp.patch
USER root
RUN cd /opt/claude-agent-acp \
&& npm install --omit=dev --no-audit --no-fund \
&& git -C node_modules/@agentclientprotocol/claude-agent-acp apply /tmp/claude-agent-acp.patch \
&& npm ls @anthropic-ai/claude-agent-sdk | grep -q 'claude-agent-sdk@0.3.280' \
&& ln -s /opt/claude-agent-acp/node_modules/.bin/claude-agent-acp /usr/local/bin/claude-agent-acp \
&& rm /tmp/claude-agent-acp.patch \
&& npm cache clean --force
USER 1000:1000
# The sandbox pod has a read-only root FS. Writable mounts: $HOME
# (/home/paperclip, 1Gi), $HOME/.cache (1Gi), /tmp (2Gi), /workspace.
# - GOPATH (module cache) goes to /tmp, the biggest one.
# - GOCACHE and the golangci-lint cache keep their default under $HOME/.cache.
# - GOTOOLCHAIN=local: a go.mod asking for a newer Go fails clearly instead of
# downloading a toolchain through the egress allow-list.
ENV PATH=/usr/local/go/bin:$PATH \
GOPATH=/tmp/go \
GOTOOLCHAIN=local
# Base user (1000:1000), entrypoint (tini) and cmd (agent shim) are inherited.
# The PATH check uses the base image's default PATH, not the ENV above.
RUN TZ=Europe/Berlin date +%Z | grep -Eq '^CES?T$' \
&& python3 -c "from zoneinfo import ZoneInfo; ZoneInfo('Europe/Berlin')" \
&& go version && golangci-lint version \
&& env -i PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
sh -ec 'for t in go gofmt golangci-lint govulncheck claude-agent-acp; do command -v "$t"; done'
-63
View File
@@ -1,63 +0,0 @@
# Paperclip agent runtime + cluster tools (REN-284 / REN-286). Runs as the pod
# image of the `k8s-ops` sandbox environment.
#
# Every tool comes from its upstream image, pinned by tag AND digest, so the
# dockerfile manager in Renovate bumps version and digest together. A curl +
# sha256 pin (as in ci/images/k8s) would break on every Renovate bump.
#
# kubectl and flux match the ci-k8s image. All four binaries are static; they
# run on the Ubuntu 22.04 base unchanged.
FROM registry.k8s.io/kubectl:v1.37.1@sha256:b7cab618e281b1ee7484e7b706a96e2135fbb6e072c2a573a7dab4e87d7f2385 AS kubectl
FROM ghcr.io/fluxcd/flux-cli:v2.9.6@sha256:b1ac18156f227af9a524b842a96f2c20986c7a779ef721df3ba4e4540f449d76 AS flux
FROM registry.k8s.io/kustomize/kustomize:v5.8.3@sha256:429eda4a1c76a7de458d0c4ca3ded9b5d764899ff6bdaeccc1d98b1b6ee873e4 AS kustomize
FROM alpine/helm:4.3.0@sha256:a6cf54599ccb99d90cf0712b30f03fdb3cab062e6b94e0418cc4db7e8a1464b2 AS helm
# upstream publishes no `v1` tag, only git-<sha> tags. Renovate keeps the
# digest current but cannot order commit tags; a newer base is a manual bump.
FROM ghcr.io/paperclipai/agent-runtime-claude:git-38d8f371722b315d2fb3bbaa512518742e33ce2f@sha256:33ad285f9476b8637d6d940c08d577797030125675bdf4f497a8ed1f79261c8b
COPY --from=kubectl /bin/kubectl /usr/local/bin/kubectl
COPY --from=flux /usr/local/bin/flux /usr/local/bin/flux
COPY --from=kustomize /app/kustomize /usr/local/bin/kustomize
COPY --from=helm /usr/bin/helm /usr/local/bin/helm
# Time zones (REN-1196): the base has no /usr/share/zoneinfo, so
# TZ=Europe/Berlin silently prints UTC and Python ZoneInfo() raises. The
# default zone stays UTC; agents set TZ where they need local time.
# jq (REN-1345): the agents' duty lists and the Paperclip skill use it.
USER root
RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends tzdata jq \
&& rm -rf /var/lib/apt/lists/*
USER 1000:1000
# ACP server the Paperclip ACP engine starts in the pod (REN-302). Version and
# patch match what Paperclip ships: packages/adapters/claude-local/package.json
# pins 0.73.0 and applies patches/@agentclientprotocol__claude-agent-acp@0.73.0.patch
# (isolated context, task-tool allow-list, usage metadata). Not bumped by
# Renovate on purpose: bump only together with Paperclip, and replace the patch.
# Paperclip also overrides the SDK (package.json pnpm overrides:
# claude-agent-sdk 0.3.280); a plain `npm install -g` ignores that and pulls an
# older SDK. So install from a package.json with the same override into a
# fixed prefix, and link the binary into /usr/local/bin.
COPY agent-runtime-common/claude-agent-acp.package.json /opt/claude-agent-acp/package.json
COPY agent-runtime-common/claude-agent-acp-0.73.0.patch /tmp/claude-agent-acp.patch
USER root
RUN cd /opt/claude-agent-acp \
&& npm install --omit=dev --no-audit --no-fund \
&& git -C node_modules/@agentclientprotocol/claude-agent-acp apply /tmp/claude-agent-acp.patch \
&& npm ls @anthropic-ai/claude-agent-sdk | grep -q 'claude-agent-sdk@0.3.280' \
&& ln -s /opt/claude-agent-acp/node_modules/.bin/claude-agent-acp /usr/local/bin/claude-agent-acp \
&& rm /tmp/claude-agent-acp.patch \
&& npm cache clean --force
USER 1000:1000
# Read-only root FS: kubectl (~/.kube/cache) and helm (~/.cache, ~/.config)
# write only under $HOME, which the sandbox pod mounts writable. No ENV needed.
# Base user (1000:1000), entrypoint (tini) and cmd (agent shim) are inherited.
RUN TZ=Europe/Berlin date +%Z | grep -Eq '^CES?T$' \
&& python3 -c "from zoneinfo import ZoneInfo; ZoneInfo('Europe/Berlin')" \
&& kubectl version --client && flux version --client && kustomize version && helm version && jq --version \
&& env -i PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
sh -ec 'for t in kubectl flux kustomize helm jq claude-agent-acp; do command -v "$t"; done'
+1
View File
@@ -18,6 +18,7 @@ RUN apk add --no-cache \
sshpass
RUN pip3 install --break-system-packages --no-cache-dir \
hvac \
proxmoxer \
requests
@@ -0,0 +1,20 @@
# authentik-gate-sync: the authentik-gate ingress class. Turns each Ingress of
# that class into an Authentik proxy gate plus the Traefik Ingress, ExternalName
# Service and network policies in front of it. Standard library only, so the
# image is the interpreter plus one file; runs unprivileged as its own user.
# The unit tests gate the image: the final stage copies the controller out of
# this stage, so a failing test fails the build.
FROM python:3.15-slim@sha256:2da676046630d7bf14c6764ae1143dc7fa0377a53f94bf5a642f0b214aca5bce AS test
WORKDIR /src
COPY controller.py test_controller.py ./
RUN python3 -B -m unittest -v
FROM python:3.15-slim@sha256:2da676046630d7bf14c6764ae1143dc7fa0377a53f94bf5a642f0b214aca5bce
RUN useradd --system --no-create-home --shell /usr/sbin/nologin controller
COPY --from=test /src/controller.py /opt/controller.py
ENV PYTHONUNBUFFERED=1
USER controller
ENTRYPOINT ["python3", "/opt/controller.py"]
+652
View File
@@ -0,0 +1,652 @@
#!/usr/bin/env python3
"""authentik-gate-sync: an ingress class that puts an Authentik proxy gate in front of an app.
Source of truth: every Ingress with spec.ingressClassName "authentik-gate". Traefik
ignores that class (its IngressClass names another controller), so a source
Ingress is never served directly. For each one this controller builds:
Authentik proxy provider + application + outpost (+ group-membership
expression policy bound to the application).
Authentik's own Kubernetes integration then runs the outpost as
ak-outpost-<gate> in the authentik namespace.
app ns ExternalName Service <ingress>-gate -> the outpost Service,
Traefik Ingress <ingress>-gate: the source's hosts, paths, TLS,
labels and annotations, backend swapped for the gate,
CNP <ingress>-gate: the backend pods admit this outpost.
authentik CNP gate-<gate>: the outpost may reach only those backend pods.
Like any ingress controller it publishes the source's status.loadBalancer: the
address Traefik reports for the route, or none while the gate has no route.
The source Ingress's backend is the real upstream; it becomes the provider's
internal host and its host the external host. Annotations (all optional):
auth.renner.uno/group group whose members may pass; none = any user
auth.renner.uno/login-flow authentication flow slug; none = the brand's
auth.renner.uno/authorization-flow authorization flow slug (default below)
auth.renner.uno/skip-path provider skip_path_regex
Fail closed: the Traefik Ingress is applied last and only after the Authentik
side reconciled, and is removed when that fails (e.g. the named group is gone),
so there is never a route to an unguarded backend.
Ownership: Kubernetes objects carry app.kubernetes.io/managed-by; Authentik
applications carry meta_publisher. Anything without the marker is never touched.
"""
import hashlib
import json
import logging
import os
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
LOG = logging.getLogger("authentik-gate-sync")
AK_URL = os.environ.get("AK_URL", "http://authentik-server.authentik.svc.cluster.local").rstrip("/")
AK_TOKEN_FILE = os.environ.get("AK_TOKEN_FILE", "/var/run/secrets/authentik-gate-sync/token")
INGRESS_CLASS = os.environ.get("INGRESS_CLASS", "authentik-gate")
TARGET_CLASS = os.environ.get("TARGET_INGRESS_CLASS", "traefik")
OUTPOST_NS = os.environ.get("OUTPOST_NAMESPACE", "authentik")
SERVICE_CONNECTION = os.environ.get("SERVICE_CONNECTION", "Local Kubernetes Cluster")
DEFAULT_AUTHZ_FLOW = os.environ.get("DEFAULT_AUTHORIZATION_FLOW", "default-provider-authorization-implicit-consent")
INVALIDATION_FLOW = os.environ.get("INVALIDATION_FLOW", "default-provider-invalidation-flow")
COOKIE_DOMAIN = os.environ.get("COOKIE_DOMAIN", "renner.uno")
AK_BROWSER_URL = os.environ.get("AUTHENTIK_HOST_BROWSER", "https://auth.renner.uno/")
INTERVAL = int(os.environ.get("INTERVAL_SECONDS", "60"))
HTTP_TIMEOUT = 20
ANN = "auth.renner.uno/"
MANAGER = "authentik-gate-sync"
MANAGED_BY = {"app.kubernetes.io/managed-by": MANAGER}
GATE_LABEL = "auth.renner.uno/gate"
OUTPOST_PORT = 9000
# Never copied from the source onto the generated Ingress: cert-manager would
# issue a second Certificate for the same secret, Flux would treat it as its own,
# and the gate annotations mean nothing to Traefik.
DROP_LABEL_PREFIXES = ("kustomize.toolkit.fluxcd.io/", "app.kubernetes.io/managed-by")
DROP_ANNOTATION_PREFIXES = (ANN, "cert-manager.io/", "acme.cert-manager.io/",
"kubectl.kubernetes.io/last-applied-configuration")
K8S_API = "https://kubernetes.default.svc"
K8S_TOKEN_FILE = "/var/run/secrets/kubernetes.io/serviceaccount/token"
K8S_CA_FILE = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"
class ApiError(Exception):
def __init__(self, msg, code=None):
super().__init__(msg)
self.code = code
class GateError(Exception):
"""A source Ingress that cannot be turned into a gate; it gets no route."""
def http(method, url, body=None, headers=None, cafile=None, content_type="application/json"):
data = None if body is None else json.dumps(body).encode()
req = urllib.request.Request(url, data=data, method=method)
req.add_header("Accept", "application/json")
if data is not None:
req.add_header("Content-Type", content_type)
for k, v in (headers or {}).items():
req.add_header(k, v)
ctx = None
if cafile:
import ssl
ctx = ssl.create_default_context(cafile=cafile)
try:
with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT, context=ctx) as resp:
raw = resp.read()
return json.loads(raw) if raw else None
except urllib.error.HTTPError as e:
detail = e.read().decode(errors="replace")[:500]
raise ApiError(f"{method} {url} -> {e.code}: {detail}", e.code) from None
except (OSError, ValueError) as e: # URLError, timeouts, resets, bad JSON
raise ApiError(f"{method} {url} -> {e}") from None
# --- desired state (pure) ---------------------------------------------------
def gate_name(ing):
"""<ns>-<ingress>, truncated, plus a hash of both: unambiguous (a-b/c vs a/b-c)
and short enough for label values and ak-outpost-<name> (63)."""
ns, name = ing["metadata"]["namespace"], ing["metadata"]["name"]
digest = hashlib.sha256(f"{ns}/{name}".encode()).hexdigest()[:6]
return f"{ns}-{name}"[:45].rstrip("-") + "-" + digest
def group_expression(group):
# repr() makes the annotation value a Python string literal, never code.
# A group that does not exist matches nothing, so deleting it denies access.
return f"return ak_is_group_member(request.user, name={group!r})"
def gate_spec(ing):
"""Translate a source Ingress into the gate it asks for. Raises GateError."""
meta = ing["metadata"]
ann = meta.get("annotations") or {}
rules = ing.get("spec", {}).get("rules") or []
hosts = sorted({r.get("host") for r in rules if r.get("host")})
if len(hosts) != 1 or len(hosts) != len(rules):
raise GateError(f"needs exactly one rule with one host, has {[r.get('host') for r in rules]}")
if hosts[0].startswith("*"):
raise GateError(f"wildcard host {hosts[0]} cannot be a gate")
backends = set()
for p in rules[0].get("http", {}).get("paths") or []:
svc = (p.get("backend") or {}).get("service")
if not svc:
raise GateError("every path needs a service backend")
port = svc.get("port") or {}
backends.add((svc["name"], port.get("number") or port.get("name")))
if ing.get("spec", {}).get("defaultBackend"):
raise GateError("defaultBackend is not supported")
if len(backends) != 1:
raise GateError(f"all paths must share one backend, has {sorted(map(str, backends))}")
(service, port), = backends
tls_hosts = {h for t in ing.get("spec", {}).get("tls") or [] for h in t.get("hosts") or []}
return {
"name": gate_name(ing),
"namespace": meta["namespace"],
"ingress": meta["name"],
"uid": meta["uid"],
"host": hosts[0],
"scheme": "https" if hosts[0] in tls_hosts else "http",
"service": service,
"port": port,
"group": ann.get(ANN + "group") or None,
"login_flow": ann.get(ANN + "login-flow") or None,
"authorization_flow": ann.get(ANN + "authorization-flow") or DEFAULT_AUTHZ_FLOW,
"skip_path": ann.get(ANN + "skip-path", ""),
}
def owner_ref(gate):
return [{"apiVersion": "networking.k8s.io/v1", "kind": "Ingress", "name": gate["ingress"],
"uid": gate["uid"], "controller": True, "blockOwnerDeletion": False}]
def gate_labels(gate):
return {**MANAGED_BY, GATE_LABEL: gate["name"]}
def outpost_service(gate):
return f"ak-outpost-{gate['name']}"
def external_name_service(gate):
return {"apiVersion": "v1", "kind": "Service",
"metadata": {"name": f"{gate['ingress']}-gate", "namespace": gate["namespace"],
"labels": gate_labels(gate), "ownerReferences": owner_ref(gate)},
"spec": {"type": "ExternalName",
"externalName": f"{outpost_service(gate)}.{OUTPOST_NS}.svc.cluster.local",
"ports": [{"name": "http", "port": OUTPOST_PORT, "targetPort": OUTPOST_PORT}]}}
def route_ingress(ing, gate):
"""The Traefik Ingress: the source with its backend swapped for the gate."""
meta = ing["metadata"]
labels = {k: v for k, v in (meta.get("labels") or {}).items() if not k.startswith(DROP_LABEL_PREFIXES)}
annotations = {k: v for k, v in (meta.get("annotations") or {}).items()
if not k.startswith(DROP_ANNOTATION_PREFIXES)}
backend = {"service": {"name": f"{gate['ingress']}-gate", "port": {"number": OUTPOST_PORT}}}
rules = []
for r in ing["spec"]["rules"]:
paths = [{"path": p.get("path", "/"), "pathType": p.get("pathType", "Prefix"), "backend": backend}
for p in r["http"]["paths"]]
rules.append({"host": r["host"], "http": {"paths": paths}})
spec = {"ingressClassName": TARGET_CLASS, "rules": rules}
if ing["spec"].get("tls"):
spec["tls"] = ing["spec"]["tls"]
return {"apiVersion": "networking.k8s.io/v1", "kind": "Ingress",
"metadata": {"name": f"{gate['ingress']}-gate", "namespace": gate["namespace"],
"labels": {**labels, **gate_labels(gate)}, "annotations": annotations,
"ownerReferences": owner_ref(gate)},
"spec": spec}
def outpost_selector(gate):
return {"k8s:io.kubernetes.pod.namespace": OUTPOST_NS, "goauthentik.io/outpost-name": gate["name"]}
def backend_ingress_cnp(gate, pod_selector, target_port):
return {"apiVersion": "cilium.io/v2", "kind": "CiliumNetworkPolicy",
"metadata": {"name": f"{gate['ingress']}-gate", "namespace": gate["namespace"],
"labels": gate_labels(gate), "ownerReferences": owner_ref(gate)},
"spec": {"endpointSelector": {"matchLabels": pod_selector},
"ingress": [{"fromEndpoints": [{"matchLabels": outpost_selector(gate)}],
"toPorts": [{"ports": [{"port": str(target_port), "protocol": "TCP"}]}]}]}}
def outpost_egress_cnp(gate, pod_selector, target_port):
peer = {"k8s:io.kubernetes.pod.namespace": gate["namespace"], **pod_selector}
return {"apiVersion": "cilium.io/v2", "kind": "CiliumNetworkPolicy",
"metadata": {"name": f"gate-{gate['name']}", "namespace": OUTPOST_NS, "labels": gate_labels(gate)},
"spec": {"endpointSelector": {"matchLabels": {"goauthentik.io/outpost-name": gate["name"]}},
"egress": [{"toEndpoints": [{"matchLabels": peer}],
"toPorts": [{"ports": [{"port": str(target_port), "protocol": "TCP"}]}]}]}}
def resolve_backend(gate, service):
"""(service port number, pod selector, target port) for the gate's backend.
A named targetPort stays a name: Cilium resolves named ports against the
selected pods' container ports, so the policy does not depend on the pods
being up."""
selector = service.get("spec", {}).get("selector")
if not selector:
raise GateError(f"Service {gate['service']} has no selector; cannot scope its network policy")
sp = None
for p in service["spec"].get("ports") or []:
if p.get("port") == gate["port"] or (p.get("name") and p.get("name") == gate["port"]):
sp = p
break
if sp is None:
raise GateError(f"Service {gate['service']} has no port {gate['port']}")
target = sp.get("targetPort", sp["port"])
if isinstance(target, int):
return sp["port"], selector, target
if isinstance(target, str) and target.isdigit():
return sp["port"], selector, int(target)
return sp["port"], selector, target
def outpost_config():
return {
"log_level": "info",
"authentik_host": AK_URL,
"authentik_host_insecure": False,
"authentik_host_browser": AK_BROWSER_URL,
"refresh_interval": "minutes=5",
"container_image": None,
"object_naming_template": "ak-outpost-%(name)s",
"kubernetes_namespace": OUTPOST_NS,
"kubernetes_replicas": 1,
"kubernetes_service_type": "ClusterIP",
"kubernetes_disabled_components": ["ingress"],
"kubernetes_image_pull_secrets": [],
"kubernetes_ingress_annotations": {},
"kubernetes_ingress_class_name": None,
"kubernetes_ingress_secret_name": "authentik-outpost-tls",
"kubernetes_json_patches": None,
}
def provider_body(gate, svc_port, flows):
return {
"name": gate["name"],
"mode": "proxy",
"external_host": f"{gate['scheme']}://{gate['host']}",
"internal_host": f"http://{gate['service']}.{gate['namespace']}.svc.cluster.local:{svc_port}",
"internal_host_ssl_validation": False,
"cookie_domain": COOKIE_DOMAIN,
"access_token_validity": "hours=24",
"refresh_token_validity": "days=30",
"intercept_header_auth": True,
"skip_path_regex": gate["skip_path"],
"authorization_flow": flows[gate["authorization_flow"]],
"authentication_flow": flows[gate["login_flow"]] if gate["login_flow"] else None,
"invalidation_flow": flows[INVALIDATION_FLOW],
}
def lb_ingress(obj):
return ((obj or {}).get("status") or {}).get("loadBalancer", {}).get("ingress") or []
def status_patch(source, addresses):
"""Merge patch publishing `addresses` as the source Ingress's load-balancer
status, as an ingress controller does; None when it already matches."""
if lb_ingress(source) == addresses:
return None
return {"status": {"loadBalancer": {"ingress": addresses or None}}}
def drift(current, desired):
return {k: v for k, v in desired.items() if current.get(k) != v}
# --- Kubernetes -------------------------------------------------------------
class K8s:
def _h(self):
with open(K8S_TOKEN_FILE) as f:
return {"Authorization": f"Bearer {f.read().strip()}"}
def get(self, path):
return http("GET", K8S_API + path, headers=self._h(), cafile=K8S_CA_FILE)
def apply(self, obj):
"""Server-side apply; idempotent, so every pass re-asserts every object.
Refuses to take over a same-named object this controller does not own."""
try:
live = self.get(object_path(obj))
except ApiError as e:
if e.code != 404:
raise
else:
if (live["metadata"].get("labels") or {}).get("app.kubernetes.io/managed-by") != MANAGER:
raise GateError(f'{obj["kind"]} {obj["metadata"]["namespace"]}/{obj["metadata"]["name"]} '
f"exists and is not managed by {MANAGER}")
path = object_path(obj) + f"?fieldManager={MANAGER}&force=true"
return http("PATCH", K8S_API + path, obj, headers=self._h(), cafile=K8S_CA_FILE,
content_type="application/apply-patch+yaml")
def delete(self, obj):
try:
http("DELETE", K8S_API + object_path(obj), headers=self._h(), cafile=K8S_CA_FILE)
except ApiError as e:
if e.code != 404:
raise
def patch_status(self, ns, name, patch):
path = f"/apis/networking.k8s.io/v1/namespaces/{ns}/ingresses/{name}/status"
return http("PATCH", K8S_API + path, patch, headers=self._h(), cafile=K8S_CA_FILE,
content_type="application/merge-patch+json")
def delete_managed(self, obj):
try:
live = self.get(object_path(obj))
except ApiError as e:
if e.code == 404:
return
raise
if (live["metadata"].get("labels") or {}).get("app.kubernetes.io/managed-by") == MANAGER:
self.delete(obj)
LOG.info("deleted %s %s/%s", obj["kind"], obj["metadata"]["namespace"], obj["metadata"]["name"])
COLLECTIONS = {
"Ingress": "/apis/networking.k8s.io/v1/namespaces/{ns}/ingresses",
"Service": "/api/v1/namespaces/{ns}/services",
"CiliumNetworkPolicy": "/apis/cilium.io/v2/namespaces/{ns}/ciliumnetworkpolicies",
}
CLUSTER_COLLECTIONS = {
"Ingress": "/apis/networking.k8s.io/v1/ingresses",
"Service": "/api/v1/services",
"CiliumNetworkPolicy": "/apis/cilium.io/v2/ciliumnetworkpolicies",
}
def object_path(obj):
m = obj["metadata"]
return COLLECTIONS[obj["kind"]].format(ns=m["namespace"]) + "/" + m["name"]
def managed_objects(k8s):
sel = urllib.parse.quote(f"app.kubernetes.io/managed-by={MANAGER}")
out = []
for kind, path in CLUSTER_COLLECTIONS.items():
for item in k8s.get(f"{path}?labelSelector={sel}").get("items", []):
item["kind"] = kind
out.append(item)
return out
# --- Authentik --------------------------------------------------------------
class Authentik:
def __init__(self, token):
self.h = {"Authorization": f"Bearer {token}"}
def req(self, method, path, body=None):
return http(method, f"{AK_URL}/api/v3/{path}", body, headers=self.h)
def list(self, path, **query):
"""All pages of a list endpoint."""
query = {**query, "page_size": 100}
out, page = [], 1
while True:
res = self.req("GET", f"{path}?{urllib.parse.urlencode({**query, 'page': page})}")
out.extend(res["results"])
if not res["pagination"].get("next"):
return out
page += 1
def one(self, path, **query):
found = self.list(path, **query)
return found[0] if found else None
def get(self, path):
try:
return self.req("GET", path)
except ApiError as e:
if e.code == 404:
return None
raise
def get_application(ak, slug):
# Retrieve, never list: the list endpoint only shows applications the token's
# user passes policies for and caches that per user, ignoring the filters.
return ak.get(f"core/applications/{slug}/")
def ensure_gate(ak, gate, svc_port, flows, connection):
name = gate["name"]
if gate["group"] and ak.one("core/groups/", name=gate["group"]) is None:
raise GateError(f"group {gate['group']!r} not found in Authentik")
app = get_application(ak, name)
if app is not None and app.get("meta_publisher") != MANAGER:
raise GateError(f"application {name} exists and is not managed by {MANAGER}")
want = provider_body(gate, svc_port, flows)
provider = ak.one("providers/proxy/", name__iexact=name)
if provider is None:
provider = ak.req("POST", "providers/proxy/", want)
LOG.info("%s: created provider %s", name, provider["pk"])
elif provider.get("assigned_application_slug") not in (None, name) or \
(app is not None and app.get("provider") != provider["pk"]):
raise GateError(f"proxy provider {name} belongs to another application")
elif drift(provider, want):
# Also adopts a provider whose application an interrupted teardown removed.
# The full body: the serializer validates internal_host against mode
# from the request alone, so a partial PATCH without them is a 400.
ak.req("PATCH", f"providers/proxy/{provider['pk']}/", want)
LOG.info("%s: updated provider %s", name, sorted(drift(provider, want)))
want_app = {"name": f"{gate['host']} (gate)", "slug": name, "provider": provider["pk"],
"meta_launch_url": want["external_host"], "meta_publisher": MANAGER,
"meta_description": f"Gate for Ingress {gate['namespace']}/{gate['ingress']}"}
if app is None:
app = ak.req("POST", "core/applications/", want_app)
LOG.info("%s: created application", name)
elif drift(app, want_app):
ak.req("PATCH", f"core/applications/{name}/", drift(app, want_app))
LOG.info("%s: updated application %s", name, sorted(drift(app, want_app)))
ensure_access(ak, gate, app["pbm_uuid"])
want_outpost = {"name": name, "type": "proxy", "service_connection": connection,
"providers": [provider["pk"]], "config": outpost_config()}
outpost = ak.one("outposts/instances/", name__iexact=name)
if outpost is None:
ak.req("POST", "outposts/instances/", want_outpost)
LOG.info("%s: created outpost", name)
elif set(outpost.get("providers") or []) - {provider["pk"]}:
raise GateError(f"outpost {name} serves providers this controller does not own")
elif drift(outpost, want_outpost):
ak.req("PATCH", f"outposts/instances/{outpost['pk']}/", drift(outpost, want_outpost))
LOG.info("%s: updated outpost %s", name, sorted(drift(outpost, want_outpost)))
def ensure_access(ak, gate, target):
"""Group gates bind an expression policy, not the group itself: a deleted
group cascades its bindings away (leaving the app open to everyone), while
the policy keeps evaluating False. The new binding exists before any stale
one is removed, so a change never passes through "no binding"."""
name = gate["name"]
policy = None
if gate["group"]:
want = {"name": name, "expression": group_expression(gate["group"]), "execution_logging": False}
policy = ak.one("policies/expression/", name=name)
if policy is None:
policy = ak.req("POST", "policies/expression/", want)
LOG.info("%s: created policy for group %s", name, gate["group"])
elif drift(policy, want):
ak.req("PATCH", f"policies/expression/{policy['pk']}/", drift(policy, want))
LOG.info("%s: policy now requires group %s", name, gate["group"])
bindings = ak.list("policies/bindings/", target=target)
keep = next((b for b in bindings if policy and b.get("policy") == policy["pk"]), None)
want_binding = {"enabled": True, "negate": False, "failure_result": False}
if policy and keep is None:
ak.req("POST", "policies/bindings/", {"target": target, "policy": policy["pk"], "order": 0,
"timeout": 30, **want_binding})
LOG.info("%s: bound policy", name)
elif keep is not None and drift(keep, want_binding):
ak.req("PATCH", f"policies/bindings/{keep['pk']}/", want_binding)
for b in bindings:
if b is not keep:
ak.req("DELETE", f"policies/bindings/{b['pk']}/")
LOG.info("%s: removed binding %s", name, b["pk"])
if not gate["group"]:
stale = ak.one("policies/expression/", name=name)
if stale is not None:
ak.req("DELETE", f"policies/expression/{stale['pk']}/")
def remove_gate(ak, app):
"""Delete an owned gate. The outpost serving only its provider goes first
(Authentik tears its pods down), then the application (bindings cascade; its
provider is PROTECTed while referenced), the provider, the policy. Ownership
is found through the application, so until it is gone a failed step is
retried on the next pass."""
name, provider = app["slug"], app.get("provider")
for o in ak.list("outposts/instances/", name__iexact=name):
if o.get("providers") == [provider]:
ak.req("DELETE", f"outposts/instances/{o['pk']}/")
ak.req("DELETE", f"core/applications/{name}/")
if provider:
ak.req("DELETE", f"providers/proxy/{provider}/")
policy = ak.one("policies/expression/", name=name)
if policy is not None:
ak.req("DELETE", f"policies/expression/{policy['pk']}/")
LOG.info("%s: removed gate from Authentik", name)
def owned_applications(ak):
"""Applications this controller owns, found through the (unfiltered) proxy
provider list and confirmed one by one."""
for p in ak.list("providers/proxy/"):
slug = p.get("assigned_application_slug")
if not slug:
continue
app = get_application(ak, slug)
if app and app.get("meta_publisher") == MANAGER and app.get("provider") == p["pk"]:
yield app
# --- reconcile --------------------------------------------------------------
def publish_status(k8s, source, addresses):
"""Status is cosmetic for the gate itself: a failure is logged, never fatal."""
patch = status_patch(source, addresses)
if patch is None:
return
m = source["metadata"]
try:
k8s.patch_status(m["namespace"], m["name"], patch)
LOG.info("%s/%s: status %s", m["namespace"], m["name"],
[a.get("ip") or a.get("hostname") for a in addresses])
except Exception as e:
LOG.warning("%s/%s: publishing status: %s", m["namespace"], m["name"], e)
def reconcile():
k8s = K8s()
with open(AK_TOKEN_FILE) as f:
ak = Authentik(f.read().strip())
sources = [i for i in k8s.get("/apis/networking.k8s.io/v1/ingresses").get("items", [])
if i.get("spec", {}).get("ingressClassName") == INGRESS_CLASS]
flows = {f["slug"]: f["pk"] for f in ak.list("flows/instances/")}
connection = ak.one("outposts/service_connections/kubernetes/", name=SERVICE_CONNECTION)
if connection is None:
raise ApiError(f"Kubernetes service connection {SERVICE_CONNECTION!r} not found")
connection = connection["pk"]
desired = set() # (kind, ns, name) of every Kubernetes object some gate wants
gates = set() # gate names whose Authentik side should exist
held = set() # gates hit by an API error: their live objects stay as they are
failed = 0
for ing in sorted(sources, key=gate_name):
ref = f'{ing["metadata"]["namespace"]}/{ing["metadata"]["name"]}'
try:
gate = gate_spec(ing)
for slug in filter(None, (gate["authorization_flow"], gate["login_flow"], INVALIDATION_FLOW)):
if slug not in flows:
raise GateError(f"flow {slug!r} not found in Authentik")
ns = gate["namespace"]
try:
service = k8s.get(f"/api/v1/namespaces/{ns}/services/{gate['service']}")
except ApiError as e:
if e.code == 404:
raise GateError(f"backend Service {gate['service']} does not exist") from None
raise
svc_port, pods, target_port = resolve_backend(gate, service)
gates.add(gate["name"])
ensure_gate(ak, gate, svc_port, flows, connection)
objs = [outpost_egress_cnp(gate, pods, target_port), backend_ingress_cnp(gate, pods, target_port),
external_name_service(gate), route_ingress(ing, gate)] # the route goes last
for obj in objs:
live = k8s.apply(obj)
desired.add((obj["kind"], obj["metadata"]["namespace"], obj["metadata"]["name"]))
publish_status(k8s, ing, lb_ingress(live)) # the route's address, from Traefik
except GateError as e:
# The Ingress cannot be gated as written: its route goes now, the
# rest with the sweep below.
LOG.error("%s: %s", ref, e)
failed += 1
gates.discard(gate_name(ing))
route = {"kind": "Ingress", "metadata": {"namespace": ing["metadata"]["namespace"],
"name": f'{ing["metadata"]["name"]}-gate'}}
try:
k8s.delete_managed(route)
except Exception as e: # the sweep retries
LOG.error("%s: removing route: %s", ref, e)
publish_status(k8s, ing, [])
except Exception as e:
# Transient: leave this gate exactly as the last good pass left it.
LOG.error("%s: %s", ref, e)
gates.add(gate_name(ing))
held.add(gate_name(ing))
failed += 1
removed = 0
for obj in managed_objects(k8s):
key = (obj["kind"], obj["metadata"]["namespace"], obj["metadata"]["name"])
if key not in desired and obj["metadata"]["labels"].get(GATE_LABEL) not in held:
obj = {"kind": obj["kind"], "metadata": {"namespace": key[1], "name": key[2]}}
k8s.delete(obj)
LOG.info("deleted %s %s/%s", *key)
removed += 1
for app in list(owned_applications(ak)):
if app["slug"] not in gates:
remove_gate(ak, app)
removed += 1
LOG.info("reconciled: %d gates, %d failed, %d objects removed", len(sources), failed, removed)
def main():
logging.basicConfig(level=os.environ.get("LOG_LEVEL", "INFO"), stream=sys.stdout,
format="%(asctime)s %(levelname)s %(message)s")
if os.environ.get("ONCE") == "1":
reconcile()
return
while True:
try:
reconcile()
except Exception as e: # keep running; the next pass retries
LOG.error("reconcile failed: %s", e)
time.sleep(INTERVAL)
if __name__ == "__main__":
main()
@@ -0,0 +1,351 @@
import copy
import unittest
import controller as c
LLAMA = {
"metadata": {
"name": "llama-server", "namespace": "ai", "uid": "u-1",
"labels": {"dns.renner.uno/public": "true", "kustomize.toolkit.fluxcd.io/name": "llama-server"},
"annotations": {
"cert-manager.io/cluster-issuer": "letsencrypt-cluster",
"auth.renner.uno/login-flow": "sso-authentication",
"auth.renner.uno/skip-path": r"^/(manifest\.webmanifest|sw\.js)$",
"traefik.ingress.kubernetes.io/router.middlewares": "ingress-foo@kubernetescrd",
},
},
"spec": {
"ingressClassName": "authentik-gate",
"tls": [{"hosts": ["llm.renner.uno"], "secretName": "llama-server-tls"}],
"rules": [{"host": "llm.renner.uno", "http": {"paths": [
{"path": "/", "pathType": "Prefix",
"backend": {"service": {"name": "llama-server", "port": {"number": 8080}}}}]}}],
},
}
SERVICE = {"spec": {"selector": {"app.kubernetes.io/name": "llama-server"},
"ports": [{"name": "http", "port": 8080, "targetPort": "http"}]}}
def ing(ns, name):
return {"metadata": {"namespace": ns, "name": name}}
class Names(unittest.TestCase):
def test_unambiguous(self):
self.assertNotEqual(c.gate_name(ing("a-b", "c")), c.gate_name(ing("a", "b-c")))
def test_stable_and_readable(self):
self.assertEqual(c.gate_name(ing("ai", "llama-server")), c.gate_name(ing("ai", "llama-server")))
self.assertTrue(c.gate_name(ing("ai", "llama-server")).startswith("ai-llama-server-"))
def test_bounded(self):
n = c.gate_name(ing("n" * 63, "i" * 253))
self.assertLessEqual(len(c.outpost_service({"name": n})), 63)
self.assertNotIn("--", n)
def test_group_expression_is_a_literal(self):
expr = c.group_expression("x'); import os; os.system('id'); ('")
self.assertEqual(expr, "return ak_is_group_member(request.user, name="
+ repr("x'); import os; os.system('id'); ('") + ")")
compile(expr.replace("return ", "_ = "), "<expr>", "exec")
class GateSpec(unittest.TestCase):
def test_llama(self):
g = c.gate_spec(LLAMA)
self.assertEqual(g["name"], c.gate_name(LLAMA))
self.assertEqual((g["host"], g["scheme"], g["service"], g["port"]),
("llm.renner.uno", "https", "llama-server", 8080))
self.assertIsNone(g["group"])
self.assertEqual(g["login_flow"], "sso-authentication")
self.assertEqual(g["authorization_flow"], c.DEFAULT_AUTHZ_FLOW)
def test_group_and_flow_override(self):
ing = copy.deepcopy(LLAMA)
ing["metadata"]["annotations"]["auth.renner.uno/group"] = "Home Assistant Config Editors"
ing["metadata"]["annotations"]["auth.renner.uno/authorization-flow"] = "implicit"
g = c.gate_spec(ing)
self.assertEqual(g["group"], "Home Assistant Config Editors")
self.assertEqual(g["authorization_flow"], "implicit")
def test_rejects_two_hosts(self):
ing = copy.deepcopy(LLAMA)
ing["spec"]["rules"].append(copy.deepcopy(ing["spec"]["rules"][0]))
ing["spec"]["rules"][1]["host"] = "other.renner.uno"
with self.assertRaises(c.GateError):
c.gate_spec(ing)
def test_rejects_two_backends(self):
ing = copy.deepcopy(LLAMA)
paths = ing["spec"]["rules"][0]["http"]["paths"]
paths.append({"path": "/api", "pathType": "Prefix",
"backend": {"service": {"name": "other", "port": {"number": 80}}}})
with self.assertRaises(c.GateError):
c.gate_spec(ing)
def test_rejects_wildcard(self):
ing = copy.deepcopy(LLAMA)
ing["spec"]["rules"][0]["host"] = "*.renner.uno"
with self.assertRaises(c.GateError):
c.gate_spec(ing)
class Backend(unittest.TestCase):
def test_named_target_port_stays_named(self):
g = c.gate_spec(LLAMA)
self.assertEqual(c.resolve_backend(g, SERVICE),
(8080, {"app.kubernetes.io/name": "llama-server"}, "http"))
cnp = c.backend_ingress_cnp(g, {"app.kubernetes.io/name": "llama-server"}, "http")
self.assertEqual(cnp["spec"]["ingress"][0]["toPorts"][0]["ports"][0]["port"], "http")
def test_numeric_target_port(self):
g = c.gate_spec(LLAMA)
svc = copy.deepcopy(SERVICE)
svc["spec"]["ports"][0]["targetPort"] = 9090
self.assertEqual(c.resolve_backend(g, svc)[2], 9090)
def test_missing_target_port_defaults_to_port(self):
g = c.gate_spec(LLAMA)
svc = copy.deepcopy(SERVICE)
del svc["spec"]["ports"][0]["targetPort"]
self.assertEqual(c.resolve_backend(g, svc)[2], 8080)
def test_service_without_selector_fails(self):
with self.assertRaises(c.GateError):
c.resolve_backend(c.gate_spec(LLAMA), {"spec": {"ports": SERVICE["spec"]["ports"]}})
def test_missing_port_fails(self):
g = c.gate_spec(LLAMA)
g["port"] = 1234
with self.assertRaises(c.GateError):
c.resolve_backend(g, SERVICE)
class Objects(unittest.TestCase):
def setUp(self):
self.g = c.gate_spec(LLAMA)
def test_route_ingress(self):
r = c.route_ingress(LLAMA, self.g)
self.assertEqual(r["metadata"]["name"], "llama-server-gate")
self.assertEqual(r["spec"]["ingressClassName"], "traefik")
self.assertEqual(r["spec"]["tls"], LLAMA["spec"]["tls"])
backend = r["spec"]["rules"][0]["http"]["paths"][0]["backend"]["service"]
self.assertEqual(backend, {"name": "llama-server-gate", "port": {"number": 9000}})
labels, ann = r["metadata"]["labels"], r["metadata"]["annotations"]
self.assertEqual(labels["dns.renner.uno/public"], "true")
self.assertNotIn("kustomize.toolkit.fluxcd.io/name", labels)
self.assertEqual(labels["app.kubernetes.io/managed-by"], c.MANAGER)
self.assertEqual(ann, {"traefik.ingress.kubernetes.io/router.middlewares": "ingress-foo@kubernetescrd"})
self.assertEqual(r["metadata"]["ownerReferences"][0]["uid"], "u-1")
def test_external_name(self):
s = c.external_name_service(self.g)
self.assertEqual(s["spec"]["externalName"], f"ak-outpost-{self.g['name']}.authentik.svc.cluster.local")
def test_cnps(self):
pods = {"app.kubernetes.io/name": "llama-server"}
e = c.outpost_egress_cnp(self.g, pods, 8080)
self.assertEqual(e["metadata"]["namespace"], "authentik")
self.assertEqual(e["spec"]["endpointSelector"]["matchLabels"],
{"goauthentik.io/outpost-name": self.g["name"]})
self.assertEqual(e["spec"]["egress"][0]["toEndpoints"][0]["matchLabels"],
{"k8s:io.kubernetes.pod.namespace": "ai", "app.kubernetes.io/name": "llama-server"})
i = c.backend_ingress_cnp(self.g, pods, 8080)
self.assertEqual(i["metadata"]["namespace"], "ai")
self.assertEqual(i["spec"]["ingress"][0]["fromEndpoints"][0]["matchLabels"],
{"k8s:io.kubernetes.pod.namespace": "authentik",
"goauthentik.io/outpost-name": self.g["name"]})
self.assertEqual(i["spec"]["ingress"][0]["toPorts"][0]["ports"][0]["port"], "8080")
def test_provider_body(self):
flows = {c.DEFAULT_AUTHZ_FLOW: "az", "sso-authentication": "au",
c.INVALIDATION_FLOW: "inv"}
p = c.provider_body(self.g, 8080, flows)
self.assertEqual(p["external_host"], "https://llm.renner.uno")
self.assertEqual(p["internal_host"], "http://llama-server.ai.svc.cluster.local:8080")
self.assertEqual((p["authorization_flow"], p["authentication_flow"], p["invalidation_flow"]),
("az", "au", "inv"))
self.assertEqual(p["skip_path_regex"], r"^/(manifest\.webmanifest|sw\.js)$")
def test_status_mirrors_route_address(self):
addrs = [{"ip": "10.2.99.86"}, {"ip": "fd99::86"}]
route = {"status": {"loadBalancer": {"ingress": addrs}}}
self.assertEqual(c.status_patch(LLAMA, c.lb_ingress(route)),
{"status": {"loadBalancer": {"ingress": addrs}}})
synced = dict(LLAMA, status={"loadBalancer": {"ingress": addrs}})
self.assertIsNone(c.status_patch(synced, addrs))
def test_status_cleared_without_route(self):
stale = dict(LLAMA, status={"loadBalancer": {"ingress": [{"ip": "10.2.99.86"}]}})
self.assertEqual(c.status_patch(stale, []), {"status": {"loadBalancer": {"ingress": None}}})
self.assertIsNone(c.status_patch(LLAMA, []))
def test_drift(self):
self.assertEqual(c.drift({"a": 1, "b": 2}, {"a": 1, "b": 3}), {"b": 3})
class FakeAuthentik:
"""In-memory stand-in for the endpoints the controller uses; records calls."""
def __init__(self):
self.db = {"providers/proxy/": {}, "core/applications/": {}, "outposts/instances/": {},
"policies/bindings/": {}, "policies/expression/": {}, "core/groups/": {}}
self.calls = []
self.next = 1
def add(self, path, obj):
obj.setdefault("pk", self.next)
self.next += 1
self.db[path][obj["pk"]] = obj
return obj
def _split(self, path):
for coll in self.db:
if path.startswith(coll):
rest = path[len(coll):].strip("/")
return coll, rest or None
raise AssertionError(path)
def _find(self, coll, key):
if coll == "core/applications/":
return next((a for a in self.db[coll].values() if a["slug"] == key), None)
return self.db[coll].get(int(key) if str(key).isdigit() else key)
def list(self, path, **q):
out = []
for o in self.db[path].values():
ok = True
for k, v in q.items():
field = k.replace("__iexact", "")
ok &= str(o.get(field)).lower() == str(v).lower() if "__iexact" in k else o.get(field) == v
if ok:
out.append(copy.deepcopy(o))
return out
def one(self, path, **q):
found = self.list(path, **q)
return found[0] if found else None
def get(self, path):
coll, key = self._split(path)
o = self._find(coll, key)
return copy.deepcopy(o) if o else None
def req(self, method, path, body=None):
self.calls.append((method, path))
coll, key = self._split(path)
if method == "POST":
obj = self.add(coll, dict(body))
if coll == "core/applications/":
obj["pbm_uuid"] = f"pbm-{obj['pk']}"
self.db["providers/proxy/"][obj["provider"]]["assigned_application_slug"] = obj["slug"]
return copy.deepcopy(obj)
obj = self._find(coll, key)
if method == "PATCH":
# ProxyProviderSerializer.validate sees only the request body.
if coll == "providers/proxy/" and body.get("mode", "proxy") == "proxy" \
and body.get("internal_host", "") == "":
raise AssertionError("400 internal_host")
obj.update(body)
return copy.deepcopy(obj)
if method == "DELETE":
del self.db[coll][obj["pk"]]
if coll == "core/applications/":
self.db["providers/proxy/"][obj["provider"]]["assigned_application_slug"] = None
for b in [b for b in self.db["policies/bindings/"].values() if b["target"] == obj["pbm_uuid"]]:
del self.db["policies/bindings/"][b["pk"]]
return None
raise AssertionError(method)
FLOWS = {c.DEFAULT_AUTHZ_FLOW: "az", "sso-authentication": "au", c.INVALIDATION_FLOW: "inv"}
class Reconcile(unittest.TestCase):
def setUp(self):
self.ak = FakeAuthentik()
self.g = c.gate_spec(LLAMA)
def gate(self, group=None):
g = dict(self.g, group=group)
c.ensure_gate(self.ak, g, 8080, FLOWS, "conn")
return g
def test_creates_owned_gate(self):
self.gate()
app = self.ak.get(f"core/applications/{self.g['name']}/")
self.assertEqual(app["meta_publisher"], c.MANAGER)
outpost = self.ak.one("outposts/instances/", name__iexact=self.g["name"])
self.assertEqual(outpost["providers"], [app["provider"]])
self.assertEqual(self.ak.list("policies/bindings/"), [])
def test_second_pass_is_a_noop(self):
self.gate()
before = len(self.ak.calls)
self.gate()
self.assertEqual(self.ak.calls[before:], [])
def test_flow_change_updates_provider(self):
self.gate()
self.g = dict(self.g, login_flow=None)
self.gate()
provider = self.ak.one("providers/proxy/", name__iexact=self.g["name"])
self.assertIsNone(provider["authentication_flow"])
def test_ignores_foreign_provider_names(self):
self.ak.add("providers/proxy/", {"name": "aaa-first-alphabetically", "assigned_application_slug": "x"})
self.gate()
self.assertEqual(len(self.ak.list("providers/proxy/")), 2)
def test_refuses_unowned_application(self):
self.ak.add("core/applications/", {"slug": self.g["name"], "meta_publisher": "", "provider": None})
with self.assertRaises(c.GateError):
self.gate()
def test_missing_group_is_a_gate_error(self):
with self.assertRaises(c.GateError):
self.gate(group="Nope")
def test_group_change_binds_before_unbinding(self):
self.ak.add("core/groups/", {"name": "A"})
self.ak.add("core/groups/", {"name": "B"})
self.gate(group="A")
bindings = self.ak.list("policies/bindings/")
self.assertEqual(len(bindings), 1)
policy = self.ak.one("policies/expression/", name=self.g["name"])
self.assertIn("'A'", policy["expression"])
self.gate(group="B")
policy = self.ak.one("policies/expression/", name=self.g["name"])
self.assertIn("'B'", policy["expression"])
self.assertEqual([b["pk"] for b in self.ak.list("policies/bindings/")], [bindings[0]["pk"]])
def test_dropping_group_removes_binding_after_nothing_new(self):
self.ak.add("core/groups/", {"name": "A"})
self.gate(group="A")
self.gate(group=None)
self.assertEqual(self.ak.list("policies/bindings/"), [])
self.assertIsNone(self.ak.one("policies/expression/", name=self.g["name"]))
def test_remove_gate_and_ownership_scan(self):
self.ak.add("providers/proxy/", {"name": "foreign", "assigned_application_slug": "foreign"})
self.ak.add("core/applications/", {"slug": "foreign", "meta_publisher": "", "provider": 1})
self.gate()
owned = list(c.owned_applications(self.ak))
self.assertEqual([a["slug"] for a in owned], [self.g["name"]])
c.remove_gate(self.ak, owned[0])
self.assertIsNone(self.ak.get(f"core/applications/{self.g['name']}/"))
self.assertEqual([p["name"] for p in self.ak.list("providers/proxy/")], ["foreign"])
self.assertEqual(self.ak.list("outposts/instances/"), [])
def test_adopts_provider_orphaned_by_interrupted_teardown(self):
self.gate()
app = self.ak.get(f"core/applications/{self.g['name']}/")
self.ak.req("DELETE", f"core/applications/{self.g['name']}/")
self.gate()
self.assertEqual(self.ak.get(f"core/applications/{self.g['name']}/")["provider"], app["provider"])
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1
FROM node:24-alpine@sha256:44fce696f38ba8c987c4db89954051000bbd05ffd8d8659ba15ed4a8602e937f
# Default to the latest Claude Code. Pinning a fixed version is the wrong
# default here: old releases eventually stop working against the API, so a
+3 -3
View File
@@ -23,7 +23,7 @@
# namespace, so can0 is only reachable with network_mode: host. That is also
# how the local MQTT broker on 127.0.0.1:1883 is reached. CAN_RAW sockets need
# no capabilities, so both run unprivileged.
FROM python:3.14-slim@sha256:a2b82f3c48559aa0a8446d9af49826b6e2b2016f4cd2afabfe6013ec53729170 AS build
FROM python:3.15-slim@sha256:2da676046630d7bf14c6764ae1143dc7fa0377a53f94bf5a642f0b214aca5bce AS build
# Pins live here, in the artifact, rather than in Ansible variables.
#
@@ -56,14 +56,14 @@ RUN git clone --quiet https://github.com/MyHomeMyData/E3onCAN.git /opt/e3oncan \
&& rm -rf /opt/e3oncan/.git
FROM python:3.14-slim@sha256:a2b82f3c48559aa0a8446d9af49826b6e2b2016f4cd2afabfe6013ec53729170
FROM python:3.15-slim@sha256:2da676046630d7bf14c6764ae1143dc7fa0377a53f94bf5a642f0b214aca5bce
ARG OPEN3E_REF=6553c10453a6bae1210908f38a5af2481a46a3e5
ARG E3ONCAN_REF=461dcbdf97e8d72d2edb9929c63b39382af6c3a1
LABEL org.opencontainers.image.title="heating-can" \
org.opencontainers.image.description="open3e + E3onCAN, Viessmann E3 CAN bus readers" \
org.opencontainers.image.source="https://gitea.renner.uno/k.renner/homelab" \
org.opencontainers.image.source="https://gitea.renner.uno/konairius/homelab" \
uno.renner.open3e.ref="${OPEN3E_REF}" \
uno.renner.e3oncan.ref="${E3ONCAN_REF}"
+20
View File
@@ -0,0 +1,20 @@
FROM nousresearch/hermes-agent:v0.22.2@sha256:36099926da316742423544e055d4734777105ffe6341873af5459b957d0c2919
USER root
RUN apt-get update \
&& apt-get install -y --no-install-recommends jq \
&& rm -rf /var/lib/apt/lists/*
COPY --from=ghcr.io/siderolabs/talosctl:v1.13.10@sha256:3116f64c97ddffb53bd3c5bf87c8614d99f04df4326177dbbb7702b5087f0e4d /talosctl /usr/local/bin/talosctl
COPY --from=alpine/kubectl:1.36.3@sha256:dae261106d5555008acede256b7102c8a6decde7209198434f5999f051460f57 /usr/local/bin/kubectl /usr/local/bin/kubectl
COPY --from=ghcr.io/fluxcd/flux-cli:v2.9.5@sha256:704d5529535570a495249b643159c93a9aa88ffca07813c974a54b7ba6edbd86 /usr/local/bin/flux /usr/local/bin/flux
COPY --from=quay.io/openbao/openbao:2.7.1@sha256:6d2b93856e3fcf7b18ad855a0b51eaba474dc8b79cf554379ea32034797d2acf /usr/bin/bao /usr/local/bin/bao
COPY --from=gitea/tea:0.16.0@sha256:af50a2060ce4d8fd76b7ebe75a3d6db4bb0c615c5bc8f2bdfd8ea83207c4752d /usr/bin/tea /usr/local/bin/tea
RUN talosctl version --client \
&& kubectl version --client \
&& flux version --client \
&& bao version \
&& tea --version \
&& jq --version
+1
View File
@@ -0,0 +1 @@
bin/
+1
View File
@@ -0,0 +1 @@
/bin/
+15
View File
@@ -0,0 +1,15 @@
# kuma-sync: reconciles KumaMonitor resources into Uptime Kuma. The unit tests
# (no tags, so no Docker and no envtest) gate the image: the final stage copies
# the binary out of this stage, so a failing test fails the build.
FROM golang:1.27@sha256:820153096fda4de6ac1b7194d12e416b7dd8f4e9a33d17e0345b111b945162d8 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go test ./...
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/kuma-sync ./cmd/kuma-sync
FROM gcr.io/distroless/static:nonroot@sha256:e2e927ec666bae08560abb3c55d0659eceabb657f56b6782ab500a9fc7f555e3
COPY --from=build /out/kuma-sync /kuma-sync
USER 65532
ENTRYPOINT ["/kuma-sync"]
+29
View File
@@ -0,0 +1,29 @@
CONTROLLER_GEN_VERSION := v0.23.0
ENVTEST_VERSION := v0.25.2
ENVTEST_K8S_VERSION := 1.37.0
BIN := $(CURDIR)/bin
CONTROLLER_GEN := $(BIN)/controller-gen
SETUP_ENVTEST := $(BIN)/setup-envtest
.PHONY: generate test test-envtest test-integration
$(CONTROLLER_GEN):
GOBIN=$(BIN) go install sigs.k8s.io/controller-tools/cmd/controller-gen@$(CONTROLLER_GEN_VERSION)
$(SETUP_ENVTEST):
GOBIN=$(BIN) go install sigs.k8s.io/controller-runtime/tools/setup-envtest@$(ENVTEST_VERSION)
generate: $(CONTROLLER_GEN)
$(CONTROLLER_GEN) object paths=./api/...
$(CONTROLLER_GEN) crd paths=./api/... output:crd:artifacts:config=config/crd
$(CONTROLLER_GEN) rbac:roleName=kuma-sync paths=./internal/... output:rbac:artifacts:config=config/rbac
test:
go test ./...
test-integration: $(SETUP_ENVTEST)
KUBEBUILDER_ASSETS="$$($(SETUP_ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(BIN)/k8s -p path)" go test -tags integration ./...
test-envtest:$(SETUP_ENVTEST)
KUBEBUILDER_ASSETS="$$($(SETUP_ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(BIN)/k8s -p path)" go test -tags envtest ./...
@@ -0,0 +1,15 @@
// +kubebuilder:object:generate=true
// +groupName=uptime.renner.uno
package v1alpha1
import (
"k8s.io/apimachinery/pkg/runtime/schema"
"sigs.k8s.io/controller-runtime/pkg/scheme"
)
var (
GroupVersion = schema.GroupVersion{Group: "uptime.renner.uno", Version: "v1alpha1"}
SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion}
AddToScheme = SchemeBuilder.AddToScheme
)
@@ -0,0 +1,132 @@
package v1alpha1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
const ConditionReady = "Ready"
// LocalRef references a KumaMonitor in the same namespace.
type LocalRef struct {
Name string `json:"name"`
}
// HTTPSpec configures http, keyword and json-query monitors.
type HTTPSpec struct {
// +kubebuilder:validation:MinLength=1
URL string `json:"url"`
// +kubebuilder:default=GET
Method string `json:"method,omitempty"`
// +kubebuilder:default={"200-299"}
AcceptedStatusCodes []string `json:"acceptedStatusCodes,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
Body string `json:"body,omitempty"`
MaxRedirects *int32 `json:"maxRedirects,omitempty"`
IgnoreTLS bool `json:"ignoreTLS,omitempty"`
ExpiryNotification bool `json:"expiryNotification,omitempty"`
}
type KeywordSpec struct {
// +kubebuilder:validation:MinLength=1
Keyword string `json:"keyword"`
Invert bool `json:"invert,omitempty"`
}
type JSONQuerySpec struct {
// JSONata expression.
// +kubebuilder:validation:MinLength=1
Expression string `json:"expression"`
// +kubebuilder:validation:Enum="==";"!=";"<";"<=";">";">=";contains
Operator string `json:"operator"`
Expected string `json:"expected"`
}
type DNSSpec struct {
// +kubebuilder:validation:MinLength=1
Hostname string `json:"hostname"`
// +kubebuilder:validation:Enum=A;AAAA;CNAME;MX;NS;PTR;SOA;SRV;TXT;CAA
ResolveType string `json:"resolveType"`
// +kubebuilder:default="1.1.1.1"
ResolveServer string `json:"resolveServer,omitempty"`
// +kubebuilder:validation:Minimum=1
// +kubebuilder:validation:Maximum=65535
// +kubebuilder:default=53
Port int32 `json:"port,omitempty"`
}
type PortSpec struct {
// +kubebuilder:validation:MinLength=1
Hostname string `json:"hostname"`
// +kubebuilder:validation:Minimum=1
// +kubebuilder:validation:Maximum=65535
Port int32 `json:"port"`
}
// +kubebuilder:validation:XValidation:rule="(self.type in ['http','keyword','json-query']) == has(self.http)",message="http is required for types http, keyword and json-query, and forbidden otherwise"
// +kubebuilder:validation:XValidation:rule="(self.type == 'keyword') == has(self.keyword)",message="keyword is required for type keyword and forbidden otherwise"
// +kubebuilder:validation:XValidation:rule="(self.type == 'json-query') == has(self.jsonQuery)",message="jsonQuery is required for type json-query and forbidden otherwise"
// +kubebuilder:validation:XValidation:rule="(self.type == 'dns') == has(self.dns)",message="dns is required for type dns and forbidden otherwise"
// +kubebuilder:validation:XValidation:rule="(self.type == 'port') == has(self.port)",message="port is required for type port and forbidden otherwise"
// +kubebuilder:validation:XValidation:rule="self.type != 'group' || !(has(self.http) || has(self.keyword) || has(self.jsonQuery) || has(self.dns) || has(self.port))",message="type group takes no type blocks"
type KumaMonitorSpec struct {
// +kubebuilder:validation:Enum=group;http;keyword;json-query;dns;port
Type string `json:"type"`
// +kubebuilder:validation:MinLength=1
DisplayName string `json:"displayName"`
ParentRef *LocalRef `json:"parentRef,omitempty"`
// Seconds.
// +kubebuilder:validation:Minimum=20
// +kubebuilder:default=60
Interval int32 `json:"interval,omitempty"`
// Seconds.
// +kubebuilder:validation:Minimum=20
// +kubebuilder:default=60
RetryInterval int32 `json:"retryInterval,omitempty"`
// +kubebuilder:validation:Minimum=0
// +kubebuilder:default=0
MaxRetries int32 `json:"maxRetries,omitempty"`
// Seconds.
// +kubebuilder:validation:Minimum=1
Timeout *int32 `json:"timeout,omitempty"`
Paused bool `json:"paused,omitempty"`
Description string `json:"description,omitempty"`
HTTP *HTTPSpec `json:"http,omitempty"`
Keyword *KeywordSpec `json:"keyword,omitempty"`
JSONQuery *JSONQuerySpec `json:"jsonQuery,omitempty"`
DNS *DNSSpec `json:"dns,omitempty"`
Port *PortSpec `json:"port,omitempty"`
}
type KumaMonitorStatus struct {
MonitorID int64 `json:"monitorID,omitempty"`
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
// +listType=map
// +listMapKey=type
Conditions []metav1.Condition `json:"conditions,omitempty"`
}
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:resource:path=kumamonitors,scope=Namespaced
// +kubebuilder:printcolumn:name="Type",type=string,JSONPath=`.spec.type`
// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status`
type KumaMonitor struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec KumaMonitorSpec `json:"spec"`
Status KumaMonitorStatus `json:"status,omitempty"`
}
// +kubebuilder:object:root=true
type KumaMonitorList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []KumaMonitor `json:"items"`
}
func init() {
SchemeBuilder.Register(&KumaMonitor{}, &KumaMonitorList{})
}
@@ -0,0 +1,198 @@
//go:build envtest
package v1alpha1
import (
"context"
"path/filepath"
"strings"
"testing"
"k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/envtest"
)
var k8s client.Client
func TestMain(m *testing.M) {
env := &envtest.Environment{
CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd")},
ErrorIfCRDPathMissing: true,
}
cfg, err := env.Start()
if err != nil {
panic(err)
}
scheme := runtime.NewScheme()
if err := AddToScheme(scheme); err != nil {
panic(err)
}
k8s, err = client.New(cfg, client.Options{Scheme: scheme})
if err != nil {
panic(err)
}
code := m.Run()
if err := env.Stop(); err != nil {
panic(err)
}
if code != 0 {
panic("tests failed")
}
}
func monitor(name string, spec KumaMonitorSpec) *KumaMonitor {
return &KumaMonitor{
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default"},
Spec: spec,
}
}
func TestValidKeyword(t *testing.T) {
m := monitor("valid-keyword", KumaMonitorSpec{
Type: "keyword",
DisplayName: "kw",
HTTP: &HTTPSpec{URL: "https://example.com"},
Keyword: &KeywordSpec{Keyword: "ok"},
})
if err := k8s.Create(context.Background(), m); err != nil {
t.Fatalf("expected accept, got %v", err)
}
}
func TestKeywordWithoutBlock(t *testing.T) {
m := monitor("kw-no-block", KumaMonitorSpec{
Type: "keyword",
DisplayName: "kw",
HTTP: &HTTPSpec{URL: "https://example.com"},
})
expectInvalid(t, m, "keyword is required for type keyword")
}
func expectInvalid(t *testing.T, m *KumaMonitor, wants ...string) {
t.Helper()
err := k8s.Create(context.Background(), m)
if err == nil || !errors.IsInvalid(err) {
t.Fatalf("expected invalid, got %v", err)
}
for _, w := range wants {
if !strings.Contains(err.Error(), w) {
t.Fatalf("error %q does not contain %q", err.Error(), w)
}
}
}
func TestGroupWithHTTP(t *testing.T) {
m := monitor("group-http", KumaMonitorSpec{
Type: "group",
DisplayName: "g",
HTTP: &HTTPSpec{URL: "https://example.com"},
})
expectInvalid(t, m, "type group takes no type blocks")
}
func TestDNSWithHTTP(t *testing.T) {
m := monitor("dns-http", KumaMonitorSpec{
Type: "dns",
DisplayName: "d",
HTTP: &HTTPSpec{URL: "https://example.com"},
DNS: &DNSSpec{Hostname: "example.com", ResolveType: "A"},
})
expectInvalid(t, m, "http is required for types http, keyword and json-query")
}
func TestPushTypeRejected(t *testing.T) {
m := monitor("push", KumaMonitorSpec{Type: "push", DisplayName: "p"})
expectInvalid(t, m, "spec.type", "Unsupported value")
}
func TestEmptyURLRejected(t *testing.T) {
m := monitor("empty-url", KumaMonitorSpec{
Type: "http",
DisplayName: "h",
HTTP: &HTTPSpec{URL: ""},
})
expectInvalid(t, m, "spec.http.url")
}
func TestNegativeMaxRetriesRejected(t *testing.T) {
m := monitor("neg-retries", KumaMonitorSpec{
Type: "http",
DisplayName: "h",
MaxRetries: -1,
HTTP: &HTTPSpec{URL: "https://example.com"},
})
expectInvalid(t, m, "spec.maxRetries")
}
func TestJSONQueryBadOperator(t *testing.T) {
m := monitor("jq-bad-op", KumaMonitorSpec{
Type: "json-query",
DisplayName: "jq",
HTTP: &HTTPSpec{URL: "https://example.com"},
JSONQuery: &JSONQuerySpec{Expression: "a", Operator: "=~", Expected: "b"},
})
expectInvalid(t, m, "spec.jsonQuery.operator", "Unsupported value")
}
func TestIntervalBelowMin(t *testing.T) {
m := monitor("interval-low", KumaMonitorSpec{
Type: "http",
DisplayName: "h",
Interval: 5,
HTTP: &HTTPSpec{URL: "https://example.com"},
})
expectInvalid(t, m, "spec.interval")
}
func TestDefaults(t *testing.T) {
ctx := context.Background()
m := monitor("defaults", KumaMonitorSpec{
Type: "http",
DisplayName: "h",
HTTP: &HTTPSpec{URL: "https://example.com"},
})
if err := k8s.Create(ctx, m); err != nil {
t.Fatal(err)
}
got := &KumaMonitor{}
if err := k8s.Get(ctx, client.ObjectKeyFromObject(m), got); err != nil {
t.Fatal(err)
}
if got.Spec.Interval != 60 {
t.Fatalf("interval = %v, want 60", got.Spec.Interval)
}
codes := got.Spec.HTTP.AcceptedStatusCodes
if len(codes) != 1 || codes[0] != "200-299" {
t.Fatalf("acceptedStatusCodes = %v", codes)
}
}
// The controller ignores updates that leave metadata.generation unchanged, so
// deleting a resource that has a finalizer must bump it.
func TestDeletionBumpsGeneration(t *testing.T) {
ctx := context.Background()
m := monitor("deletion-gen", KumaMonitorSpec{
Type: "http", DisplayName: "h", HTTP: &HTTPSpec{URL: "https://example.com"},
})
m.Finalizers = []string{"test/finalizer"}
if err := k8s.Create(ctx, m); err != nil {
t.Fatal(err)
}
before := m.Generation
if err := k8s.Delete(ctx, m); err != nil {
t.Fatal(err)
}
got := &KumaMonitor{}
if err := k8s.Get(ctx, client.ObjectKeyFromObject(m), got); err != nil {
t.Fatal(err)
}
if got.DeletionTimestamp.IsZero() {
t.Fatal("expected deletionTimestamp to be set")
}
if got.Generation <= before {
t.Fatalf("generation %d did not increase past %d on deletion", got.Generation, before)
}
}
@@ -0,0 +1,248 @@
//go:build !ignore_autogenerated
// Code generated by controller-gen. DO NOT EDIT.
package v1alpha1
import (
"k8s.io/apimachinery/pkg/apis/meta/v1"
runtime "k8s.io/apimachinery/pkg/runtime"
)
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *DNSSpec) DeepCopyInto(out *DNSSpec) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSSpec.
func (in *DNSSpec) DeepCopy() *DNSSpec {
if in == nil {
return nil
}
out := new(DNSSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *HTTPSpec) DeepCopyInto(out *HTTPSpec) {
*out = *in
if in.AcceptedStatusCodes != nil {
in, out := &in.AcceptedStatusCodes, &out.AcceptedStatusCodes
*out = make([]string, len(*in))
copy(*out, *in)
}
if in.Headers != nil {
in, out := &in.Headers, &out.Headers
*out = make(map[string]string, len(*in))
for key, val := range *in {
(*out)[key] = val
}
}
if in.MaxRedirects != nil {
in, out := &in.MaxRedirects, &out.MaxRedirects
*out = new(int32)
**out = **in
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HTTPSpec.
func (in *HTTPSpec) DeepCopy() *HTTPSpec {
if in == nil {
return nil
}
out := new(HTTPSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *JSONQuerySpec) DeepCopyInto(out *JSONQuerySpec) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new JSONQuerySpec.
func (in *JSONQuerySpec) DeepCopy() *JSONQuerySpec {
if in == nil {
return nil
}
out := new(JSONQuerySpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *KeywordSpec) DeepCopyInto(out *KeywordSpec) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KeywordSpec.
func (in *KeywordSpec) DeepCopy() *KeywordSpec {
if in == nil {
return nil
}
out := new(KeywordSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *KumaMonitor) DeepCopyInto(out *KumaMonitor) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
in.Spec.DeepCopyInto(&out.Spec)
in.Status.DeepCopyInto(&out.Status)
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KumaMonitor.
func (in *KumaMonitor) DeepCopy() *KumaMonitor {
if in == nil {
return nil
}
out := new(KumaMonitor)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *KumaMonitor) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *KumaMonitorList) DeepCopyInto(out *KumaMonitorList) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ListMeta.DeepCopyInto(&out.ListMeta)
if in.Items != nil {
in, out := &in.Items, &out.Items
*out = make([]KumaMonitor, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KumaMonitorList.
func (in *KumaMonitorList) DeepCopy() *KumaMonitorList {
if in == nil {
return nil
}
out := new(KumaMonitorList)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *KumaMonitorList) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *KumaMonitorSpec) DeepCopyInto(out *KumaMonitorSpec) {
*out = *in
if in.ParentRef != nil {
in, out := &in.ParentRef, &out.ParentRef
*out = new(LocalRef)
**out = **in
}
if in.Timeout != nil {
in, out := &in.Timeout, &out.Timeout
*out = new(int32)
**out = **in
}
if in.HTTP != nil {
in, out := &in.HTTP, &out.HTTP
*out = new(HTTPSpec)
(*in).DeepCopyInto(*out)
}
if in.Keyword != nil {
in, out := &in.Keyword, &out.Keyword
*out = new(KeywordSpec)
**out = **in
}
if in.JSONQuery != nil {
in, out := &in.JSONQuery, &out.JSONQuery
*out = new(JSONQuerySpec)
**out = **in
}
if in.DNS != nil {
in, out := &in.DNS, &out.DNS
*out = new(DNSSpec)
**out = **in
}
if in.Port != nil {
in, out := &in.Port, &out.Port
*out = new(PortSpec)
**out = **in
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KumaMonitorSpec.
func (in *KumaMonitorSpec) DeepCopy() *KumaMonitorSpec {
if in == nil {
return nil
}
out := new(KumaMonitorSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *KumaMonitorStatus) DeepCopyInto(out *KumaMonitorStatus) {
*out = *in
if in.Conditions != nil {
in, out := &in.Conditions, &out.Conditions
*out = make([]v1.Condition, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KumaMonitorStatus.
func (in *KumaMonitorStatus) DeepCopy() *KumaMonitorStatus {
if in == nil {
return nil
}
out := new(KumaMonitorStatus)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *LocalRef) DeepCopyInto(out *LocalRef) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LocalRef.
func (in *LocalRef) DeepCopy() *LocalRef {
if in == nil {
return nil
}
out := new(LocalRef)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *PortSpec) DeepCopyInto(out *PortSpec) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PortSpec.
func (in *PortSpec) DeepCopy() *PortSpec {
if in == nil {
return nil
}
out := new(PortSpec)
in.DeepCopyInto(out)
return out
}
@@ -0,0 +1,32 @@
package main
import (
"errors"
"fmt"
)
type config struct {
URL string
Username string
Password string
}
// loadConfig reads the Kuma connection settings from the environment. It
// names every missing variable.
func loadConfig(getenv func(string) string) (config, error) {
var cfg config
var missing []error
for _, v := range []struct {
name string
dst *string
}{
{"KUMA_URL", &cfg.URL},
{"KUMA_USERNAME", &cfg.Username},
{"KUMA_PASSWORD", &cfg.Password},
} {
if *v.dst = getenv(v.name); *v.dst == "" {
missing = append(missing, fmt.Errorf("missing required environment variable %s", v.name))
}
}
return cfg, errors.Join(missing...)
}
@@ -0,0 +1,38 @@
package main
import (
"strings"
"testing"
)
func TestLoadConfig(t *testing.T) {
env := func(m map[string]string) func(string) string {
return func(k string) string { return m[k] }
}
full := map[string]string{"KUMA_URL": "http://kuma", "KUMA_USERNAME": "u", "KUMA_PASSWORD": "p"}
cfg, err := loadConfig(env(full))
if err != nil || cfg != (config{URL: "http://kuma", Username: "u", Password: "p"}) {
t.Fatalf("cfg=%+v err=%v", cfg, err)
}
for _, name := range []string{"KUMA_URL", "KUMA_USERNAME", "KUMA_PASSWORD"} {
m := map[string]string{}
for k, v := range full {
if k != name {
m[k] = v
}
}
_, err := loadConfig(env(m))
if err == nil || !strings.Contains(err.Error(), name) {
t.Errorf("missing %s: err = %v, want it to name the variable", name, err)
}
}
_, err = loadConfig(env(nil))
for _, name := range []string{"KUMA_URL", "KUMA_USERNAME", "KUMA_PASSWORD"} {
if err == nil || !strings.Contains(err.Error(), name) {
t.Errorf("all missing: err = %v, want %s named", err, name)
}
}
}
+56
View File
@@ -0,0 +1,56 @@
// Command kuma-sync reconciles KumaMonitor resources into Uptime Kuma.
package main
import (
"fmt"
"io"
"os"
"time"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/log/zap"
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/app"
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/kumaclient"
)
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "kuma-sync:", err)
os.Exit(1)
}
}
func run() error {
cfg, err := loadConfig(os.Getenv)
if err != nil {
return err
}
ctrl.SetLogger(zap.New())
// The Kuma session lives as long as this context, so it is the signal
// context of the process.
ctx := ctrl.SetupSignalHandler()
kuma, err := kumaclient.NewBreml(ctx, cfg.URL, cfg.Username, cfg.Password)
if err != nil {
return fmt.Errorf("connect to Kuma: %w", err)
}
if c, ok := kuma.(io.Closer); ok {
defer c.Close()
}
mgr, err := app.NewManager(app.Options{
Config: ctrl.GetConfigOrDie(),
Kuma: kuma,
MetricsAddr: ":8080",
ProbeAddr: ":8081",
GCInterval: 5 * time.Minute,
OrphanGrace: time.Hour,
Resync: 5 * time.Minute,
})
if err != nil {
return err
}
return mgr.Start(ctx)
}
@@ -0,0 +1,295 @@
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.23.0
name: kumamonitors.uptime.renner.uno
spec:
group: uptime.renner.uno
names:
kind: KumaMonitor
listKind: KumaMonitorList
plural: kumamonitors
singular: kumamonitor
scope: Namespaced
versions:
- additionalPrinterColumns:
- jsonPath: .spec.type
name: Type
type: string
- jsonPath: .status.conditions[?(@.type=="Ready")].status
name: Ready
type: string
name: v1alpha1
schema:
openAPIV3Schema:
properties:
apiVersion:
description: |-
APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
type: string
kind:
description: |-
Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
type: string
metadata:
type: object
spec:
properties:
description:
type: string
displayName:
minLength: 1
type: string
dns:
properties:
hostname:
minLength: 1
type: string
port:
default: 53
format: int32
maximum: 65535
minimum: 1
type: integer
resolveServer:
default: 1.1.1.1
type: string
resolveType:
enum:
- A
- AAAA
- CNAME
- MX
- NS
- PTR
- SOA
- SRV
- TXT
- CAA
type: string
required:
- hostname
- resolveType
type: object
http:
description: HTTPSpec configures http, keyword and json-query monitors.
properties:
acceptedStatusCodes:
default:
- 200-299
items:
type: string
type: array
body:
type: string
expiryNotification:
type: boolean
headers:
additionalProperties:
type: string
type: object
ignoreTLS:
type: boolean
maxRedirects:
format: int32
type: integer
method:
default: GET
type: string
url:
minLength: 1
type: string
required:
- url
type: object
interval:
default: 60
description: Seconds.
format: int32
minimum: 20
type: integer
jsonQuery:
properties:
expected:
type: string
expression:
description: JSONata expression.
minLength: 1
type: string
operator:
enum:
- ==
- '!='
- <
- <=
- '>'
- '>='
- contains
type: string
required:
- expected
- expression
- operator
type: object
keyword:
properties:
invert:
type: boolean
keyword:
minLength: 1
type: string
required:
- keyword
type: object
maxRetries:
default: 0
format: int32
minimum: 0
type: integer
parentRef:
description: LocalRef references a KumaMonitor in the same namespace.
properties:
name:
type: string
required:
- name
type: object
paused:
type: boolean
port:
properties:
hostname:
minLength: 1
type: string
port:
format: int32
maximum: 65535
minimum: 1
type: integer
required:
- hostname
- port
type: object
retryInterval:
default: 60
description: Seconds.
format: int32
minimum: 20
type: integer
timeout:
description: Seconds.
format: int32
minimum: 1
type: integer
type:
enum:
- group
- http
- keyword
- json-query
- dns
- port
type: string
required:
- displayName
- type
type: object
x-kubernetes-validations:
- message: http is required for types http, keyword and json-query, and
forbidden otherwise
rule: (self.type in ['http','keyword','json-query']) == has(self.http)
- message: keyword is required for type keyword and forbidden otherwise
rule: (self.type == 'keyword') == has(self.keyword)
- message: jsonQuery is required for type json-query and forbidden otherwise
rule: (self.type == 'json-query') == has(self.jsonQuery)
- message: dns is required for type dns and forbidden otherwise
rule: (self.type == 'dns') == has(self.dns)
- message: port is required for type port and forbidden otherwise
rule: (self.type == 'port') == has(self.port)
- message: type group takes no type blocks
rule: self.type != 'group' || !(has(self.http) || has(self.keyword)
|| has(self.jsonQuery) || has(self.dns) || has(self.port))
status:
properties:
conditions:
items:
description: Condition contains details for one aspect of the current
state of this API Resource.
properties:
lastTransitionTime:
description: |-
lastTransitionTime is the last time the condition transitioned from one status to another.
This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
format: date-time
type: string
message:
description: |-
message is a human readable message indicating details about the transition.
This may be an empty string.
maxLength: 32768
type: string
observedGeneration:
description: |-
observedGeneration represents the .metadata.generation that the condition was set based upon.
For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
with respect to the current state of the instance.
format: int64
minimum: 0
type: integer
reason:
description: |-
reason contains a programmatic identifier indicating the reason for the condition's last transition.
Producers of specific condition types may define expected values and meanings for this field,
and whether the values are considered a guaranteed API.
The value should be a CamelCase string.
This field may not be empty.
maxLength: 1024
minLength: 1
pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
type: string
status:
description: status of the condition, one of True, False, Unknown.
enum:
- "True"
- "False"
- Unknown
type: string
type:
description: type of condition in CamelCase or in foo.example.com/CamelCase.
maxLength: 316
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
type: string
required:
- lastTransitionTime
- message
- reason
- status
- type
type: object
type: array
x-kubernetes-list-map-keys:
- type
x-kubernetes-list-type: map
monitorID:
format: int64
type: integer
observedGeneration:
format: int64
type: integer
type: object
required:
- spec
type: object
served: true
storage: true
subresources:
status: {}
+43
View File
@@ -0,0 +1,43 @@
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: kuma-sync
rules:
- apiGroups:
- ""
resources:
- events
verbs:
- create
- patch
- apiGroups:
- ""
resources:
- namespaces
verbs:
- get
- apiGroups:
- uptime.renner.uno
resources:
- kumamonitors
verbs:
- get
- list
- patch
- update
- watch
- apiGroups:
- uptime.renner.uno
resources:
- kumamonitors/finalizers
verbs:
- update
- apiGroups:
- uptime.renner.uno
resources:
- kumamonitors/status
verbs:
- get
- patch
- update
+123
View File
@@ -0,0 +1,123 @@
module gitea.renner.uno/konairius/homelab/kuma-sync
go 1.27
require (
github.com/breml/go-uptime-kuma-client v0.6.2
github.com/testcontainers/testcontainers-go v0.44.0
k8s.io/api v0.37.1
k8s.io/apimachinery v0.37.1
k8s.io/client-go v0.37.1
k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3
sigs.k8s.io/controller-runtime v0.25.2
)
require (
dario.cat/mergo v1.0.2 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v0.2.1 // indirect
github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.7.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/ebitengine/purego v0.10.1 // indirect
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
github.com/evanphx/json-patch/v5 v5.9.11 // indirect
github.com/felixge/httpsnoop v1.1.0 // indirect
github.com/fsnotify/fsnotify v1.10.1 // indirect
github.com/fxamacker/cbor/v2 v2.9.1 // indirect
github.com/go-logr/logr v1.4.4 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-logr/zapr v1.3.0 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-openapi/jsonpointer v1.0.0 // indirect
github.com/go-openapi/jsonreference v1.0.0 // indirect
github.com/go-openapi/swag v0.27.1 // indirect
github.com/go-openapi/swag/cmdutils v0.27.1 // indirect
github.com/go-openapi/swag/conv v0.27.1 // indirect
github.com/go-openapi/swag/fileutils v0.27.1 // indirect
github.com/go-openapi/swag/jsonutils v0.27.1 // indirect
github.com/go-openapi/swag/loading v0.27.1 // indirect
github.com/go-openapi/swag/mangling v0.27.1 // indirect
github.com/go-openapi/swag/netutils v0.27.1 // indirect
github.com/go-openapi/swag/pools v0.27.1 // indirect
github.com/go-openapi/swag/stringutils v0.27.1 // indirect
github.com/go-openapi/swag/typeutils v0.27.1 // indirect
github.com/go-openapi/swag/yamlutils v0.27.1 // indirect
github.com/google/gnostic-models v0.7.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.19.2 // indirect
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/maldikhan/go.socket.io v0.1.1 // indirect
github.com/maniartech/signals v1.3.1 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.2.0 // indirect
github.com/moby/moby/api v1.55.0 // indirect
github.com/moby/moby/client v0.5.0 // indirect
github.com/moby/patternmatcher v0.6.1 // indirect
github.com/moby/sys/sequential v0.7.0 // indirect
github.com/moby/sys/user v0.4.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/pquerna/otp v1.5.0 // indirect
github.com/prometheus/client_golang v1.24.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.70.0 // indirect
github.com/prometheus/procfs v0.21.1 // indirect
github.com/shirou/gopsutil/v4 v4.26.6 // indirect
github.com/sirupsen/logrus v1.10.1 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/stretchr/testify v1.12.1 // indirect
github.com/tklauser/go-sysconf v0.4.0 // indirect
github.com/tklauser/numcpus v0.12.0 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 // indirect
go.opentelemetry.io/otel v1.45.0 // indirect
go.opentelemetry.io/otel/metric v1.45.0 // indirect
go.opentelemetry.io/otel/trace v1.45.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.28.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/crypto v0.55.0 // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/term v0.45.0 // indirect
golang.org/x/text v0.41.0 // indirect
golang.org/x/time v0.15.0 // indirect
gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
k8s.io/apiextensions-apiserver v0.37.1 // indirect
k8s.io/klog/v2 v2.140.0 // indirect
k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad // indirect
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
sigs.k8s.io/randfill v1.0.0 // indirect
sigs.k8s.io/structured-merge-diff/v6 v6.4.2 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
)
replace github.com/maldikhan/go.socket.io => github.com/breml/go.socket.io v0.0.0-20260516193936-e70410c8cd31
+312
View File
@@ -0,0 +1,312 @@
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw=
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/breml/go-uptime-kuma-client v0.6.2 h1:C2Z56OFLNCHHeEkQ6T8B7Nuqkf8SYtPy3Nb1YhFMCUA=
github.com/breml/go-uptime-kuma-client v0.6.2/go.mod h1:LQkdwVs3TzTQZ92WvPIGgHoAC6rUEJkn+8ULkc51uJ8=
github.com/breml/go.socket.io v0.0.0-20260516193936-e70410c8cd31 h1:l5fVxSzbJHJ1jcLxmcOLVY0uxZFfB1yuztPJVtMfLRg=
github.com/breml/go.socket.io v0.0.0-20260516193936-e70410c8cd31/go.mod h1:H1EoWDJvqfV2WryM8F9og6ZkH7NsZDlHr0BRkKb58tY=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/containerd/continuity v0.4.5 h1:ZRoN1sXq9u7V6QoHMcVWGhOwDFqZ4B9i5H6un1Wh0x4=
github.com/containerd/continuity v0.4.5/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/cli v27.4.1+incompatible h1:VzPiUlRJ/xh+otB75gva3r05isHMo5wXDfPRi5/b4hI=
github.com/docker/cli v27.4.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/docker v27.1.1+incompatible h1:hO/M4MtV36kzKldqnA37IWhebRA+LnqqcqDja6kVaKY=
github.com/docker/docker v27.1.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k=
github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ=
github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU=
github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM=
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ=
github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ=
github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s=
github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y=
github.com/go-openapi/jsonreference v1.0.0 h1:jlmTr6torcd1YgDQvSfNmRtKzYDO4FGBkrAdlAVWnpY=
github.com/go-openapi/jsonreference v1.0.0/go.mod h1:jtwdyGbJk0Xhe5Y+rwtglQP6Sb1WZST4rT32LWB+sv0=
github.com/go-openapi/swag v0.27.1 h1:VotvOLWW8q/EAxB0YdsBBGC8XYyeL1YwBj2ungAGPNg=
github.com/go-openapi/swag v0.27.1/go.mod h1:GTkJPwHfhJp6MWr4/rCh64HVI3Ofu+tcsbfjfHmTxpE=
github.com/go-openapi/swag/cmdutils v0.27.1 h1:I7sYqaWVl5mq0NEmNQkAmFDyNin9ufvMX/p2zwtQaOE=
github.com/go-openapi/swag/cmdutils v0.27.1/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM=
github.com/go-openapi/swag/conv v0.27.1 h1:8wi9ZG+olmY1wXphl93EWniPtbSPkXM/feH7FgjsvrU=
github.com/go-openapi/swag/conv v0.27.1/go.mod h1:QbqMivkpKhC3g1B1GGGOJ6ANewI3S62dbzYu3Duowqs=
github.com/go-openapi/swag/fileutils v0.27.1 h1:QQqBSoi5mW4XpU85nS0mLcA+zAE6vLzrb0QkmLKf9oM=
github.com/go-openapi/swag/fileutils v0.27.1/go.mod h1:VvJFZLTZS0AI854gEQz5tk7dBESdLjiNUMSZ/th2ry8=
github.com/go-openapi/swag/jsonutils v0.27.1 h1:SVgK3i4USzCU5mibOOS/l4ea2h9UQXy7J7RNLTjuXjU=
github.com/go-openapi/swag/jsonutils v0.27.1/go.mod h1:tdlEpZqdcQ17uj6J4YdK9vd8It5qWMwjWXOs0tjpRlk=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.1 h1:mJu3COL9WEaZVp/Kf2PRMi7tPszPEJfSr/OO75ynCs8=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.1/go.mod h1:mofwUWx70wvskwESqRJ//k/9kURmCgyJl5m5Ppoh5kY=
github.com/go-openapi/swag/loading v0.27.1 h1:/DxUgDXKbBX4bcn7r9uEXfJyzN5XpiJmZplzQTjrRCY=
github.com/go-openapi/swag/loading v0.27.1/go.mod h1:jvGh3iA2+zyUUycB5fgJWzeHnhrpvGnJJM0RVE9ZShE=
github.com/go-openapi/swag/mangling v0.27.1 h1:yC9D0HyUE8gbP+BfmGx9+AA89ikwZTMjESK3OnnoaqA=
github.com/go-openapi/swag/mangling v0.27.1/go.mod h1:jtBE2+V+3pILxOR7Vgce+Cwp6A2PgZbvVqfNntbVs0w=
github.com/go-openapi/swag/netutils v0.27.1 h1:mICMFoS82F5TZ4Zy3cqmcQk+BFeCp3Uyq3Np7GI0/qU=
github.com/go-openapi/swag/netutils v0.27.1/go.mod h1:J+WYyFMLtvtCGqa6jLv+YNUmIKI3ZRQRrvfNDMoQoEQ=
github.com/go-openapi/swag/pools v0.27.1 h1:9LeadcMyb2GJCbXX5hVQDbZ2Lq9TL4dCs/nx1j5DO0E=
github.com/go-openapi/swag/pools v0.27.1/go.mod h1:kVQefhSK5RWuRe7BXsL8htgBPAMpN7HDGpGEknqugeE=
github.com/go-openapi/swag/stringutils v0.27.1 h1:ZXePZ0r2p1qSjo8tD3Un4vFj8+FqlCkczxDrJIhYUp8=
github.com/go-openapi/swag/stringutils v0.27.1/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM=
github.com/go-openapi/swag/typeutils v0.27.1 h1:KSTdFlfnse4r6dP9IrEnwMldjE+zs71UeEB3//PtVXc=
github.com/go-openapi/swag/typeutils v0.27.1/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ=
github.com/go-openapi/swag/yamlutils v0.27.1 h1:ftxv6xvXb1E3zohUc+okZ9nSqNb9StQX/FXnKZ98sQA=
github.com/go-openapi/swag/yamlutils v0.27.1/go.mod h1:bnxFIB1qewGRiZHypXGZ3fNgf13/0HfRgnS/iZBDrOo=
github.com/go-openapi/testify/enable/yaml/v2 v2.6.0 h1:gGHwAJ0R/5jU8BEGDbfRNR3hL68dAVi84WuOApp29B0=
github.com/go-openapi/testify/enable/yaml/v2 v2.6.0/go.mod h1:tY+St1SGq4NFl0QIqdTY4aEdbChAHxhyB77XQi9iJCo=
github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug=
github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak=
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/maniartech/signals v1.3.1 h1:pT3dK6x5Un+B6L3ZLAKygEe+L49TClPreyT08vOoHXY=
github.com/maniartech/signals v1.3.1/go.mod h1:AbE8Yy9ZjKCWNU/VhQ+0Ea9KOaTWHp6aOfdLBe5m1iM=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc=
github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/moby/moby/client v0.5.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc=
github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s=
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/sequential v0.7.0 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31zj/8=
github.com/moby/sys/sequential v0.7.0/go.mod h1:NfSTAp6V3fw4tmkD62PEcOKeZKquXT8VKCkf7aVR79o=
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/onsi/ginkgo/v2 v2.27.4 h1:fcEcQW/A++6aZAZQNUmNjvA9PSOzefMJBerHJ4t8v8Y=
github.com/onsi/ginkgo/v2 v2.27.4/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
github.com/onsi/gomega v1.39.0 h1:y2ROC3hKFmQZJNFeGAMeHZKkjBL65mIZcvrLQBF9k6Q=
github.com/onsi/gomega v1.39.0/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/opencontainers/runc v1.2.3 h1:fxE7amCzfZflJO2lHXf4y/y8M1BoAqp+FVmG19oYB80=
github.com/opencontainers/runc v1.2.3/go.mod h1:nSxcWUydXrsBZVYNSkTjoQ/N6rcyTtn+1SD5D4+kRIM=
github.com/ory/dockertest/v3 v3.12.0 h1:3oV9d0sDzlSQfHtIaB5k6ghUCVMVLpAY8hwrqoCyRCw=
github.com/ory/dockertest/v3 v3.12.0/go.mod h1:aKNDTva3cp8dwOWwb9cWuX84aH5akkxXRvO7KCwWVjE=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/prometheus/client_golang v1.24.0 h1:5XStIklKuAtJSNpdD3s8XJj/Yv78IQmE1kbNk87JrAI=
github.com/prometheus/client_golang v1.24.0/go.mod h1:QcsNdotprC2nS4BTM2ucbcqxd2CeXTEa9jW7zHO9iDE=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.70.0 h1:bcpru3tWPVnxGnETLgOV5jbp/JRXgYEyv65CuBLAMMI=
github.com/prometheus/common v0.70.0/go.mod h1:S/SFasQmgGiYH6C81LKCtYa8QACgthGg5zxL2udV7SY=
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs=
github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/sirupsen/logrus v1.10.1 h1:xi4336Zh11WpU14fXR6I67V3yaTPQYwRx2WEtHbRg4Q=
github.com/sirupsen/logrus v1.10.1/go.mod h1:vsQHnG7xzNsxk3NrwboUiWPnIC3dmbjcGPykD7+tiHk=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/testcontainers/testcontainers-go v0.44.0 h1:/Fwh6HY1mIikhnm9e7HwoxGycx0lzRAE0f5VQpjFxzI=
github.com/testcontainers/testcontainers-go v0.44.0/go.mod h1:IcnwQrYTO86xHXu5bvMaBH7ATlbS3Qn1M1QWW3c66rE=
github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU=
github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI=
github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4=
github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo=
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 h1:LMuyCAyfalSjDyjdC65nK6N0zoTT63+E/u95X0JovZI=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0/go.mod h1:085m8qbm4hgc8rZWGDEa4vmyyo2c3nPxUslYUKUIU04=
go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU=
go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ=
go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M=
go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s=
go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw=
go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA=
go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o=
go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA=
go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag=
go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw=
gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY=
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI=
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo=
gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
k8s.io/api v0.37.1 h1:l6N77U7tjwB5L056bgrBTJIEdevac/naBZ3iSvDNfpM=
k8s.io/api v0.37.1/go.mod h1:zSlbB1YpJ1YQlFVQy20UYll81UJSJJUMLhkhvg6Z78M=
k8s.io/apiextensions-apiserver v0.37.1 h1:7fIQG8eThDSTVYBWg/DOpI8v5wYfKCo0N8/TDMjj+zY=
k8s.io/apiextensions-apiserver v0.37.1/go.mod h1:1Q3ujWwHgnhBoGsdmGORflwK1sDFmuaqH1OsMDWk2Gs=
k8s.io/apimachinery v0.37.1 h1:hGCYyvKHCwtwMitj2vU4vYx0Z16N9GyZk9BBnz0wDAE=
k8s.io/apimachinery v0.37.1/go.mod h1:jF84AyUi/IRIXRot5f+lm6MpxoWI+F1XgjaMmwCdTFw=
k8s.io/client-go v0.37.1 h1:QTv/5ha4jAHtW9qxxVBkQVFBRDb4jHfFopQqqMdc+wM=
k8s.io/client-go v0.37.1/go.mod h1:dnAPtTnCNY38Ho04D2KdY1F4IKausa9UbqaAZKl60SY=
k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc=
k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0=
k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad h1:oXImqH8mQNk7PmvzKhmN3ddJoY6OnyM225MXwGHPm0A=
k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad/go.mod h1:0/mqHCVhlumdJ3BhCfnjSZQE037nAhNodh1/hK0T8/I=
k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 h1:jVkFFVfXdXP74B/zbO3hM3hpSFD0xvhQ5U686DPurkE=
k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3/go.mod h1:M2s5JB1lIYP3jzZdorPLHXIPJzt9vv2muW5a6L9DtNM=
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
sigs.k8s.io/controller-runtime v0.25.2 h1:bEkK3PVOIVK9X8QWLGVhJgmFc++47vfT6wakSzAOLsQ=
sigs.k8s.io/controller-runtime v0.25.2/go.mod h1:4QqLdT6z/L6Olj8JJCtvztid4/fnIiYsfaTFScegctc=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
sigs.k8s.io/structured-merge-diff/v6 v6.4.2 h1:qdOxHwrl2Kaag1aQEarlYcOA9vSyGCp3CIki3aW8c4Q=
sigs.k8s.io/structured-merge-diff/v6 v6.4.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
+103
View File
@@ -0,0 +1,103 @@
// Package app wires the controller-runtime manager shared by the binary and
// the end-to-end tests.
package app
import (
"context"
"errors"
"fmt"
"net/http"
"time"
"k8s.io/apimachinery/pkg/runtime"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/rest"
"k8s.io/utils/ptr"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/config"
"sigs.k8s.io/controller-runtime/pkg/healthz"
"sigs.k8s.io/controller-runtime/pkg/manager"
metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
v1 "gitea.renner.uno/konairius/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/controller"
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/kumaclient"
)
// Options configures NewManager.
type Options struct {
Config *rest.Config
Kuma kumaclient.Client
// MetricsAddr and ProbeAddr are bind addresses; "0" and "" disable them.
MetricsAddr string
ProbeAddr string
// GCInterval is the orphan collection period; 0 means 5 minutes.
GCInterval time.Duration
// OrphanGrace is how long a monitor must be seen without its resource
// before the GC deletes it. Production uses 1h; 0 only requires two runs.
OrphanGrace time.Duration
// Resync is how often a healthy resource is reconciled again, which is
// what repairs drift made in Kuma; 0 means 5 minutes.
Resync time.Duration
// SkipNameValidation allows several managers in one process, as in tests
// that restart the manager.
SkipNameValidation bool
}
// NewManager builds the manager with the reconciler and the orphan GC.
// There is no leader election: a single replica runs.
func NewManager(o Options) (manager.Manager, error) {
scheme := runtime.NewScheme()
// Core types: the GC reads Namespaces.
if err := clientgoscheme.AddToScheme(scheme); err != nil {
return nil, err
}
if err := v1.AddToScheme(scheme); err != nil {
return nil, err
}
mopts := ctrl.Options{
Scheme: scheme,
Metrics: metricsserver.Options{BindAddress: o.MetricsAddr},
HealthProbeBindAddress: o.ProbeAddr,
LeaderElection: false,
}
if o.SkipNameValidation {
mopts.Controller = config.Controller{SkipNameValidation: ptr.To(true)}
}
mgr, err := ctrl.NewManager(o.Config, mopts)
if err != nil {
return nil, fmt.Errorf("create manager: %w", err)
}
if err := (&controller.Reconciler{Client: mgr.GetClient(), Kuma: o.Kuma, Resync: o.Resync}).SetupWithManager(mgr); err != nil {
return nil, fmt.Errorf("set up reconciler: %w", err)
}
if err := mgr.Add(&controller.GC{
Client: mgr.GetClient(),
Kuma: o.Kuma,
Interval: o.GCInterval,
OrphanGrace: o.OrphanGrace,
Cache: mgr.GetCache(),
APIReader: mgr.GetAPIReader(),
}); err != nil {
return nil, fmt.Errorf("add GC: %w", err)
}
if err := mgr.AddHealthzCheck("ping", healthz.Ping); err != nil {
return nil, err
}
if err := mgr.AddReadyzCheck("cache-synced", func(req *http.Request) error {
ctx, cancel := context.WithTimeout(req.Context(), time.Second)
defer cancel()
if !mgr.GetCache().WaitForCacheSync(ctx) {
return errors.New("cache not synced")
}
return nil
}); err != nil {
return nil, err
}
return mgr, nil
}
@@ -0,0 +1,514 @@
//go:build integration
package controller_test
import (
"context"
"fmt"
"io"
"path/filepath"
"slices"
"sync"
"testing"
"time"
kuma "github.com/breml/go-uptime-kuma-client"
"github.com/breml/go-uptime-kuma-client/monitor"
"github.com/breml/go-uptime-kuma-client/notification"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/util/retry"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/envtest"
v1 "gitea.renner.uno/konairius/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/app"
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/kumaclient"
)
const (
user = "admin"
pass = "Sup3r-Secret-Passw0rd!"
gcInterval = 2 * time.Second
resync = 2 * time.Second
)
// e2e is a real API server (envtest, CRD installed) next to a real Kuma.
type e2e struct {
t *testing.T
k8s client.Client
url string
raw *kuma.Client // library session used to change Kuma behind the controller's back
census kumaclient.Client // separate session used only to count monitors
}
func startKuma(t *testing.T) string {
t.Helper()
ctx := context.Background()
ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: testcontainers.ContainerRequest{
Image: "louislam/uptime-kuma:2.5.6-slim",
ExposedPorts: []string{"3001/tcp"},
Env: map[string]string{"UPTIME_KUMA_DB_TYPE": "sqlite"},
WaitingFor: wait.ForHTTP("/api/entry-page").WithPort("3001/tcp").
WithStatusCodeMatcher(func(s int) bool { return s == 200 }).
WithStartupTimeout(3 * time.Minute),
},
Started: true,
})
if err != nil {
t.Fatalf("start kuma: %v", err)
}
t.Cleanup(func() { _ = ctr.Terminate(context.Background()) })
url, err := ctr.PortEndpoint(ctx, "3001/tcp", "http")
if err != nil {
t.Fatal(err)
}
return url
}
func newE2E(t *testing.T) (*e2e, *envtest.Environment) {
t.Helper()
env := &envtest.Environment{
CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd")},
ErrorIfCRDPathMissing: true,
}
cfg, err := env.Start()
if err != nil {
t.Fatalf("start envtest: %v", err)
}
t.Cleanup(func() { _ = env.Stop() })
scheme := runtime.NewScheme()
if err := v1.AddToScheme(scheme); err != nil {
t.Fatal(err)
}
k8s, err := client.New(cfg, client.Options{Scheme: scheme})
if err != nil {
t.Fatal(err)
}
url := startKuma(t)
// The first login creates the admin account.
raw, err := kuma.New(t.Context(), url, user, pass, kuma.WithAutosetup(), kuma.WithConnectTimeout(2*time.Minute))
if err != nil {
t.Fatalf("kuma setup: %v", err)
}
t.Cleanup(func() { _ = raw.Disconnect() })
census, err := kumaclient.NewBreml(t.Context(), url, user, pass)
if err != nil {
t.Fatal(err)
}
if c, ok := census.(io.Closer); ok {
t.Cleanup(func() { _ = c.Close() })
}
return &e2e{t: t, k8s: k8s, url: url, raw: raw, census: census}, env
}
// manager is one run of the controller, built from scratch like a pod start.
type running struct {
cancel context.CancelFunc
done chan error
kuma io.Closer
once sync.Once
}
func (f *e2e) start(env *envtest.Environment) *running {
f.t.Helper()
ctx, cancel := context.WithCancel(f.t.Context())
kc, err := kumaclient.NewBreml(ctx, f.url, user, pass)
if err != nil {
cancel()
f.t.Fatalf("new kuma client: %v", err)
}
mgr, err := app.NewManager(app.Options{
Config: env.Config,
Kuma: kc,
MetricsAddr: "0",
GCInterval: gcInterval,
OrphanGrace: 3 * time.Second,
Resync: resync,
SkipNameValidation: true,
})
if err != nil {
cancel()
f.t.Fatalf("new manager: %v", err)
}
r := &running{cancel: cancel, done: make(chan error, 1)}
if c, ok := kc.(io.Closer); ok {
r.kuma = c
}
go func() { r.done <- mgr.Start(ctx) }()
// Runs before the envtest and Kuma teardown, also after an early Fatal.
f.t.Cleanup(func() { f.stop(r) })
return r
}
// stop cancels the manager and waits for it to return. It is idempotent.
func (f *e2e) stop(r *running) {
f.t.Helper()
r.once.Do(func() {
r.cancel()
select {
case err := <-r.done:
if err != nil {
f.t.Errorf("manager returned %v", err)
}
case <-time.After(30 * time.Second):
f.t.Errorf("manager did not stop")
}
if r.kuma != nil {
_ = r.kuma.Close()
}
})
}
func eventually(t *testing.T, timeout time.Duration, what string, cond func() (bool, string)) {
t.Helper()
deadline := time.Now().Add(timeout)
for {
ok, detail := cond()
if ok {
return
}
if time.Now().After(deadline) {
t.Fatalf("timed out after %v waiting for %s: %s", timeout, what, detail)
}
time.Sleep(100 * time.Millisecond)
}
}
func (f *e2e) owned() []kumaclient.Monitor {
f.t.Helper()
list, err := f.census.ListOwned(f.t.Context())
if err != nil {
f.t.Fatalf("census list: %v", err)
}
return list
}
func byKey(list []kumaclient.Monitor) map[string]kumaclient.Monitor {
out := map[string]kumaclient.Monitor{}
for _, m := range list {
out[m.OwnerKey] = m
}
return out
}
func group(name, parent string) *v1.KumaMonitor {
km := &v1.KumaMonitor{
ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: name},
Spec: v1.KumaMonitorSpec{Type: "group", DisplayName: name},
}
if parent != "" {
km.Spec.ParentRef = &v1.LocalRef{Name: parent}
}
return km
}
func dns(name, parent string) *v1.KumaMonitor {
km := &v1.KumaMonitor{
ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: name},
Spec: v1.KumaMonitorSpec{
Type: "dns", DisplayName: name,
DNS: &v1.DNSSpec{Hostname: "example.com", ResolveType: "A"},
},
}
if parent != "" {
km.Spec.ParentRef = &v1.LocalRef{Name: parent}
}
return km
}
func (f *e2e) apply(objs ...*v1.KumaMonitor) {
f.t.Helper()
for _, o := range objs {
if err := f.k8s.Create(f.t.Context(), o); err != nil {
f.t.Fatalf("create %s: %v", o.Name, err)
}
}
}
func TestE2EGroupNesting(t *testing.T) {
f, env := newE2E(t)
f.start(env)
f.apply(group("outer", ""), group("inner", "outer"), dns("probe", "inner"))
eventually(t, 60*time.Second, "outer > inner > probe in Kuma", func() (bool, string) {
m := byKey(f.owned())
outer, inner, probe := m["default/outer"], m["default/inner"], m["default/probe"]
if outer.ID == 0 || inner.ID == 0 || probe.ID == 0 {
return false, "not all three monitors exist yet"
}
if outer.Parent != nil {
return false, "outer must have no parent"
}
if inner.Parent == nil || *inner.Parent != outer.ID {
return false, "inner is not under outer"
}
if probe.Parent == nil || *probe.Parent != inner.ID {
return false, "probe is not under inner"
}
return true, ""
})
}
func TestE2ERestartNoDuplicates(t *testing.T) {
f, env := newE2E(t)
r := f.start(env)
f.apply(group("g", ""), dns("probe", "g"))
eventually(t, 30*time.Second, "both monitors in Kuma", func() (bool, string) {
return len(f.owned()) == 2, "monitors missing"
})
before := byKey(f.owned())
f.stop(r)
f.start(env)
// A spec change after the restart must land on the same Kuma monitor: the
// new manager reconciled and adopted it instead of creating another.
key := types.NamespacedName{Namespace: "default", Name: "g"}
if err := retry.RetryOnConflict(retry.DefaultRetry, func() error {
km := &v1.KumaMonitor{}
if err := f.k8s.Get(t.Context(), key, km); err != nil {
return err
}
km.Spec.DisplayName = "g renamed"
return f.k8s.Update(t.Context(), km)
}); err != nil {
t.Fatal(err)
}
eventually(t, 30*time.Second, "rename reflected on the original monitor", func() (bool, string) {
m := byKey(f.owned())["default/g"]
if m.ID != before["default/g"].ID {
return false, fmt.Sprintf("default/g has ID %d, want %d", m.ID, before["default/g"].ID)
}
return m.Name == "g renamed", "name is " + m.Name
})
// Two GC intervals and then some, checked continuously rather than once.
deadline := time.Now().Add(2*gcInterval + 3*time.Second)
for time.Now().Before(deadline) {
after := byKey(f.owned())
if len(after) != 2 {
t.Fatalf("monitor count changed after restart: %d", len(after))
}
for k, m := range before {
if after[k].ID != m.ID {
t.Fatalf("%s changed ID %d -> %d", k, m.ID, after[k].ID)
}
}
time.Sleep(500 * time.Millisecond)
}
// Both resources must still be reconciled by the new manager.
for _, name := range []string{"g", "probe"} {
km := &v1.KumaMonitor{}
if err := f.k8s.Get(t.Context(), types.NamespacedName{Namespace: "default", Name: name}, km); err != nil {
t.Fatal(err)
}
if km.Status.MonitorID != before["default/"+name].ID {
t.Errorf("%s status.monitorID = %d, want %d", name, km.Status.MonitorID, before["default/"+name].ID)
}
}
}
func TestDeleteWhileDownIsCollected(t *testing.T) {
f, env := newE2E(t)
r := f.start(env)
f.apply(dns("doomed", ""), dns("keeper", ""))
eventually(t, 30*time.Second, "both monitors in Kuma", func() (bool, string) {
return len(f.owned()) == 2, "monitors missing"
})
f.stop(r)
// With the controller down, drop the finalizer and delete the resource.
doomed := &v1.KumaMonitor{}
key := types.NamespacedName{Namespace: "default", Name: "doomed"}
if err := f.k8s.Get(t.Context(), key, doomed); err != nil {
t.Fatal(err)
}
doomed.Finalizers = nil
if err := f.k8s.Update(t.Context(), doomed); err != nil {
t.Fatal(err)
}
if err := f.k8s.Delete(t.Context(), doomed); err != nil {
t.Fatal(err)
}
f.start(env)
eventually(t, 30*time.Second, "orphan collected by GC", func() (bool, string) {
m := byKey(f.owned())
_, doomedLeft := m["default/doomed"]
_, keeperLeft := m["default/keeper"]
if doomedLeft {
return false, "orphan still in Kuma"
}
if !keeperLeft {
t.Fatal("GC deleted the monitor of a live resource")
}
return true, ""
})
}
func TestE2EDriftRepaired(t *testing.T) {
f, env := newE2E(t)
f.start(env)
f.apply(dns("probe", ""))
var id int64
eventually(t, 30*time.Second, "monitor in Kuma", func() (bool, string) {
m, ok := byKey(f.owned())["default/probe"]
id = m.ID
return ok, "monitor missing"
})
want := int64(byKey(f.owned())["default/probe"].Interval)
var live monitor.DNS
if err := f.raw.GetMonitorAs(t.Context(), id, &live); err != nil {
t.Fatal(err)
}
live.Interval = want + 60
if err := f.raw.UpdateMonitor(t.Context(), &live); err != nil {
t.Fatal(err)
}
// The controller may repair the drift at any moment, so observe both
// states in order instead of asserting either on its own.
sawDrift := false
eventually(t, 30*time.Second, "drift seen, then repaired by a resync", func() (bool, string) {
var cur monitor.DNS
if err := f.raw.GetMonitorAs(t.Context(), id, &cur); err != nil {
return false, err.Error()
}
switch {
case !sawDrift && cur.Interval == want+60:
sawDrift = true
case !sawDrift:
t.Fatalf("interval is %d: the drift was repaired before it was ever observed", cur.Interval)
case cur.Interval == want:
return true, ""
}
return false, fmt.Sprintf("interval is %d, want %d (drift seen: %v)", cur.Interval, want, sawDrift)
})
}
func (f *e2e) webhook(name string, isDefault bool) int64 {
f.t.Helper()
id, err := f.raw.CreateNotification(f.t.Context(), notification.Webhook{
Base: notification.Base{Name: name, IsActive: true, IsDefault: isDefault},
WebhookDetails: notification.WebhookDetails{
WebhookURL: "http://127.0.0.1:9/" + name, WebhookContentType: "json",
},
})
if err != nil {
f.t.Fatalf("create notification %s: %v", name, err)
}
return id
}
func TestE2EDefaultNotifications(t *testing.T) {
f, env := newE2E(t)
first := f.webhook("first", true)
f.webhook("opt-in", false)
f.start(env)
f.apply(group("g", ""), dns("probe", "g"))
carry := func(want ...int64) func() (bool, string) {
return func() (bool, string) {
m := byKey(f.owned())
for _, k := range []string{"default/g", "default/probe"} {
got, ok := m[k]
if !ok {
return false, k + " missing"
}
if !slices.Equal(got.NotificationIDs, want) {
return false, fmt.Sprintf("%s carries %v, want %v", k, got.NotificationIDs, want)
}
}
return true, ""
}
}
eventually(t, 30*time.Second, "monitors created with the default notification", carry(first))
second := f.webhook("second", true)
want := []int64{first, second}
slices.Sort(want)
eventually(t, 30*time.Second, "new default notification attached by a resync", carry(want...))
var live monitor.DNS
if err := f.raw.GetMonitorAs(t.Context(), byKey(f.owned())["default/probe"].ID, &live); err != nil {
t.Fatal(err)
}
live.NotificationIDs = nil
if err := f.raw.UpdateMonitor(t.Context(), &live); err != nil {
t.Fatal(err)
}
eventually(t, 30*time.Second, "notifications removed by hand restored by a resync", carry(want...))
}
// A monitor that lost its ownership tag in the UI must not get a twin when the
// controller restarts: the resource reports NotOwned and nothing is created.
func TestE2ETagRemovedNeverDuplicates(t *testing.T) {
f, env := newE2E(t)
r := f.start(env)
f.apply(dns("probe", ""))
var id int64
eventually(t, 30*time.Second, "monitor in Kuma", func() (bool, string) {
m, ok := byKey(f.owned())["default/probe"]
id = m.ID
return ok, "monitor missing"
})
eventually(t, 30*time.Second, "status.monitorID recorded", func() (bool, string) {
km := &v1.KumaMonitor{}
if err := f.k8s.Get(t.Context(), types.NamespacedName{Namespace: "default", Name: "probe"}, km); err != nil {
return false, err.Error()
}
return km.Status.MonitorID == id, "status.monitorID not set"
})
f.stop(r)
tags, err := f.raw.GetTags(t.Context())
if err != nil {
t.Fatal(err)
}
var tagID int64
for _, tg := range tags {
if tg.Name == "kuma-sync" && (tagID == 0 || tg.ID < tagID) {
tagID = tg.ID
}
}
if err := f.raw.DeleteMonitorTagWithValue(t.Context(), tagID, id, "default/probe"); err != nil {
t.Fatal(err)
}
f.start(env)
eventually(t, 30*time.Second, "Ready=False/NotOwned", func() (bool, string) {
km := &v1.KumaMonitor{}
if err := f.k8s.Get(t.Context(), types.NamespacedName{Namespace: "default", Name: "probe"}, km); err != nil {
return false, err.Error()
}
c := meta.FindStatusCondition(km.Status.Conditions, v1.ConditionReady)
if c == nil {
return false, "no Ready condition"
}
return c.Status == metav1.ConditionFalse && c.Reason == "NotOwned", "Ready is " + string(c.Status) + "/" + c.Reason
})
// No twin appears, and the GC leaves the untagged monitor alone.
deadline := time.Now().Add(2*gcInterval + 3*time.Second)
for time.Now().Before(deadline) {
all, err := f.raw.GetMonitors(t.Context())
if err != nil {
t.Fatal(err)
}
if len(all) != 1 || all[0].ID != id {
t.Fatalf("Kuma holds %d monitors, want only the untagged original %d", len(all), id)
}
time.Sleep(500 * time.Millisecond)
}
}
@@ -0,0 +1,246 @@
package controller
import (
"context"
"errors"
"fmt"
"slices"
"strings"
"sync"
"time"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/manager"
v1 "gitea.renner.uno/konairius/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/kumaclient"
)
// +kubebuilder:rbac:groups="",resources=namespaces,verbs=get
// CacheSyncer is satisfied by the manager's cache.
type CacheSyncer interface {
WaitForCacheSync(ctx context.Context) bool
}
// errListFailed marks a run that stopped before deleting anything because a
// list failed.
var errListFailed = errors.New("list failed, nothing deleted")
// minOrphanRuns is how many consecutive runs must see a monitor orphaned
// before it is deleted, whatever the grace period.
const minOrphanRuns = 2
// GC deletes owned Kuma monitors that no KumaMonitor accounts for, and
// duplicates of one owner key beyond the lowest ID.
//
// A run deletes nothing unless both lists succeeded completely: any error from
// ListOwned (conflicts and partial results included) or from listing
// KumaMonitors aborts it. Start waits for Cache to sync first, so an empty
// cache at startup never looks like "every resource was deleted".
//
// An orphan, confirmed absent on the API server, is deleted only after it was
// seen orphaned in at least two consecutive runs spanning OrphanGrace; this
// keeps a cluster rebuild, where the resources are applied long after the
// controller starts, from wiping their monitors. The first-seen times live in
// memory, so a restart starts the grace period over. Orphans in a namespace
// that does not exist are never deleted. Duplicate extras are deleted at once.
type GC struct {
Client client.Client
Kuma kumaclient.Client
Interval time.Duration
// OrphanGrace is how long a monitor must have been seen orphaned.
OrphanGrace time.Duration
// Cache is waited on before the first run; nil skips the wait.
Cache CacheSyncer
// APIReader reads straight from the API server. Every orphan is confirmed
// against it before it is deleted.
APIReader client.Reader
// Now is the clock; nil means time.Now.
Now func() time.Time
mu sync.Mutex
pending map[int64]orphanSeen
}
type orphanSeen struct {
since time.Time
runs int
}
var (
_ manager.Runnable = (*GC)(nil)
_ manager.LeaderElectionRunnable = (*GC)(nil)
)
// NeedLeaderElection is false: there is a single replica, no election.
func (g *GC) NeedLeaderElection() bool { return false }
func (g *GC) now() time.Time {
if g.Now != nil {
return g.Now()
}
return time.Now()
}
func (g *GC) Start(ctx context.Context) error {
logger := log.FromContext(ctx).WithName("gc")
if g.Cache != nil && !g.Cache.WaitForCacheSync(ctx) {
if ctx.Err() != nil {
return nil
}
return errors.New("gc: cache did not sync")
}
interval := g.Interval
if interval <= 0 {
interval = 5 * time.Minute
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
if err := g.RunOnce(ctx); err != nil {
if errors.Is(err, errListFailed) {
logger.Error(err, "gc run aborted")
} else {
logger.Error(err, "gc run finished with errors")
}
}
select {
case <-ctx.Done():
return nil
case <-ticker.C:
}
}
}
// existsInAPI asks the API server, not the cache, whether the resource behind
// an owner key exists. A malformed key is an error, never "absent".
func (g *GC) existsInAPI(ctx context.Context, ownerKey string) (bool, error) {
ns, name, ok := strings.Cut(ownerKey, "/")
if !ok || ns == "" || name == "" || strings.Contains(name, "/") {
return false, fmt.Errorf("malformed owner key %q", ownerKey)
}
err := g.APIReader.Get(ctx, types.NamespacedName{Namespace: ns, Name: name}, &v1.KumaMonitor{})
switch {
case err == nil:
return true, nil
case apierrors.IsNotFound(err):
return false, nil
default:
return false, err
}
}
// namespaceExists reports whether the namespace of an owner key exists.
func (g *GC) namespaceExists(ctx context.Context, ownerKey string) (bool, error) {
ns, _, _ := strings.Cut(ownerKey, "/")
err := g.APIReader.Get(ctx, types.NamespacedName{Name: ns}, &corev1.Namespace{})
switch {
case err == nil:
return true, nil
case apierrors.IsNotFound(err):
return false, nil
default:
return false, err
}
}
// RunOnce performs one collection pass.
func (g *GC) RunOnce(ctx context.Context) error {
logger := log.FromContext(ctx).WithName("gc")
if g.APIReader == nil {
return errors.New("gc: APIReader is required to confirm orphans")
}
g.mu.Lock()
defer g.mu.Unlock()
if g.pending == nil {
g.pending = map[int64]orphanSeen{}
}
// Kuma list must precede the cache list.
owned, err := g.Kuma.ListOwned(ctx)
if err != nil {
clear(g.pending)
return fmt.Errorf("%w: list owned monitors: %w", errListFailed, err)
}
var resources v1.KumaMonitorList
if err := g.Client.List(ctx, &resources); err != nil {
clear(g.pending)
return fmt.Errorf("%w: list KumaMonitors: %w", errListFailed, err)
}
live := make(map[string]bool, len(resources.Items))
for i := range resources.Items {
live[OwnerKey(&resources.Items[i])] = true
}
now := g.now()
slices.SortFunc(owned, func(a, b kumaclient.Monitor) int { return int(a.ID - b.ID) })
seen := map[string]bool{}
candidates := map[int64]bool{}
var errs []error
del := func(m kumaclient.Monitor, reason string) bool {
logger.Info("deleting monitor", "monitorID", m.ID, "ownerKey", m.OwnerKey, "reason", reason)
if err := g.Kuma.Delete(ctx, m.ID); err != nil && !errors.Is(err, kumaclient.ErrNotFound) {
errs = append(errs, fmt.Errorf("delete monitor %d (%s): %w", m.ID, m.OwnerKey, err))
return false
}
return true
}
for _, m := range owned {
keep := live[m.OwnerKey] && !seen[m.OwnerKey]
seen[m.OwnerKey] = true
if keep {
continue
}
if live[m.OwnerKey] {
del(m, "duplicate of a lower monitor ID")
continue
}
exists, err := g.existsInAPI(ctx, m.OwnerKey)
if err != nil {
errs = append(errs, fmt.Errorf("confirm orphan monitor %d (%s): %w", m.ID, m.OwnerKey, err))
continue
}
if exists {
logger.Info("KumaMonitor exists on the API server, not an orphan", "monitorID", m.ID, "ownerKey", m.OwnerKey)
continue
}
nsOK, err := g.namespaceExists(ctx, m.OwnerKey)
if err != nil {
errs = append(errs, fmt.Errorf("check namespace of monitor %d (%s): %w", m.ID, m.OwnerKey, err))
continue
}
if !nsOK {
logger.Info("namespace does not exist, leaving the monitor alone", "monitorID", m.ID, "ownerKey", m.OwnerKey)
continue
}
candidates[m.ID] = true
st, ok := g.pending[m.ID]
if !ok {
st.since = now
}
st.runs++
g.pending[m.ID] = st
if st.runs < minOrphanRuns || now.Sub(st.since) < g.OrphanGrace {
logger.Info("orphan candidate, waiting out the grace period", "monitorID", m.ID, "ownerKey", m.OwnerKey,
"runs", st.runs, "orphanFor", now.Sub(st.since).String(), "grace", g.OrphanGrace.String())
continue
}
if del(m, "no matching KumaMonitor") {
delete(g.pending, m.ID)
}
}
// Whatever stopped being an orphan, or vanished, loses its history.
for id := range g.pending {
if !candidates[id] {
delete(g.pending, id)
}
}
return errors.Join(errs...)
}
@@ -0,0 +1,396 @@
package controller
import (
"context"
"errors"
"fmt"
"sync/atomic"
"testing"
"time"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
"sigs.k8s.io/controller-runtime/pkg/client/interceptor"
v1 "gitea.renner.uno/konairius/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/kumaclient"
)
type gcEnv struct {
gc *GC
kuma *kumaclient.Fake
cnt *counting
}
func newGCEnv(t *testing.T, funcs *interceptor.Funcs, objs ...client.Object) *gcEnv {
t.Helper()
s := gcScheme(t)
b := fake.NewClientBuilder().WithScheme(s).WithObjects(append(objs, namespace("default"))...)
if funcs != nil {
b = b.WithInterceptorFuncs(*funcs)
}
kf := kumaclient.NewFake()
cnt := &counting{Client: kf}
c := b.Build()
return &gcEnv{gc: &GC{Client: c, APIReader: c, Kuma: cnt, Interval: time.Hour}, kuma: kf, cnt: cnt}
}
func gcScheme(t *testing.T) *runtime.Scheme {
t.Helper()
s := runtime.NewScheme()
if err := v1.AddToScheme(s); err != nil {
t.Fatal(err)
}
if err := corev1.AddToScheme(s); err != nil {
t.Fatal(err)
}
return s
}
func namespace(name string) *corev1.Namespace {
return &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: name}}
}
// apiServerWith is a fake API server that always has the default namespace.
func apiServerWith(t *testing.T, objs ...client.Object) client.Reader {
t.Helper()
return fake.NewClientBuilder().WithScheme(gcScheme(t)).WithObjects(append(objs, namespace("default"))...).Build()
}
// runs calls RunOnce n times and returns the last error.
func (e *gcEnv) runs(t *testing.T, n int) error {
t.Helper()
var err error
for range n {
err = e.gc.RunOnce(t.Context())
}
return err
}
func TestGCConfirmsWithAPIReaderBeforeOrphanDelete(t *testing.T) {
e := newGCEnv(t, nil) // stale cache: knows no resources
id := owned(t, e, "default/a")
e.gc.APIReader = apiServerWith(t, httpMonitor("a"))
if err := e.gc.RunOnce(t.Context()); err != nil {
t.Fatal(err)
}
if e.kuma.Get(id) == nil || e.cnt.deletes != 0 {
t.Error("monitor of a resource that exists on the API server was deleted")
}
// Truly absent on the API server: deleted.
e.gc.APIReader = apiServerWith(t)
if err := e.runs(t, 2); err != nil {
t.Fatal(err)
}
if e.kuma.Get(id) != nil {
t.Error("confirmed orphan survived")
}
}
func TestGCSkipsOnAPIReaderError(t *testing.T) {
e := newGCEnv(t, nil)
id := owned(t, e, "default/gone")
s := runtime.NewScheme()
if err := v1.AddToScheme(s); err != nil {
t.Fatal(err)
}
e.gc.APIReader = fake.NewClientBuilder().WithScheme(s).WithInterceptorFuncs(interceptor.Funcs{
Get: func(context.Context, client.WithWatch, client.ObjectKey, client.Object, ...client.GetOption) error {
return errors.New("apiserver unavailable")
},
}).Build()
if err := e.gc.RunOnce(t.Context()); err == nil {
t.Fatal("expected the API reader error to be recorded")
}
if e.kuma.Get(id) == nil || e.cnt.deletes != 0 {
t.Error("deleted without confirmation")
}
}
func TestGCMalformedOwnerKeyNeverDeleted(t *testing.T) {
e := newGCEnv(t, nil)
var ids []int64
for _, key := range []string{"noslash", "/name", "ns/", "a/b/c"} {
ids = append(ids, owned(t, e, key))
}
if err := e.gc.RunOnce(t.Context()); err == nil {
t.Fatal("expected errors for malformed keys")
}
for _, id := range ids {
if e.kuma.Get(id) == nil {
t.Errorf("monitor %d with a malformed key was deleted", id)
}
}
}
func owned(t *testing.T, e *gcEnv, key string) int64 {
t.Helper()
id, err := e.kuma.Create(t.Context(), kumaclient.Monitor{Type: "group", Name: key, OwnerKey: key})
if err != nil {
t.Fatal(err)
}
return id
}
func TestGCDeletesOrphans(t *testing.T) {
e := newGCEnv(t, nil, httpMonitor("live"))
live := owned(t, e, "default/live")
gone := owned(t, e, "default/gone")
if err := e.runs(t, 2); err != nil {
t.Fatal(err)
}
if e.kuma.Get(gone) != nil {
t.Error("orphan survived")
}
if e.kuma.Get(live) == nil {
t.Error("live monitor deleted")
}
}
func TestGCIgnoresUntagged(t *testing.T) {
e := newGCEnv(t, nil)
foreign := e.kuma.AddForeign(kumaclient.Monitor{Name: "foreign"})
if err := e.gc.RunOnce(t.Context()); err != nil {
t.Fatal(err)
}
if e.kuma.Get(foreign) == nil || e.cnt.deletes != 0 {
t.Errorf("foreign monitor touched: deletes=%d", e.cnt.deletes)
}
}
func TestGCKeepsLiveResources(t *testing.T) {
e := newGCEnv(t, nil, httpMonitor("a"), httpMonitor("b"))
owned(t, e, "default/a")
owned(t, e, "default/b")
if err := e.gc.RunOnce(t.Context()); err != nil {
t.Fatal(err)
}
if e.cnt.deletes != 0 {
t.Errorf("deletes = %d", e.cnt.deletes)
}
}
func TestGCKeepsResourcesBeingDeleted(t *testing.T) {
e := newGCEnv(t, nil, deleting(httpMonitor("a")))
owned(t, e, "default/a")
if err := e.gc.RunOnce(t.Context()); err != nil {
t.Fatal(err)
}
if e.cnt.deletes != 0 {
t.Errorf("the finalizer path owns this deletion, deletes = %d", e.cnt.deletes)
}
}
func TestGCAbortsOnListError(t *testing.T) {
e := newGCEnv(t, &interceptor.Funcs{
List: func(context.Context, client.WithWatch, client.ObjectList, ...client.ListOption) error {
return errors.New("cache not ready")
},
})
owned(t, e, "default/gone")
if err := e.gc.RunOnce(t.Context()); err == nil {
t.Fatal("expected error")
}
if e.cnt.deletes != 0 {
t.Errorf("deletes = %d, want 0", e.cnt.deletes)
}
}
func TestGCAbortsOnKumaListError(t *testing.T) {
e := newGCEnv(t, nil)
owned(t, e, "default/gone")
e.kuma.SetErrs(errors.New("boom"), nil, nil, nil)
if err := e.gc.RunOnce(t.Context()); err == nil {
t.Fatal("expected error")
}
if e.cnt.deletes != 0 {
t.Errorf("deletes = %d, want 0", e.cnt.deletes)
}
}
func TestGCNoDeletesOnPartialList(t *testing.T) {
e := newGCEnv(t, nil)
owned(t, e, "default/gone")
e.kuma.SetListPartial(errors.Join(&kumaclient.ConflictError{MonitorID: 9, Keys: []string{"default/x", "default/y"}}))
if err := e.gc.RunOnce(t.Context()); err == nil {
t.Fatal("expected error")
}
if e.cnt.deletes != 0 {
t.Errorf("deletes = %d, want 0", e.cnt.deletes)
}
}
func TestGCDeletesDuplicateExtras(t *testing.T) {
e := newGCEnv(t, nil, httpMonitor("a"))
first := owned(t, e, "default/a")
second := owned(t, e, "default/a")
third := owned(t, e, "default/a")
if err := e.gc.RunOnce(t.Context()); err != nil {
t.Fatal(err)
}
if e.kuma.Get(first) == nil {
t.Error("lowest ID must stay")
}
if e.kuma.Get(second) != nil || e.kuma.Get(third) != nil {
t.Error("extras must be deleted")
}
}
func TestGCDeleteNotFoundIsSuccess(t *testing.T) {
e := newGCEnv(t, nil)
owned(t, e, "default/gone")
e.kuma.SetErrs(nil, nil, nil, fmt.Errorf("delete: %w", kumaclient.ErrNotFound))
if err := e.runs(t, 2); err != nil {
t.Fatalf("ErrNotFound must count as success: %v", err)
}
}
// waiter is a CacheSyncer whose result the test controls.
type waiter struct {
release chan struct{}
result bool
started atomic.Bool
}
func (w *waiter) WaitForCacheSync(ctx context.Context) bool {
w.started.Store(true)
select {
case <-w.release:
return w.result
case <-ctx.Done():
return false
}
}
func TestGCWaitsForCacheSync(t *testing.T) {
e := newGCEnv(t, nil)
owned(t, e, "default/gone") // would look like an orphan against an empty cache
w := &waiter{release: make(chan struct{}), result: true}
e.gc.Cache = w
e.gc.Interval = 20 * time.Millisecond
ctx, cancel := context.WithCancel(t.Context())
done := make(chan error, 1)
go func() { done <- e.gc.Start(ctx) }()
deadline := time.Now().Add(5 * time.Second)
for !w.started.Load() && time.Now().Before(deadline) {
time.Sleep(5 * time.Millisecond)
}
time.Sleep(100 * time.Millisecond)
if e.kuma.Get(1) == nil {
t.Fatal("GC ran before the cache synced")
}
close(w.release)
deadline = time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) && e.kuma.Get(1) != nil {
time.Sleep(5 * time.Millisecond)
}
if e.kuma.Get(1) != nil {
t.Error("GC did not run after the cache synced")
}
cancel()
if err := <-done; err != nil {
t.Errorf("Start returned %v on shutdown", err)
}
}
func TestGCOrphanGraceDelaysDelete(t *testing.T) {
e := newGCEnv(t, nil)
id := owned(t, e, "default/gone")
e.gc.OrphanGrace = time.Hour
clock := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
e.gc.Now = func() time.Time { return clock }
for _, step := range []struct {
at time.Duration
deleted bool
}{
{0, false}, // first sighting
{30 * time.Minute, false}, // second run, grace not over
{59 * time.Minute, false}, // still inside the grace period
{60 * time.Minute, true}, // third run, a full hour after the first sighting
} {
clock = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC).Add(step.at)
if err := e.gc.RunOnce(t.Context()); err != nil {
t.Fatal(err)
}
if gone := e.kuma.Get(id) == nil; gone != step.deleted {
t.Fatalf("at +%v: deleted = %v, want %v", step.at, gone, step.deleted)
}
}
}
func TestGCOrphanGraceResetsWhenResourceReturns(t *testing.T) {
e := newGCEnv(t, nil)
id := owned(t, e, "default/a")
e.gc.OrphanGrace = time.Hour
base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
var clock time.Time
e.gc.Now = func() time.Time { return clock }
run := func(at time.Duration) {
t.Helper()
clock = base.Add(at)
if err := e.gc.RunOnce(t.Context()); err != nil {
t.Fatal(err)
}
}
run(0) // orphan seen
km := httpMonitor("a")
if err := e.gc.Client.Create(t.Context(), km); err != nil {
t.Fatal(err)
}
run(40 * time.Minute) // resource is back: history dropped
if err := e.gc.Client.Delete(t.Context(), km); err != nil {
t.Fatal(err)
}
run(61 * time.Minute) // orphan again, first sighting of the new streak
run(90 * time.Minute) // only 29 minutes into the new streak
if e.kuma.Get(id) == nil {
t.Fatal("deleted although the grace period restarted when the resource returned")
}
run(122 * time.Minute) // 61 minutes into the new streak
if e.kuma.Get(id) != nil {
t.Error("orphan survived a full grace period")
}
}
func TestGCSkipsMissingNamespace(t *testing.T) {
e := newGCEnv(t, nil)
id := owned(t, e, "ghost/x")
if err := e.runs(t, 3); err != nil {
t.Fatal(err)
}
if e.kuma.Get(id) == nil || e.cnt.deletes != 0 {
t.Fatal("monitor of a missing namespace was deleted")
}
if err := e.gc.Client.Create(t.Context(), namespace("ghost")); err != nil {
t.Fatal(err)
}
if err := e.runs(t, 2); err != nil {
t.Fatal(err)
}
if e.kuma.Get(id) != nil {
t.Error("orphan in an existing namespace survived")
}
}
func TestGCStartFailsWhenCacheNeverSyncs(t *testing.T) {
e := newGCEnv(t, nil)
owned(t, e, "default/gone")
released := make(chan struct{})
close(released)
e.gc.Cache = &waiter{release: released, result: false}
if err := e.gc.Start(t.Context()); err == nil {
t.Fatal("expected an error when the cache does not sync")
}
if e.cnt.deletes != 0 {
t.Error("GC deleted without a synced cache")
}
}
@@ -0,0 +1,333 @@
// Package controller reconciles KumaMonitor resources into Uptime Kuma.
package controller
import (
"context"
"errors"
"fmt"
"reflect"
"slices"
"time"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/builder"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
"sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/predicate"
v1 "gitea.renner.uno/konairius/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/kumaclient"
)
const (
Finalizer = "uptime.renner.uno/kuma-sync"
ReasonSynced = "Synced"
ReasonParentPending = "ParentPending"
ReasonInvalidParent = "InvalidParent"
ReasonOwnerTagMissing = "OwnerTagMissing"
ReasonOwnerConflict = "OwnerConflict"
ReasonDuplicateOwner = "DuplicateOwner"
ReasonKumaUnavailable = "KumaUnavailable"
ReasonPossibleOrphan = "PossibleOrphan"
ReasonNotOwned = "NotOwned"
parentRetry = 10 * time.Second
resyncPeriod = 5 * time.Minute
maxParentDepth = 10
)
// +kubebuilder:rbac:groups=uptime.renner.uno,resources=kumamonitors,verbs=get;list;watch;update;patch
// +kubebuilder:rbac:groups=uptime.renner.uno,resources=kumamonitors/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=uptime.renner.uno,resources=kumamonitors/finalizers,verbs=update
// +kubebuilder:rbac:groups="",resources=events,verbs=create;patch
// Reconciler syncs KumaMonitor resources to Kuma.
type Reconciler struct {
Client client.Client
Kuma kumaclient.Client
// Resync is how often a healthy resource is reconciled again, which repairs
// drift made in Kuma. 0 means 5 minutes.
Resync time.Duration
}
func (r *Reconciler) resync() time.Duration {
if r.Resync > 0 {
return r.Resync
}
return resyncPeriod
}
// SetupWithManager ignores status-only updates, which keeps failure backoff
// intact. Deletion still reconciles: setting deletionTimestamp bumps
// metadata.generation.
func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&v1.KumaMonitor{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})).
Named("kumamonitor").
Complete(r)
}
// failure is a Ready=False outcome. The message is fixed per reason; the cause
// goes to the log and the returned error only.
type failure struct {
reason string
message string
err error
}
const (
msgKumaUnavailable = "Kuma request failed"
msgListIncomplete = "monitor list incomplete: owner conflict on another monitor"
msgOwnerTagMissing = "kuma-sync tag missing in Kuma"
msgOwnerConflict = "monitor carries conflicting kuma-sync tag values"
msgPossibleOrphan = "create outcome unknown, an untagged monitor may exist in Kuma"
msgDuplicateOwner = "several monitors carry this owner key; syncing the lowest ID"
msgParentPending = "parent has no monitor ID yet"
msgParentNotGroup = "parent must be of type group"
msgParentCycle = "parentRef forms a cycle"
msgParentTooDeep = "parentRef chain is too deep"
msgSynced = "monitor in sync"
msgNotOwned = "previous monitor exists without our ownership tag; restore the tag or delete it in Kuma"
)
func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
km := &v1.KumaMonitor{}
if err := r.Client.Get(ctx, req.NamespacedName, km); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
if !km.DeletionTimestamp.IsZero() {
return r.reconcileDelete(ctx, km)
}
if controllerutil.AddFinalizer(km, Finalizer) {
if err := r.Client.Update(ctx, km); err != nil {
return ctrl.Result{}, err
}
}
desired := Desired(km)
if km.Spec.ParentRef != nil {
p, err := r.resolveParent(ctx, km)
if err != nil {
return ctrl.Result{}, err
}
if p.reason != "" {
if err := r.setReady(ctx, km, metav1.ConditionFalse, p.reason, p.message, nil); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{RequeueAfter: p.requeue}, nil
}
desired.Parent = &p.id
}
res, fail := r.lookup(ctx, desired.OwnerKey)
if fail != nil {
return r.fail(ctx, km, fail)
}
notifications, err := r.Kuma.DefaultNotifications(ctx)
if err != nil {
return r.fail(ctx, km, &failure{ReasonKumaUnavailable, msgKumaUnavailable, err})
}
desired.NotificationIDs = notifications
var id int64
if res.mon == nil {
// The monitor this resource synced before may have lost its tag; a
// second one would then sit beside it. An ID now carrying another
// owner's key was reused by Kuma, so ours is gone.
if prev := km.Status.MonitorID; prev != 0 {
found, key, err := r.Kuma.Probe(ctx, prev)
switch {
case err != nil:
return r.fail(ctx, km, &failure{ReasonKumaUnavailable, msgKumaUnavailable, err})
case found && key == "":
log.FromContext(ctx).Error(nil, "previous monitor exists without our ownership tag, not creating another",
"ownerKey", desired.OwnerKey, "monitorID", prev, "tagValue", key)
err := r.setReady(ctx, km, metav1.ConditionFalse, ReasonNotOwned, msgNotOwned, nil)
return ctrl.Result{RequeueAfter: r.resync()}, err
case found && key == desired.OwnerKey:
return r.fail(ctx, km, &failure{ReasonKumaUnavailable, msgKumaUnavailable,
fmt.Errorf("monitor %d carries our key but is missing from the owned list", prev)})
}
}
var err error
id, err = r.Kuma.Create(ctx, desired)
if err != nil {
if errors.Is(err, kumaclient.ErrPossibleOrphan) {
log.FromContext(ctx).Error(err, "create outcome unknown, an untagged monitor may exist in Kuma",
"ownerKey", desired.OwnerKey)
return r.fail(ctx, km, &failure{ReasonPossibleOrphan, msgPossibleOrphan, err})
}
return r.fail(ctx, km, &failure{ReasonKumaUnavailable, msgKumaUnavailable, err})
}
} else {
id = res.mon.ID
desired.ID = id
if !reflect.DeepEqual(desired.Normalized(), *res.mon) {
if err := r.Kuma.Update(ctx, desired); err != nil {
return r.fail(ctx, km, &failure{ReasonKumaUnavailable, msgKumaUnavailable, err})
}
}
}
if len(res.ids) > 1 {
log.FromContext(ctx).Error(nil, "several Kuma monitors carry the same owner key, syncing the lowest ID",
"ownerKey", desired.OwnerKey, "monitorIDs", res.ids)
err := r.setReady(ctx, km, metav1.ConditionFalse, ReasonDuplicateOwner, msgDuplicateOwner, &id)
return ctrl.Result{RequeueAfter: r.resync()}, err
}
if err := r.setReady(ctx, km, metav1.ConditionTrue, ReasonSynced, msgSynced, &id); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{RequeueAfter: r.resync()}, nil
}
func (r *Reconciler) reconcileDelete(ctx context.Context, km *v1.KumaMonitor) (ctrl.Result, error) {
if !controllerutil.ContainsFinalizer(km, Finalizer) {
return ctrl.Result{}, nil
}
res, fail := r.lookup(ctx, OwnerKey(km))
if fail != nil {
return r.fail(ctx, km, fail)
}
// Remove every monitor carrying the key, duplicates included.
for _, id := range res.ids {
if err := r.Kuma.Delete(ctx, id); err != nil && !errors.Is(err, kumaclient.ErrNotFound) {
return r.fail(ctx, km, &failure{ReasonKumaUnavailable, msgKumaUnavailable, err})
}
}
controllerutil.RemoveFinalizer(km, Finalizer)
return ctrl.Result{}, r.Client.Update(ctx, km)
}
type lookupResult struct {
mon *kumaclient.Monitor // lowest ID carrying the key
ids []int64 // all IDs carrying the key, ascending
}
// lookup finds the monitors owned by key. A failed or incomplete list is a
// failure, never "absent": the one exception is a conflict on other monitors
// while the partial list already holds this key, which may be adopted but
// never leads to a create.
func (r *Reconciler) lookup(ctx context.Context, key string) (lookupResult, *failure) {
list, err := r.Kuma.ListOwned(ctx)
var res lookupResult
for i := range list {
if list[i].OwnerKey != key {
continue
}
res.ids = append(res.ids, list[i].ID)
if res.mon == nil || list[i].ID < res.mon.ID {
res.mon = &list[i]
}
}
slices.Sort(res.ids)
if err == nil {
return res, nil
}
if errors.Is(err, kumaclient.ErrOwnerTagMissing) {
return res, &failure{ReasonOwnerTagMissing, msgOwnerTagMissing, err}
}
conflicts, only := kumaclient.Conflicts(err)
for _, c := range conflicts {
if slices.Contains(c.Keys, key) {
return res, &failure{ReasonOwnerConflict, msgOwnerConflict, err}
}
}
if !only {
return res, &failure{ReasonKumaUnavailable, msgKumaUnavailable, err}
}
if res.mon != nil {
log.FromContext(ctx).Error(err, "owner conflict on another monitor, updating the monitor already found",
"ownerKey", key)
return res, nil
}
return res, &failure{ReasonKumaUnavailable, msgListIncomplete, err}
}
type parentOutcome struct {
id int64
reason string // empty: resolved
message string
requeue time.Duration
}
// resolveParent checks the whole parentRef chain: every ancestor must exist
// and be a group, without cycles or more than maxParentDepth levels.
func (r *Reconciler) resolveParent(ctx context.Context, km *v1.KumaMonitor) (parentOutcome, error) {
invalid := func(msg string) (parentOutcome, error) {
return parentOutcome{reason: ReasonInvalidParent, message: msg, requeue: r.resync()}, nil
}
pending := parentOutcome{reason: ReasonParentPending, message: msgParentPending, requeue: parentRetry}
visited := map[string]bool{km.Name: true}
cur := km
var directID int64
for depth := 0; cur.Spec.ParentRef != nil; depth++ {
if depth >= maxParentDepth {
return invalid(msgParentTooDeep)
}
name := cur.Spec.ParentRef.Name
if visited[name] {
return invalid(msgParentCycle)
}
visited[name] = true
parent := &v1.KumaMonitor{}
err := r.Client.Get(ctx, types.NamespacedName{Namespace: km.Namespace, Name: name}, parent)
if apierrors.IsNotFound(err) {
return pending, nil
}
if err != nil {
return parentOutcome{}, err
}
if parent.Spec.Type != "group" {
return invalid(msgParentNotGroup)
}
if depth == 0 {
directID = parent.Status.MonitorID
}
cur = parent
}
if directID == 0 {
return pending, nil
}
return parentOutcome{id: directID}, nil
}
// fail records a Ready=False outcome and returns its cause so the request is
// requeued with backoff.
func (r *Reconciler) fail(ctx context.Context, km *v1.KumaMonitor, f *failure) (ctrl.Result, error) {
log.FromContext(ctx).Info("reconcile failed", "reason", f.reason, "error", f.err.Error())
if serr := r.setReady(ctx, km, metav1.ConditionFalse, f.reason, f.message, nil); serr != nil {
return ctrl.Result{}, errors.Join(f.err, serr)
}
return ctrl.Result{}, f.err
}
// setReady writes the Ready condition, and the monitor ID when given, through
// the status subresource. It writes nothing if the status is unchanged.
func (r *Reconciler) setReady(
ctx context.Context, km *v1.KumaMonitor, status metav1.ConditionStatus, reason, msg string, monitorID *int64,
) error {
orig := km.Status.DeepCopy()
if monitorID != nil {
km.Status.MonitorID = *monitorID
km.Status.ObservedGeneration = km.Generation
}
meta.SetStatusCondition(&km.Status.Conditions, metav1.Condition{
Type: v1.ConditionReady,
Status: status,
Reason: reason,
Message: msg,
ObservedGeneration: km.Generation,
})
if reflect.DeepEqual(*orig, km.Status) {
return nil
}
return r.Client.Status().Update(ctx, km)
}
@@ -0,0 +1,692 @@
package controller
import (
"context"
"errors"
"fmt"
"slices"
"testing"
"time"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
v1 "gitea.renner.uno/konairius/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/kumaclient"
)
// counting records calls on top of the Kuma fake.
type counting struct {
kumaclient.Client
creates, updates, deletes int
}
func (c *counting) Create(ctx context.Context, m kumaclient.Monitor) (int64, error) {
c.creates++
return c.Client.Create(ctx, m)
}
func (c *counting) Update(ctx context.Context, m kumaclient.Monitor) error {
c.updates++
return c.Client.Update(ctx, m)
}
func (c *counting) Delete(ctx context.Context, id int64) error {
c.deletes++
return c.Client.Delete(ctx, id)
}
type env struct {
r *Reconciler
k8s client.Client
kuma *kumaclient.Fake
cnt *counting
}
func newEnv(t *testing.T, objs ...client.Object) *env {
t.Helper()
s := runtime.NewScheme()
if err := v1.AddToScheme(s); err != nil {
t.Fatal(err)
}
k8s := fake.NewClientBuilder().WithScheme(s).WithObjects(objs...).WithStatusSubresource(&v1.KumaMonitor{}).Build()
kf := kumaclient.NewFake()
cnt := &counting{Client: kf}
return &env{r: &Reconciler{Client: k8s, Kuma: cnt}, k8s: k8s, kuma: kf, cnt: cnt}
}
func httpMonitor(name string) *v1.KumaMonitor {
return &v1.KumaMonitor{
ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: name},
Spec: v1.KumaMonitorSpec{
Type: "http", DisplayName: name, Interval: 60, RetryInterval: 60,
HTTP: &v1.HTTPSpec{URL: "https://example.com"},
},
}
}
func (e *env) reconcile(t *testing.T, name string) (ctrl.Result, error) {
t.Helper()
return e.r.Reconcile(t.Context(), ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "default", Name: name}})
}
func (e *env) get(t *testing.T, name string) *v1.KumaMonitor {
t.Helper()
km := &v1.KumaMonitor{}
if err := e.k8s.Get(t.Context(), types.NamespacedName{Namespace: "default", Name: name}, km); err != nil {
t.Fatal(err)
}
return km
}
func ready(t *testing.T, km *v1.KumaMonitor) *metav1.Condition {
t.Helper()
c := meta.FindStatusCondition(km.Status.Conditions, v1.ConditionReady)
if c == nil {
t.Fatal("no Ready condition")
}
return c
}
func TestReconcileCreates(t *testing.T) {
e := newEnv(t, httpMonitor("a"))
res, err := e.reconcile(t, "a")
if err != nil {
t.Fatal(err)
}
if res.RequeueAfter != 5*time.Minute {
t.Errorf("RequeueAfter = %v", res.RequeueAfter)
}
if e.cnt.creates != 1 {
t.Fatalf("creates = %d", e.cnt.creates)
}
km := e.get(t, "a")
if km.Status.MonitorID == 0 || km.Status.ObservedGeneration != km.Generation {
t.Errorf("status = %+v", km.Status)
}
if c := ready(t, km); c.Status != metav1.ConditionTrue {
t.Errorf("Ready = %+v", c)
}
if len(km.Finalizers) != 1 {
t.Errorf("finalizers = %v", km.Finalizers)
}
if got := e.kuma.Get(km.Status.MonitorID); got == nil || got.OwnerKey != "default/a" {
t.Errorf("kuma monitor = %+v", got)
}
}
func TestReconcileAdoptsExisting(t *testing.T) {
km := httpMonitor("a")
e := newEnv(t, km)
existing := Desired(km)
id, err := e.kuma.Create(t.Context(), existing)
if err != nil {
t.Fatal(err)
}
if _, err := e.reconcile(t, "a"); err != nil {
t.Fatal(err)
}
if e.cnt.creates != 0 || e.cnt.updates != 0 {
t.Errorf("creates=%d updates=%d, want 0 and 0", e.cnt.creates, e.cnt.updates)
}
if got := e.get(t, "a").Status.MonitorID; got != id {
t.Errorf("monitorID = %d, want %d", got, id)
}
}
func TestReconcileUpdatesDrift(t *testing.T) {
km := httpMonitor("a")
e := newEnv(t, km)
drifted := Desired(km)
drifted.Interval = 30
id, _ := e.kuma.Create(t.Context(), drifted)
if _, err := e.reconcile(t, "a"); err != nil {
t.Fatal(err)
}
if e.cnt.updates != 1 || e.cnt.creates != 0 {
t.Fatalf("updates=%d creates=%d", e.cnt.updates, e.cnt.creates)
}
if got := e.kuma.Get(id); got.Interval != 60 {
t.Errorf("interval = %d", got.Interval)
}
}
func TestReconcileNoopWhenEqual(t *testing.T) {
km := httpMonitor("a")
e := newEnv(t, km)
e.kuma.Create(t.Context(), Desired(km))
if _, err := e.reconcile(t, "a"); err != nil {
t.Fatal(err)
}
if e.cnt.updates != 0 || e.cnt.creates != 0 {
t.Fatalf("updates=%d creates=%d", e.cnt.updates, e.cnt.creates)
}
}
func TestReconcileNormalizedNoSpuriousUpdate(t *testing.T) {
km := httpMonitor("a")
km.Spec.HTTP.AcceptedStatusCodes = nil
km.Spec.Timeout = nil
e := newEnv(t, km)
for range 2 {
if _, err := e.reconcile(t, "a"); err != nil {
t.Fatal(err)
}
}
if e.cnt.creates != 1 || e.cnt.updates != 0 {
t.Fatalf("creates=%d updates=%d, want 1 and 0", e.cnt.creates, e.cnt.updates)
}
}
func TestReconcileWaitsForParent(t *testing.T) {
group := &v1.KumaMonitor{
ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "grp"},
Spec: v1.KumaMonitorSpec{Type: "group", DisplayName: "grp", Interval: 60, RetryInterval: 60},
}
child := httpMonitor("child")
child.Spec.ParentRef = &v1.LocalRef{Name: "grp"}
e := newEnv(t, group, child)
for _, step := range []string{"parent without monitorID", "missing parent"} {
if step == "missing parent" {
if err := e.k8s.Delete(t.Context(), group); err != nil {
t.Fatal(err)
}
}
res, err := e.reconcile(t, "child")
if err != nil {
t.Fatalf("%s: %v", step, err)
}
if res.RequeueAfter != 10*time.Second {
t.Errorf("%s: RequeueAfter = %v", step, res.RequeueAfter)
}
if c := ready(t, e.get(t, "child")); c.Status != metav1.ConditionFalse || c.Reason != ReasonParentPending {
t.Errorf("%s: Ready = %+v", step, c)
}
}
if e.cnt.creates != 0 {
t.Errorf("creates = %d", e.cnt.creates)
}
}
func TestReconcileResolvesParent(t *testing.T) {
group := &v1.KumaMonitor{
ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "grp"},
Spec: v1.KumaMonitorSpec{Type: "group", DisplayName: "grp", Interval: 60, RetryInterval: 60},
Status: v1.KumaMonitorStatus{MonitorID: 42},
}
child := httpMonitor("child")
child.Spec.ParentRef = &v1.LocalRef{Name: "grp"}
e := newEnv(t, group, child)
if _, err := e.reconcile(t, "child"); err != nil {
t.Fatal(err)
}
got := e.kuma.Get(e.get(t, "child").Status.MonitorID)
if got == nil || got.Parent == nil || *got.Parent != 42 {
t.Fatalf("parent = %+v", got)
}
}
func deleting(km *v1.KumaMonitor) *v1.KumaMonitor {
now := metav1.Now()
km.DeletionTimestamp = &now
km.Finalizers = []string{Finalizer}
return km
}
func TestReconcileDeleteRemovesMonitorAndFinalizer(t *testing.T) {
km := deleting(httpMonitor("a"))
e := newEnv(t, km)
id, _ := e.kuma.Create(t.Context(), Desired(km))
if _, err := e.reconcile(t, "a"); err != nil {
t.Fatal(err)
}
if e.kuma.Get(id) != nil {
t.Error("kuma monitor not deleted")
}
err := e.k8s.Get(t.Context(), client.ObjectKeyFromObject(km), &v1.KumaMonitor{})
if !apierrors.IsNotFound(err) {
t.Errorf("resource should be gone after finalizer removal, got %v", err)
}
}
func TestReconcileDeleteToleratesMissingMonitor(t *testing.T) {
km := deleting(httpMonitor("a"))
e := newEnv(t, km)
if _, err := e.reconcile(t, "a"); err != nil {
t.Fatal(err)
}
if e.cnt.deletes != 0 {
t.Errorf("deletes = %d", e.cnt.deletes)
}
err := e.k8s.Get(t.Context(), client.ObjectKeyFromObject(km), &v1.KumaMonitor{})
if !apierrors.IsNotFound(err) {
t.Errorf("finalizer not removed: %v", err)
}
}
func TestReconcileDeleteToleratesNotFoundFromKuma(t *testing.T) {
km := deleting(httpMonitor("a"))
e := newEnv(t, km)
e.kuma.Create(t.Context(), Desired(km))
e.kuma.SetErrs(nil, nil, nil, fmt.Errorf("delete 1: %w", kumaclient.ErrNotFound))
if _, err := e.reconcile(t, "a"); err != nil {
t.Fatalf("ErrNotFound from Delete must count as success: %v", err)
}
if err := e.k8s.Get(t.Context(), client.ObjectKeyFromObject(km), &v1.KumaMonitor{}); !apierrors.IsNotFound(err) {
t.Errorf("finalizer not removed: %v", err)
}
}
func TestReconcileDeleteNotOwnedIsError(t *testing.T) {
km := deleting(httpMonitor("a"))
e := newEnv(t, km)
e.kuma.Create(t.Context(), Desired(km))
e.kuma.SetErrs(nil, nil, nil, fmt.Errorf("delete 1: %w", kumaclient.ErrNotOwned))
if _, err := e.reconcile(t, "a"); !errors.Is(err, kumaclient.ErrNotOwned) {
t.Fatalf("want ErrNotOwned, got %v", err)
}
if err := e.k8s.Get(t.Context(), client.ObjectKeyFromObject(km), &v1.KumaMonitor{}); err != nil {
t.Errorf("finalizer must stay: %v", err)
}
}
func TestReconcileListErrorNoCreate(t *testing.T) {
e := newEnv(t, httpMonitor("a"))
e.kuma.SetErrs(errors.New("boom"), nil, nil, nil)
if _, err := e.reconcile(t, "a"); err == nil {
t.Fatal("expected error")
}
if e.cnt.creates != 0 || e.cnt.updates != 0 {
t.Errorf("creates=%d updates=%d", e.cnt.creates, e.cnt.updates)
}
if c := ready(t, e.get(t, "a")); c.Status != metav1.ConditionFalse || c.Reason != ReasonKumaUnavailable {
t.Errorf("Ready = %+v", c)
}
}
func TestReconcileCreateGetsDefaultNotifications(t *testing.T) {
group := &v1.KumaMonitor{
ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "grp"},
Spec: v1.KumaMonitorSpec{Type: "group", DisplayName: "grp", Interval: 60, RetryInterval: 60},
}
e := newEnv(t, httpMonitor("a"), group)
e.kuma.SetDefaultNotifications(7, 3)
for _, name := range []string{"a", "grp"} {
if _, err := e.reconcile(t, name); err != nil {
t.Fatal(err)
}
got := e.kuma.Get(e.get(t, name).Status.MonitorID)
if got == nil || !slices.Equal(got.NotificationIDs, []int64{3, 7}) {
t.Errorf("%s: kuma monitor = %+v, want notifications [3 7]", name, got)
}
}
}
func TestReconcileRevertsNotificationDrift(t *testing.T) {
km := httpMonitor("a")
e := newEnv(t, km)
e.kuma.SetDefaultNotifications(1, 2)
drifted := Desired(km)
drifted.NotificationIDs = []int64{2, 9}
id, _ := e.kuma.Create(t.Context(), drifted)
if _, err := e.reconcile(t, "a"); err != nil {
t.Fatal(err)
}
if e.cnt.updates != 1 || e.cnt.creates != 0 {
t.Fatalf("updates=%d creates=%d", e.cnt.updates, e.cnt.creates)
}
if got := e.kuma.Get(id).NotificationIDs; !slices.Equal(got, []int64{1, 2}) {
t.Errorf("notifications = %v, want [1 2]", got)
}
}
func TestReconcileUnchangedNotificationsNoUpdate(t *testing.T) {
km := httpMonitor("a")
e := newEnv(t, km)
e.kuma.SetDefaultNotifications(4, 2)
existing := Desired(km)
existing.NotificationIDs = []int64{4, 2}
e.kuma.Create(t.Context(), existing)
for range 2 {
if _, err := e.reconcile(t, "a"); err != nil {
t.Fatal(err)
}
}
if e.cnt.updates != 0 || e.cnt.creates != 0 {
t.Fatalf("updates=%d creates=%d, want 0 and 0", e.cnt.updates, e.cnt.creates)
}
}
func TestReconcileNotificationListErrorNoWrite(t *testing.T) {
adopt := httpMonitor("b")
e := newEnv(t, httpMonitor("a"), adopt)
drifted := Desired(adopt)
drifted.Interval = 30
e.kuma.Create(t.Context(), drifted)
e.kuma.SetNotificationsErr(errors.New("boom"))
for _, name := range []string{"a", "b"} {
if _, err := e.reconcile(t, name); err == nil {
t.Fatalf("%s: expected error", name)
}
if c := ready(t, e.get(t, name)); c.Status != metav1.ConditionFalse || c.Reason != ReasonKumaUnavailable {
t.Errorf("%s: Ready = %+v", name, c)
}
}
if e.cnt.creates != 0 || e.cnt.updates != 0 {
t.Errorf("creates=%d updates=%d", e.cnt.creates, e.cnt.updates)
}
}
func TestReconcileOwnerTagMissing(t *testing.T) {
e := newEnv(t, httpMonitor("a"))
e.kuma.SetErrs(kumaclient.ErrOwnerTagMissing, nil, nil, nil)
if _, err := e.reconcile(t, "a"); !errors.Is(err, kumaclient.ErrOwnerTagMissing) {
t.Fatalf("err = %v", err)
}
if e.cnt.creates != 0 {
t.Errorf("creates = %d", e.cnt.creates)
}
if c := ready(t, e.get(t, "a")); c.Reason != ReasonOwnerTagMissing {
t.Errorf("Ready = %+v", c)
}
}
func TestReconcileOwnerConflict(t *testing.T) {
e := newEnv(t, httpMonitor("a"))
// Three keys, ours last: a first-two-values message would miss it.
e.kuma.SetListPartial(errors.Join(&kumaclient.ConflictError{MonitorID: 3, Keys: []string{"default/other", "default/x", "default/a"}}))
if _, err := e.reconcile(t, "a"); !errors.Is(err, kumaclient.ErrOwnerConflict) {
t.Fatalf("err = %v", err)
}
if e.cnt.creates != 0 || e.cnt.updates != 0 {
t.Errorf("creates=%d updates=%d", e.cnt.creates, e.cnt.updates)
}
if c := ready(t, e.get(t, "a")); c.Reason != ReasonOwnerConflict {
t.Errorf("Ready = %+v", c)
}
}
func otherConflict() error {
return errors.Join(&kumaclient.ConflictError{MonitorID: 3, Keys: []string{"default/x", "default/y"}})
}
func TestReconcileConflictOnOtherMonitorNeverCreates(t *testing.T) {
e := newEnv(t, httpMonitor("a"))
e.kuma.SetListPartial(otherConflict())
if _, err := e.reconcile(t, "a"); !errors.Is(err, kumaclient.ErrOwnerConflict) {
t.Fatalf("err = %v", err)
}
if e.cnt.creates != 0 {
t.Errorf("creates = %d, want 0", e.cnt.creates)
}
c := ready(t, e.get(t, "a"))
if c.Status != metav1.ConditionFalse || c.Reason != ReasonKumaUnavailable ||
c.Message != "monitor list incomplete: owner conflict on another monitor" {
t.Errorf("Ready = %+v", c)
}
}
func TestReconcileConflictOnOtherMonitorAdoptsFoundByID(t *testing.T) {
km := httpMonitor("a")
e := newEnv(t, km)
drifted := Desired(km)
drifted.Interval = 30
id, _ := e.kuma.Create(t.Context(), drifted)
e.kuma.SetListPartial(otherConflict())
if _, err := e.reconcile(t, "a"); err != nil {
t.Fatal(err)
}
if e.cnt.creates != 0 || e.cnt.updates != 1 {
t.Fatalf("creates=%d updates=%d, want 0 and 1", e.cnt.creates, e.cnt.updates)
}
if got := e.get(t, "a").Status.MonitorID; got != id {
t.Errorf("monitorID = %d, want %d", got, id)
}
}
func TestReconcileFailureMessageIsStable(t *testing.T) {
e := newEnv(t, httpMonitor("a"))
e.kuma.SetErrs(errors.New("boom 1"), nil, nil, nil)
e.reconcile(t, "a")
first := e.get(t, "a")
e.kuma.SetErrs(errors.New("boom 2"), nil, nil, nil)
e.reconcile(t, "a")
second := e.get(t, "a")
if ready(t, first).Message != ready(t, second).Message || first.ResourceVersion != second.ResourceVersion {
t.Errorf("failure rewrote status: %q vs %q (rv %s vs %s)",
ready(t, first).Message, ready(t, second).Message, first.ResourceVersion, second.ResourceVersion)
}
}
func TestReconcileFinalizerSurvivesFailedCreate(t *testing.T) {
e := newEnv(t, httpMonitor("a"))
e.kuma.SetErrs(nil, errors.New("boom"), nil, nil)
if _, err := e.reconcile(t, "a"); err == nil {
t.Fatal("expected error")
}
if got := e.get(t, "a").Finalizers; len(got) != 1 || got[0] != Finalizer {
t.Errorf("finalizers = %v", got)
}
}
func withPreviousMonitor(id int64) *v1.KumaMonitor {
km := httpMonitor("a")
km.Status.MonitorID = id
return km
}
func TestReconcileRefusesCreateBesideUntaggedPrevious(t *testing.T) {
e := newEnv(t, withPreviousMonitor(1))
e.kuma.AddForeign(kumaclient.Monitor{ID: 1, Name: "was ours"})
res, err := e.reconcile(t, "a")
if err != nil {
t.Fatal(err)
}
if e.cnt.creates != 0 {
t.Fatalf("creates = %d, want 0", e.cnt.creates)
}
if res.RequeueAfter != 5*time.Minute {
t.Errorf("RequeueAfter = %v", res.RequeueAfter)
}
c := ready(t, e.get(t, "a"))
if c.Status != metav1.ConditionFalse || c.Reason != ReasonNotOwned ||
c.Message != "previous monitor exists without our ownership tag; restore the tag or delete it in Kuma" {
t.Errorf("Ready = %+v", c)
}
}
func TestReconcileCreatesWhenPreviousIDReusedByOtherOwner(t *testing.T) {
e := newEnv(t, withPreviousMonitor(1))
if _, err := e.kuma.Create(t.Context(), kumaclient.Monitor{Name: "x", OwnerKey: "default/other"}); err != nil {
t.Fatal(err)
}
if _, err := e.reconcile(t, "a"); err != nil {
t.Fatal(err)
}
if e.cnt.creates != 1 {
t.Errorf("creates = %d, want 1", e.cnt.creates)
}
if c := ready(t, e.get(t, "a")); c.Reason != ReasonSynced {
t.Errorf("Ready = %+v", c)
}
if m := e.kuma.Get(1); m == nil || m.OwnerKey != "default/other" {
t.Errorf("other owner's monitor 1 = %+v, want untouched", m)
}
}
func TestReconcileCreatesWhenPreviousMonitorIsGone(t *testing.T) {
e := newEnv(t, withPreviousMonitor(77))
if _, err := e.reconcile(t, "a"); err != nil {
t.Fatal(err)
}
if e.cnt.creates != 1 {
t.Errorf("creates = %d, want 1", e.cnt.creates)
}
}
func TestReconcileProbeErrorNeverCreates(t *testing.T) {
e := newEnv(t, withPreviousMonitor(1))
e.kuma.SetProbeErr(errors.New("boom"))
if _, err := e.reconcile(t, "a"); err == nil {
t.Fatal("expected error")
}
if e.cnt.creates != 0 {
t.Errorf("creates = %d, want 0", e.cnt.creates)
}
if c := ready(t, e.get(t, "a")); c.Reason != ReasonKumaUnavailable {
t.Errorf("Ready = %+v", c)
}
}
func TestReconcileDuplicateOwner(t *testing.T) {
km := httpMonitor("a")
e := newEnv(t, km)
first, _ := e.kuma.Create(t.Context(), Desired(km))
second, _ := e.kuma.Create(t.Context(), Desired(km))
if _, err := e.reconcile(t, "a"); err != nil {
t.Fatal(err)
}
if e.cnt.creates != 0 || e.cnt.deletes != 0 {
t.Errorf("creates=%d deletes=%d, want 0 and 0", e.cnt.creates, e.cnt.deletes)
}
got := e.get(t, "a")
if got.Status.MonitorID != first || first > second {
t.Errorf("monitorID = %d, want lowest %d", got.Status.MonitorID, first)
}
if c := ready(t, got); c.Status != metav1.ConditionFalse || c.Reason != ReasonDuplicateOwner {
t.Errorf("Ready = %+v", c)
}
if e.kuma.Get(second) == nil {
t.Error("extra monitor must not be deleted by the reconciler")
}
}
func TestReconcileDeleteRemovesAllDuplicates(t *testing.T) {
km := deleting(httpMonitor("a"))
e := newEnv(t, km)
first, _ := e.kuma.Create(t.Context(), Desired(km))
second, _ := e.kuma.Create(t.Context(), Desired(km))
if _, err := e.reconcile(t, "a"); err != nil {
t.Fatal(err)
}
if e.kuma.Get(first) != nil || e.kuma.Get(second) != nil {
t.Error("all monitors carrying the key must be deleted")
}
if err := e.k8s.Get(t.Context(), client.ObjectKeyFromObject(km), &v1.KumaMonitor{}); !apierrors.IsNotFound(err) {
t.Errorf("finalizer not removed: %v", err)
}
}
func TestReconcileDeleteFailureIsVisibleInCondition(t *testing.T) {
km := deleting(httpMonitor("a"))
e := newEnv(t, km)
e.kuma.SetErrs(kumaclient.ErrOwnerTagMissing, nil, nil, nil)
if _, err := e.reconcile(t, "a"); !errors.Is(err, kumaclient.ErrOwnerTagMissing) {
t.Fatalf("err = %v", err)
}
got := e.get(t, "a")
if len(got.Finalizers) != 1 {
t.Errorf("finalizer must stay: %v", got.Finalizers)
}
if c := ready(t, got); c.Reason != ReasonOwnerTagMissing {
t.Errorf("Ready = %+v", c)
}
// Other delete failures: incomplete list without our key.
e2 := newEnv(t, deleting(httpMonitor("a")))
e2.kuma.SetListPartial(otherConflict())
if _, err := e2.reconcile(t, "a"); err == nil {
t.Fatal("deleting on an incomplete list must fail")
}
if err := e2.k8s.Get(t.Context(), client.ObjectKeyFromObject(km), &v1.KumaMonitor{}); err != nil {
t.Errorf("finalizer must stay: %v", err)
}
}
func group(name, parent string) *v1.KumaMonitor {
g := &v1.KumaMonitor{
ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: name},
Spec: v1.KumaMonitorSpec{Type: "group", DisplayName: name, Interval: 60, RetryInterval: 60},
Status: v1.KumaMonitorStatus{MonitorID: 100},
}
if parent != "" {
g.Spec.ParentRef = &v1.LocalRef{Name: parent}
}
return g
}
func TestReconcileInvalidParent(t *testing.T) {
notGroup := httpMonitor("web")
notGroup.Status.MonitorID = 5
selfRef := httpMonitor("self")
selfRef.Spec.ParentRef = &v1.LocalRef{Name: "self"}
cycleA, cycleB := group("ga", "gb"), group("gb", "ga")
underCycle := httpMonitor("underCycle")
underCycle.Spec.ParentRef = &v1.LocalRef{Name: "ga"}
// A chain of 12 groups is deeper than allowed.
var chain []client.Object
for i := range 12 {
parent := ""
if i < 11 {
parent = fmt.Sprintf("c%d", i+1)
}
chain = append(chain, group(fmt.Sprintf("c%d", i), parent))
}
deep := httpMonitor("deep")
deep.Spec.ParentRef = &v1.LocalRef{Name: "c0"}
underWeb := httpMonitor("underWeb")
underWeb.Spec.ParentRef = &v1.LocalRef{Name: "web"}
objs := append([]client.Object{notGroup, selfRef, cycleA, cycleB, underCycle, deep, underWeb}, chain...)
e := newEnv(t, objs...)
for _, name := range []string{"underWeb", "self", "underCycle", "deep"} {
res, err := e.reconcile(t, name)
if err != nil {
t.Fatalf("%s: %v", name, err)
}
if res.RequeueAfter != 5*time.Minute {
t.Errorf("%s: RequeueAfter = %v, want 5m", name, res.RequeueAfter)
}
if c := ready(t, e.get(t, name)); c.Status != metav1.ConditionFalse || c.Reason != ReasonInvalidParent {
t.Errorf("%s: Ready = %+v", name, c)
}
}
if e.cnt.creates != 0 {
t.Errorf("creates = %d", e.cnt.creates)
}
}
func TestReconcileNestedGroupsResolve(t *testing.T) {
child := httpMonitor("child")
child.Spec.ParentRef = &v1.LocalRef{Name: "inner"}
e := newEnv(t, group("outer", ""), group("inner", "outer"), child)
if _, err := e.reconcile(t, "child"); err != nil {
t.Fatal(err)
}
if e.cnt.creates != 1 {
t.Errorf("creates = %d", e.cnt.creates)
}
}
func TestReconcilePossibleOrphanStatus(t *testing.T) {
e := newEnv(t, httpMonitor("a"))
e.kuma.SetErrs(nil, fmt.Errorf("create: %w", kumaclient.ErrPossibleOrphan), nil, nil)
if _, err := e.reconcile(t, "a"); !errors.Is(err, kumaclient.ErrPossibleOrphan) {
t.Fatalf("err = %v", err)
}
c := ready(t, e.get(t, "a"))
if c.Status != metav1.ConditionFalse || c.Reason != ReasonPossibleOrphan {
t.Errorf("Ready = %+v", c)
}
}
@@ -0,0 +1,69 @@
package controller
import (
"maps"
"slices"
v1 "gitea.renner.uno/konairius/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/kumaclient"
)
// defaultMaxRedirects is Kuma's default for a monitor with no explicit value.
const defaultMaxRedirects = 10
// OwnerKey is the value of the kuma-sync tag for a resource.
func OwnerKey(km *v1.KumaMonitor) string {
return km.Namespace + "/" + km.Name
}
// Desired maps a spec to the monitor Kuma should hold. Parent is resolved by
// the reconciler.
func Desired(km *v1.KumaMonitor) kumaclient.Monitor {
s := km.Spec
m := kumaclient.Monitor{
Type: s.Type,
Name: s.DisplayName,
Description: s.Description,
Active: !s.Paused,
Interval: s.Interval,
RetryInterval: s.RetryInterval,
MaxRetries: s.MaxRetries,
OwnerKey: OwnerKey(km),
}
if s.Timeout != nil {
m.Timeout = *s.Timeout
}
if h := s.HTTP; h != nil {
m.URL = h.URL
m.Method = h.Method
m.AcceptedStatusCodes = slices.Clone(h.AcceptedStatusCodes)
m.Headers = maps.Clone(h.Headers)
m.Body = h.Body
m.MaxRedirects = defaultMaxRedirects
if h.MaxRedirects != nil {
m.MaxRedirects = *h.MaxRedirects
}
m.IgnoreTLS = h.IgnoreTLS
m.ExpiryNotification = h.ExpiryNotification
}
if k := s.Keyword; k != nil {
m.Keyword = k.Keyword
m.InvertKeyword = k.Invert
}
if q := s.JSONQuery; q != nil {
m.JSONPath = q.Expression
m.JSONPathOperator = q.Operator
m.ExpectedValue = q.Expected
}
if d := s.DNS; d != nil {
m.Hostname = d.Hostname
m.DNSResolveType = d.ResolveType
m.DNSResolveServer = d.ResolveServer
m.Port = d.Port
}
if p := s.Port; p != nil {
m.Hostname = p.Hostname
m.Port = p.Port
}
return m
}
@@ -0,0 +1,98 @@
package controller
import (
"reflect"
"testing"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
v1 "gitea.renner.uno/konairius/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/kumaclient"
)
func TestMappingAllTypes(t *testing.T) {
i32 := func(v int32) *int32 { return &v }
base := func(typ string) v1.KumaMonitorSpec {
return v1.KumaMonitorSpec{
Type: typ, DisplayName: "dn", Description: "desc",
Interval: 120, RetryInterval: 30, MaxRetries: 2,
}
}
httpSpec := &v1.HTTPSpec{
URL: "https://e.com", Method: "POST", AcceptedStatusCodes: []string{"200"},
Headers: map[string]string{"a": "b"}, Body: "x", MaxRedirects: i32(3),
IgnoreTLS: true, ExpiryNotification: true,
}
mk := func(s v1.KumaMonitorSpec) *v1.KumaMonitor {
return &v1.KumaMonitor{ObjectMeta: metav1.ObjectMeta{Namespace: "ns", Name: "n"}, Spec: s}
}
common := func(typ string) kumaclient.Monitor {
return kumaclient.Monitor{
Type: typ, Name: "dn", Description: "desc", Active: true,
Interval: 120, RetryInterval: 30, MaxRetries: 2, OwnerKey: "ns/n",
}
}
withHTTP := func(m kumaclient.Monitor) kumaclient.Monitor {
m.URL, m.Method, m.AcceptedStatusCodes = "https://e.com", "POST", []string{"200"}
m.Headers, m.Body, m.MaxRedirects = map[string]string{"a": "b"}, "x", 3
m.IgnoreTLS, m.ExpiryNotification, m.Timeout = true, true, 15
return m
}
group := base("group")
httpS := base("http")
httpS.HTTP, httpS.Timeout = httpSpec, i32(15)
kw := base("keyword")
kw.HTTP, kw.Timeout = httpSpec, i32(15)
kw.Keyword = &v1.KeywordSpec{Keyword: "ok", Invert: true}
jq := base("json-query")
jq.HTTP, jq.Timeout = httpSpec, i32(15)
jq.JSONQuery = &v1.JSONQuerySpec{Expression: "a.b", Operator: "contains", Expected: "z"}
dns := base("dns")
dns.DNS = &v1.DNSSpec{Hostname: "h.com", ResolveType: "AAAA", ResolveServer: "9.9.9.9", Port: 5353}
port := base("port")
port.Port = &v1.PortSpec{Hostname: "p.com", Port: 443}
paused := base("group")
paused.Paused = true
wantKW := withHTTP(common("keyword"))
wantKW.Keyword, wantKW.InvertKeyword = "ok", true
wantJQ := withHTTP(common("json-query"))
wantJQ.JSONPath, wantJQ.JSONPathOperator, wantJQ.ExpectedValue = "a.b", "contains", "z"
wantDNS := common("dns")
wantDNS.Hostname, wantDNS.DNSResolveType, wantDNS.DNSResolveServer, wantDNS.Port = "h.com", "AAAA", "9.9.9.9", 5353
wantPort := common("port")
wantPort.Hostname, wantPort.Port = "p.com", 443
wantPaused := common("group")
wantPaused.Active = false
cases := []struct {
name string
spec v1.KumaMonitorSpec
want kumaclient.Monitor
}{
{"group", group, common("group")},
{"http", httpS, withHTTP(common("http"))},
{"keyword", kw, wantKW},
{"json-query", jq, wantJQ},
{"dns", dns, wantDNS},
{"port", port, wantPort},
{"paused maps to inactive", paused, wantPaused},
}
for _, c := range cases {
got := Desired(mk(c.spec))
if !reflect.DeepEqual(got, c.want) {
t.Errorf("%s:\n got %+v\n want %+v", c.name, got, c.want)
}
}
}
func TestMappingMaxRedirectsDefault(t *testing.T) {
km := &v1.KumaMonitor{
ObjectMeta: metav1.ObjectMeta{Namespace: "ns", Name: "n"},
Spec: v1.KumaMonitorSpec{Type: "http", DisplayName: "d", HTTP: &v1.HTTPSpec{URL: "https://e.com"}},
}
if got := Desired(km).MaxRedirects; got != 10 {
t.Errorf("MaxRedirects = %d, want Kuma's default 10", got)
}
}
@@ -0,0 +1,618 @@
package kumaclient
import (
"context"
"encoding/json"
"errors"
"fmt"
"math"
"slices"
"strings"
"sync"
"time"
kuma "github.com/breml/go-uptime-kuma-client"
"github.com/breml/go-uptime-kuma-client/monitor"
"github.com/breml/go-uptime-kuma-client/notification"
"github.com/breml/go-uptime-kuma-client/tag"
)
const (
ownerTagName = "kuma-sync"
ownerTagColor = "#2563eb"
operationTimeout = 30 * time.Second
probeTimeout = 10 * time.Second
connectTimeout = 30 * time.Second
reconnectAttempts = 6
reconnectMaxDelay = 8 * time.Second
)
// Breml is a Client backed by github.com/breml/go-uptime-kuma-client.
// It serialises all calls on one session. The socket.io transport does not
// reconnect, so when a call fails and a probe shows the session is dead, the
// session is rebuilt (session token first, password as fallback; bounded
// backoff) and the call is retried once. Tag ownership caveats: the kuma-sync
// tag is the lowest-ID tag of that name, and the library clobbers monitor
// conditions and retryOnlyOnStatusCodeFailure on update.
type Breml struct {
mu sync.Mutex
c *kuma.Client
tagID int64
sessCtx context.Context
url string
user string
pass string
opts []kuma.Option
token string
backoff0 time.Duration
}
var _ Client = (*Breml)(nil)
// NewBreml connects and logs in once, then ensures the kuma-sync tag exists.
// ctx bounds the lifetime of the socket session, not just the connect call.
//
// After a Kuma restart or dropped connection, the next call that fails with a
// dead session reconnects and retries once. Create is the exception: it is
// never retried, because its outcome can be unknown. Two windows can leave an
// untagged duplicate in Kuma, each signalled by an error wrapping
// ErrPossibleOrphan (the controller should log it at error level):
// 1. Kuma stored the monitor but the ack was lost.
// 2. Kuma stored the monitor, then tagging and the cleanup delete failed.
//
// In both cases the untagged monitor is never adopted or touched, so it needs
// manual cleanup in the Kuma UI, and the next reconcile may create a tagged
// monitor beside it.
func NewBreml(ctx context.Context, url, username, password string) (Client, error) {
return newBreml(ctx, url, username, password)
}
func newBreml(ctx context.Context, url, username, password string, opts ...kuma.Option) (*Breml, error) {
opts = append([]kuma.Option{kuma.WithOperationTimeout(operationTimeout)}, opts...)
c, err := kuma.New(ctx, url, username, password, opts...)
if err != nil {
return nil, fmt.Errorf("connect to kuma: %w", err)
}
b := &Breml{
c: c, sessCtx: ctx, url: url, user: username, pass: password, opts: opts,
token: c.SessionToken(), backoff0: time.Second,
}
if b.tagID, err = b.ensureTag(ctx); err != nil {
_ = c.Disconnect()
return nil, err
}
return b, nil
}
// Close ends the session.
func (b *Breml) Close() error {
return b.c.Disconnect()
}
// pickOwnerTag returns the lowest-ID tag named kuma-sync. Kuma does not
// enforce unique tag names.
func pickOwnerTag(tags []tag.Tag) (int64, bool) {
var id int64
found := false
for _, t := range tags {
if t.Name == ownerTagName && (!found || t.ID < id) {
id, found = t.ID, true
}
}
return id, found
}
// ownerKeyOf returns the kuma-sync tag value of a monitor. Different values on
// one monitor are a conflict, never resolved by picking one.
// The error is a *ConflictError listing all distinct values.
func ownerKeyOf(monitorID int64, tags []tag.MonitorTag, tagID int64) (string, error) {
var keys []string
for _, t := range tags {
if t.TagID == tagID && t.Value != "" && !slices.Contains(keys, t.Value) {
keys = append(keys, t.Value)
}
}
switch len(keys) {
case 0:
return "", nil
case 1:
return keys[0], nil
}
slices.Sort(keys)
return "", &ConflictError{MonitorID: monitorID, Keys: keys}
}
// createdDespite reports whether a CreateMonitor result means the monitor
// exists. The library returns the ID together with ErrUpdateEventTimeout when
// the monitor was created and only the update event is missing (library
// monitor.go: CreateMonitor doc and the errors.Is(err, ErrUpdateEventTimeout)
// branch).
func createdDespite(id int64, err error) bool {
return err == nil || (id != 0 && errors.Is(err, kuma.ErrUpdateEventTimeout))
}
func (b *Breml) ensureTag(ctx context.Context) (int64, error) {
tags, err := b.c.GetTags(ctx)
if err != nil {
return 0, err
}
if id, ok := pickOwnerTag(tags); ok {
return id, nil
}
return b.c.CreateTag(ctx, tag.Tag{Name: ownerTagName, Color: ownerTagColor})
}
// recoverSession reports whether the session was rebuilt after err, so the
// caller may retry. Application-level errors and a probe on a healthy session
// return false.
func (b *Breml) recoverSession(ctx context.Context, err error) bool {
rebuilt, _ := b.checkSession(ctx, err)
return rebuilt
}
// checkSession probes the session after err. dead reports that the probe
// failed, i.e. err was a transport failure; rebuilt that it was then replaced.
func (b *Breml) checkSession(ctx context.Context, err error) (rebuilt, dead bool) {
if ctx.Err() != nil {
return false, false
}
for _, app := range []error{ErrNotFound, ErrNotOwned, ErrOwnerTagMissing, ErrOwnerConflict} {
if errors.Is(err, app) {
return false, false
}
}
pctx, cancel := context.WithTimeout(ctx, probeTimeout)
defer cancel()
if _, perr := b.c.GetTags(pctx); perr == nil {
return false, false
}
return b.reconnect(ctx) == nil, true
}
func (b *Breml) reconnect(ctx context.Context) error {
delay := b.backoff0
var last error
for attempt := 0; attempt < reconnectAttempts; attempt++ {
if attempt > 0 {
select {
case <-time.After(delay):
case <-ctx.Done():
return ctx.Err()
case <-b.sessCtx.Done():
return b.sessCtx.Err()
}
delay = min(delay*2, reconnectMaxDelay)
}
opts := append(slices.Clone(b.opts), kuma.WithConnectTimeout(connectTimeout))
if b.token != "" {
opts = append(opts, kuma.WithSessionToken(b.token))
}
c, err := kuma.New(b.sessCtx, b.url, b.user, b.pass, opts...)
if err != nil {
last = err
continue
}
_ = b.c.Disconnect()
b.c = c
if t := c.SessionToken(); t != "" {
b.token = t
}
return nil
}
return fmt.Errorf("reconnect to kuma: %w", last)
}
// withRecovery runs op and, if it fails on a dead session, retries it once on
// a rebuilt one.
func withRecovery[T any](ctx context.Context, b *Breml, op func() (T, error)) (T, error) {
v, err := op()
if err != nil && b.recoverSession(ctx, err) {
return op()
}
return v, err
}
func (b *Breml) ListOwned(ctx context.Context) ([]Monitor, error) {
b.mu.Lock()
defer b.mu.Unlock()
return withRecovery(ctx, b, func() ([]Monitor, error) { return b.listOwned(ctx) })
}
func (b *Breml) listOwned(ctx context.Context) ([]Monitor, error) {
tags, err := b.c.GetTags(ctx)
if err != nil {
return nil, err
}
if !slices.ContainsFunc(tags, func(t tag.Tag) bool { return t.ID == b.tagID && t.Name == ownerTagName }) {
return nil, ErrOwnerTagMissing
}
bases, err := b.c.GetMonitors(ctx)
if err != nil {
return nil, err
}
var out []Monitor
var conflicts []error
for _, base := range bases {
key, err := ownerKeyOf(base.ID, base.Tags, b.tagID)
if err != nil {
conflicts = append(conflicts, err)
continue
}
if key == "" {
continue
}
m, err := toDomain(base, key)
if err != nil {
return nil, err
}
out = append(out, m.Normalized())
}
return out, errors.Join(conflicts...)
}
// Create is never retried. Two windows can leave an untagged monitor in Kuma,
// both signalled by an error wrapping ErrPossibleOrphan:
// 1. Kuma stored the monitor but the ack was lost (transport or operation
// timeout error, no ID returned).
// 2. Kuma stored the monitor, then tagging failed and removing it failed too.
//
// Either way an untagged duplicate may exist; the controller never touches
// untagged monitors, so it needs manual cleanup in the Kuma UI. The next
// reconcile may create a tagged monitor beside it.
func (b *Breml) Create(ctx context.Context, m Monitor) (int64, error) {
if m.OwnerKey == "" {
return 0, errors.New("create: empty OwnerKey")
}
b.mu.Lock()
defer b.mu.Unlock()
return flagUnknownCreate(
func() (int64, error) { return b.createOnce(ctx, m) },
func(err error) bool { _, dead := b.checkSession(ctx, err); return dead },
)
}
// flagUnknownCreate runs create once. A failure without an ID that is a
// transport or operation-timeout error leaves the outcome unknown, so it is
// wrapped in ErrPossibleOrphan instead of being retried.
func flagUnknownCreate(create func() (int64, error), sessionDead func(error) bool) (int64, error) {
id, err := create()
if err == nil || id != 0 || errors.Is(err, ErrPossibleOrphan) {
return id, err
}
if errors.Is(err, kuma.ErrOperationTimeout) || sessionDead(err) {
return 0, errors.Join(err, fmt.Errorf("create outcome unknown: %w", ErrPossibleOrphan))
}
return 0, err
}
func (b *Breml) createOnce(ctx context.Context, m Monitor) (int64, error) {
wire, err := toWire(m, nil)
if err != nil {
return 0, err
}
id, err := b.c.CreateMonitor(ctx, wire)
if !createdDespite(id, err) {
return 0, err
}
// Here the monitor exists, even if err is an update-event timeout.
if _, terr := b.c.AddMonitorTag(ctx, b.tagID, id, m.OwnerKey); terr != nil {
// An untagged leftover would be invisible to ListOwned.
if derr := b.c.DeleteMonitor(ctx, id); derr != nil {
return 0, errors.Join(terr, fmt.Errorf("remove untagged monitor %d: %w", id, derr),
fmt.Errorf("monitor %d: %w", id, ErrPossibleOrphan))
}
return 0, terr
}
return id, nil
}
// lookupOwned returns the current monitor and verifies the owner tag.
func (b *Breml) lookupOwned(ctx context.Context, op string, id int64) (monitor.Base, string, error) {
bases, err := b.c.GetMonitors(ctx)
if err != nil {
return monitor.Base{}, "", err
}
for _, base := range bases {
if base.ID != id {
continue
}
key, err := ownerKeyOf(base.ID, base.Tags, b.tagID)
if err != nil {
return monitor.Base{}, "", fmt.Errorf("%s %d: %w", op, id, err)
}
if key == "" {
return monitor.Base{}, "", fmt.Errorf("%s %d: %w", op, id, ErrNotOwned)
}
return base, key, nil
}
return monitor.Base{}, "", fmt.Errorf("%s %d: %w", op, id, ErrNotFound)
}
type probeResult struct {
found bool
key string
}
// Probe reports whether a monitor with this ID exists and which owner key it
// carries; an untagged monitor has "". Conflicting tag values return the
// *ConflictError.
func (b *Breml) Probe(ctx context.Context, id int64) (bool, string, error) {
b.mu.Lock()
defer b.mu.Unlock()
res, err := withRecovery(ctx, b, func() (probeResult, error) {
bases, err := b.c.GetMonitors(ctx)
if err != nil {
return probeResult{}, err
}
for _, base := range bases {
if base.ID != id {
continue
}
key, err := ownerKeyOf(base.ID, base.Tags, b.tagID)
return probeResult{found: true, key: key}, err
}
return probeResult{}, nil
})
return res.found, res.key, err
}
func (b *Breml) Update(ctx context.Context, m Monitor) error {
b.mu.Lock()
defer b.mu.Unlock()
_, err := withRecovery(ctx, b, func() (struct{}, error) { return struct{}{}, b.update(ctx, m) })
return err
}
func (b *Breml) update(ctx context.Context, m Monitor) error {
cur, key, err := b.lookupOwned(ctx, "update", m.ID)
if err != nil {
return err
}
if key != m.OwnerKey {
return fmt.Errorf("update %d: monitor carries owner key %q, not %q: %w", m.ID, key, m.OwnerKey, ErrNotOwned)
}
// Start from the full current object, also across a type change, so
// fields we do not manage (proxy, resend interval, upside down, auth)
// survive the overwrite.
wire, err := toWire(m, &cur)
if err != nil {
return err
}
if err := b.c.UpdateMonitor(ctx, wire); err != nil {
return err
}
// editMonitor does not change the active state.
switch {
case cur.IsActive && !m.Active:
return b.c.PauseMonitor(ctx, m.ID)
case !cur.IsActive && m.Active:
return b.c.ResumeMonitor(ctx, m.ID)
}
return nil
}
func (b *Breml) Delete(ctx context.Context, id int64) error {
b.mu.Lock()
defer b.mu.Unlock()
_, err := withRecovery(ctx, b, func() (struct{}, error) {
if _, _, err := b.lookupOwned(ctx, "delete", id); err != nil {
return struct{}{}, err
}
return struct{}{}, b.c.DeleteMonitor(ctx, id)
})
return err
}
// DefaultNotifications reads the notification list cached by the session.
// Kuma has no command to request it: it pushes the list at login and to every
// session of the user after each change.
func (b *Breml) DefaultNotifications(ctx context.Context) ([]int64, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
b.mu.Lock()
defer b.mu.Unlock()
return defaultIDs(b.c.GetNotifications(ctx)), nil
}
func defaultIDs(list []notification.Base) []int64 {
var ids []int64
for _, n := range list {
if n.IsDefault {
ids = append(ids, n.ID)
}
}
slices.Sort(ids)
return ids
}
// toDomain converts a monitor from the Kuma monitor list.
func toDomain(base monitor.Base, ownerKey string) (Monitor, error) {
m := Monitor{
ID: base.ID,
Type: base.Type(),
Name: base.Name,
Parent: base.Parent,
Active: base.IsActive,
Interval: int32(base.Interval),
RetryInterval: int32(base.RetryInterval),
MaxRetries: int32(base.MaxRetries),
NotificationIDs: slices.Clone(base.NotificationIDs),
OwnerKey: ownerKey,
}
if base.Description != nil {
m.Description = *base.Description
}
switch m.Type {
case "http":
var v monitor.HTTP
if err := base.As(&v); err != nil {
return Monitor{}, err
}
if err := readHTTP(&m, v.HTTPDetails); err != nil {
return Monitor{}, err
}
case "keyword":
var v monitor.HTTPKeyword
if err := base.As(&v); err != nil {
return Monitor{}, err
}
if err := readHTTP(&m, v.HTTPDetails); err != nil {
return Monitor{}, err
}
m.Keyword = v.Keyword
m.InvertKeyword = v.InvertKeyword
case "json-query":
var v monitor.HTTPJSONQuery
if err := base.As(&v); err != nil {
return Monitor{}, err
}
if err := readHTTP(&m, v.HTTPDetails); err != nil {
return Monitor{}, err
}
m.JSONPath = v.JSONPath
m.JSONPathOperator = v.JSONPathOperator
m.ExpectedValue = v.ExpectedValue
case "dns":
var v monitor.DNS
if err := base.As(&v); err != nil {
return Monitor{}, err
}
m.Hostname = v.Hostname
m.Port = int32(v.Port)
m.DNSResolveType = string(v.ResolveType)
m.DNSResolveServer = v.ResolverServer
case "port":
var v monitor.TCPPort
if err := base.As(&v); err != nil {
return Monitor{}, err
}
m.Hostname = v.Hostname
m.Port = int32(v.Port)
}
return m, nil
}
func readHTTP(m *Monitor, d monitor.HTTPDetails) error {
m.URL = d.URL
m.Method = d.Method
m.Body = d.Body
m.AcceptedStatusCodes = slices.Clone(d.AcceptedStatusCodes)
m.MaxRedirects = int32(d.MaxRedirects)
m.IgnoreTLS = d.IgnoreTLS
m.ExpiryNotification = d.ExpiryNotification
m.Timeout = int32(math.Round(d.Timeout))
if strings.TrimSpace(d.Headers) != "" {
if err := json.Unmarshal([]byte(d.Headers), &m.Headers); err != nil {
return fmt.Errorf("monitor %d: parse headers: %w", m.ID, err)
}
}
return nil
}
// toWire builds the library monitor for m. With existing set, the typed
// object is first decoded from it so fields we do not manage are kept.
func toWire(m Monitor, existing *monitor.Base) (monitor.Monitor, error) {
m = m.Normalized()
load := func(target any) error {
if existing == nil {
return nil
}
return existing.As(target)
}
switch m.Type {
case "group":
v := &monitor.Group{}
if err := load(v); err != nil {
return nil, err
}
writeBase(&v.Base, m)
return v, nil
case "http":
v := &monitor.HTTP{}
if err := load(v); err != nil {
return nil, err
}
writeBase(&v.Base, m)
return v, writeHTTP(&v.HTTPDetails, m)
case "keyword":
v := &monitor.HTTPKeyword{}
if err := load(v); err != nil {
return nil, err
}
writeBase(&v.Base, m)
v.Keyword = m.Keyword
v.InvertKeyword = m.InvertKeyword
return v, writeHTTP(&v.HTTPDetails, m)
case "json-query":
v := &monitor.HTTPJSONQuery{}
if err := load(v); err != nil {
return nil, err
}
writeBase(&v.Base, m)
v.JSONPath = m.JSONPath
v.JSONPathOperator = m.JSONPathOperator
v.ExpectedValue = m.ExpectedValue
return v, writeHTTP(&v.HTTPDetails, m)
case "dns":
v := &monitor.DNS{}
if err := load(v); err != nil {
return nil, err
}
writeBase(&v.Base, m)
v.Hostname = m.Hostname
v.Port = int(m.Port)
v.ResolveType = monitor.DNSResolveType(m.DNSResolveType)
v.ResolverServer = m.DNSResolveServer
return v, nil
case "port":
v := &monitor.TCPPort{}
if err := load(v); err != nil {
return nil, err
}
writeBase(&v.Base, m)
v.Hostname = m.Hostname
v.Port = int(m.Port)
return v, nil
}
return nil, fmt.Errorf("unsupported monitor type %q", m.Type)
}
func writeBase(b *monitor.Base, m Monitor) {
b.ID = m.ID
b.Name = m.Name
desc := m.Description
b.Description = &desc
b.Parent = m.clone().Parent
b.Interval = int64(m.Interval)
b.RetryInterval = int64(m.RetryInterval)
b.MaxRetries = int64(m.MaxRetries)
b.IsActive = m.Active
b.NotificationIDs = slices.Clone(m.NotificationIDs)
}
// writeHTTP expects m to be normalized.
func writeHTTP(d *monitor.HTTPDetails, m Monitor) error {
d.URL = m.URL
d.Timeout = float64(m.Timeout)
d.ExpiryNotification = m.ExpiryNotification
d.IgnoreTLS = m.IgnoreTLS
d.MaxRedirects = int(m.MaxRedirects)
d.AcceptedStatusCodes = slices.Clone(m.AcceptedStatusCodes)
d.Method = m.Method
d.Body = m.Body
if d.HTTPBodyEncoding == "" {
d.HTTPBodyEncoding = "json"
}
d.Headers = ""
if len(m.Headers) > 0 {
raw, err := json.Marshal(m.Headers)
if err != nil {
return fmt.Errorf("encode headers: %w", err)
}
d.Headers = string(raw)
}
return nil
}
@@ -0,0 +1,624 @@
//go:build integration
package kumaclient
import (
"context"
"errors"
"io"
"net"
"reflect"
"testing"
"time"
kuma "github.com/breml/go-uptime-kuma-client"
"github.com/breml/go-uptime-kuma-client/monitor"
"github.com/breml/go-uptime-kuma-client/notification"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
)
const (
testUser = "admin"
testPass = "Sup3r-Secret-Passw0rd!"
)
// startKumaCtr runs a fresh Uptime Kuma and returns it with its base URL.
func startKumaCtr(t *testing.T) (testcontainers.Container, string) {
t.Helper()
ctx := context.Background()
ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: testcontainers.ContainerRequest{
Image: "louislam/uptime-kuma:2.5.6-slim",
ExposedPorts: []string{"3001/tcp"},
Env: map[string]string{"UPTIME_KUMA_DB_TYPE": "sqlite"},
WaitingFor: wait.ForHTTP("/api/entry-page").WithPort("3001/tcp").
WithStatusCodeMatcher(func(s int) bool { return s == 200 }).
WithStartupTimeout(3 * time.Minute),
},
Started: true,
})
if err != nil {
t.Fatalf("start kuma: %v", err)
}
t.Cleanup(func() { _ = ctr.Terminate(context.Background()) })
url, err := ctr.PortEndpoint(ctx, "3001/tcp", "http")
if err != nil {
t.Fatal(err)
}
return ctr, url
}
func startKuma(t *testing.T) string {
t.Helper()
_, url := startKumaCtr(t)
return url
}
// startProxy forwards a stable local address to the container's current
// mapped port, which can change when the container restarts.
func startProxy(t *testing.T, ctr testcontainers.Container) string {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = ln.Close() })
go func() {
for {
down, err := ln.Accept()
if err != nil {
return
}
go func() {
target, err := ctr.PortEndpoint(context.Background(), "3001/tcp", "")
if err != nil {
_ = down.Close()
return
}
up, err := net.Dial("tcp", target)
if err != nil {
_ = down.Close()
return
}
go func() { _, _ = io.Copy(up, down); _ = up.Close() }()
_, _ = io.Copy(down, up)
_ = down.Close()
}()
}
}()
return "http://" + ln.Addr().String()
}
// setupClient creates the admin account on first connect.
func setupClient(t *testing.T, url string) *Breml {
t.Helper()
b, err := newBreml(t.Context(), url, testUser, testPass, kuma.WithAutosetup(), kuma.WithConnectTimeout(2*time.Minute))
if err != nil {
t.Fatalf("setup client: %v", err)
}
t.Cleanup(func() { _ = b.Close() })
return b
}
// freshClient logs in again, as a restarted controller would.
func freshClient(t *testing.T, url string) *Breml {
t.Helper()
b, err := newBreml(t.Context(), url, testUser, testPass, kuma.WithConnectTimeout(time.Minute))
if err != nil {
t.Fatalf("fresh client: %v", err)
}
t.Cleanup(func() { _ = b.Close() })
return b
}
func byID(list []Monitor) map[int64]Monitor {
out := map[int64]Monitor{}
for _, m := range list {
out[m.ID] = m
}
return out
}
func TestCreateFieldsIntact(t *testing.T) {
ctx := context.Background()
url := startKuma(t)
b := setupClient(t, url)
group := Monitor{
Type: "group", Name: "grp", Description: "a group", Active: true,
Interval: 60, RetryInterval: 60, MaxRetries: 0, OwnerKey: "default/grp",
}
gid, err := b.Create(ctx, group)
if err != nil {
t.Fatal(err)
}
group.ID = gid
httpFields := func(m Monitor) Monitor {
m.Active = true
m.Interval, m.RetryInterval, m.MaxRetries, m.Timeout = 120, 30, 2, 25
m.URL = "https://example.com/health"
m.Method = "POST"
m.Body = `{"a":1}`
m.Headers = map[string]string{"X-One": "1", "X-Two": "2"}
m.AcceptedStatusCodes = []string{"200-299", "301"}
m.MaxRedirects = 5
m.IgnoreTLS = true
m.ExpiryNotification = true
m.Parent = &gid
return m
}
keyword := httpFields(Monitor{Type: "keyword", Name: "kw", Description: "kw d", OwnerKey: "default/kw"})
keyword.Keyword = "healthy"
keyword.InvertKeyword = true
jq := httpFields(Monitor{Type: "json-query", Name: "jq", OwnerKey: "default/jq"})
jq.JSONPath = "status"
jq.JSONPathOperator = "contains"
jq.ExpectedValue = "ok"
dns := Monitor{
Type: "dns", Name: "dns", Description: "dns d", Active: true,
Interval: 90, RetryInterval: 45, MaxRetries: 1, Parent: &gid, OwnerKey: "default/dns",
Hostname: "example.com", DNSResolveType: "AAAA", DNSResolveServer: "1.1.1.1", Port: 53,
}
port := Monitor{
Type: "port", Name: "port", Active: false,
Interval: 60, RetryInterval: 60, MaxRetries: 3, OwnerKey: "default/port",
Hostname: "example.com", Port: 443,
}
want := map[int64]Monitor{gid: group.Normalized()}
for _, m := range []*Monitor{&keyword, &jq, &dns, &port} {
id, err := b.Create(ctx, *m)
if err != nil {
t.Fatalf("create %s: %v", m.Type, err)
}
m.ID = id
want[id] = m.Normalized()
}
got, err := freshClient(t, url).ListOwned(ctx)
if err != nil {
t.Fatal(err)
}
gotByID := byID(got)
if len(gotByID) != len(want) {
t.Fatalf("got %d monitors, want %d: %+v", len(gotByID), len(want), got)
}
for id, w := range want {
if !reflect.DeepEqual(gotByID[id], w) {
t.Errorf("%s mismatch:\n got %+v\n want %+v", w.Type, gotByID[id], w)
}
}
}
func TestDefaultTimeoutReadsBack(t *testing.T) {
ctx := context.Background()
url := startKuma(t)
b := setupClient(t, url)
kw := Monitor{
Type: "keyword", Name: "kw", Active: true, Interval: 60, RetryInterval: 60,
URL: "https://example.com", Keyword: "ok", OwnerKey: "default/kw",
}
id, err := b.Create(ctx, kw)
if err != nil {
t.Fatal(err)
}
got, err := freshClient(t, url).ListOwned(ctx)
if err != nil {
t.Fatal(err)
}
m := byID(got)[id]
if m.Timeout != 48 || m.Method != "GET" || !reflect.DeepEqual(m.AcceptedStatusCodes, []string{"200-299"}) {
t.Errorf("defaults not applied on read-back: %+v", m)
}
kw.ID = id
if !reflect.DeepEqual(m, kw.Normalized()) {
t.Errorf("read-back differs from Normalized():\n got %+v\n want %+v", m, kw.Normalized())
}
}
func TestUpdateKeepsUnchangedFields(t *testing.T) {
ctx := context.Background()
url := startKuma(t)
b := setupClient(t, url)
gid, err := b.Create(ctx, Monitor{Type: "group", Name: "grp", Active: true, Interval: 60, RetryInterval: 60, OwnerKey: "default/grp"})
if err != nil {
t.Fatal(err)
}
orig := Monitor{
Type: "keyword", Name: "kw", Active: true, Parent: &gid,
Interval: 60, RetryInterval: 60, MaxRetries: 1, Timeout: 30,
URL: "https://example.com", Method: "GET",
AcceptedStatusCodes: []string{"200-299", "404"},
Keyword: "up", ExpiryNotification: true, OwnerKey: "default/kw",
}
id, err := b.Create(ctx, orig)
if err != nil {
t.Fatal(err)
}
orig.ID = id
updated := orig
updated.Interval = 120
if err := b.Update(ctx, updated); err != nil {
t.Fatal(err)
}
got, err := freshClient(t, url).ListOwned(ctx)
if err != nil {
t.Fatal(err)
}
m, ok := byID(got)[id]
if !ok {
t.Fatalf("monitor %d missing after update", id)
}
if m.Interval != 120 {
t.Errorf("interval = %d, want 120", m.Interval)
}
if m.Parent == nil || *m.Parent != gid {
t.Errorf("parent = %v, want %d", m.Parent, gid)
}
if !reflect.DeepEqual(m.AcceptedStatusCodes, orig.AcceptedStatusCodes) {
t.Errorf("acceptedStatusCodes = %v", m.AcceptedStatusCodes)
}
if m.Keyword != "up" {
t.Errorf("keyword = %q", m.Keyword)
}
if !m.ExpiryNotification {
t.Error("expiryNotification lost")
}
if m.OwnerKey != "default/kw" {
t.Errorf("ownerKey = %q", m.OwnerKey)
}
updated.Active = false
if err := b.Update(ctx, updated); err != nil {
t.Fatal(err)
}
got, err = freshClient(t, url).ListOwned(ctx)
if err != nil {
t.Fatal(err)
}
if byID(got)[id].Active {
t.Error("monitor still active after pausing update")
}
}
func createWebhook(t *testing.T, b *Breml, name string, isDefault bool) int64 {
t.Helper()
nid, err := b.c.CreateNotification(context.Background(), &notification.Webhook{
Base: notification.Base{Name: name, IsActive: true, IsDefault: isDefault},
WebhookDetails: notification.WebhookDetails{
WebhookURL: "http://example.com/hook", WebhookContentType: "application/json",
},
})
if err != nil {
t.Fatalf("create notification: %v", err)
}
return nid
}
// setUnmanaged sets state through the raw library that Monitor does not
// model, and attaches a notification by hand.
func setUnmanaged(t *testing.T, b *Breml, id int64) (notificationID int64) {
t.Helper()
ctx := context.Background()
nid := createWebhook(t, b, "n", false)
var kw monitor.HTTPKeyword
if err := b.c.GetMonitorAs(ctx, id, &kw); err != nil {
t.Fatal(err)
}
kw.UpsideDown = true
kw.ResendInterval = 7
kw.NotificationIDs = []int64{nid}
if err := b.c.UpdateMonitor(ctx, &kw); err != nil {
t.Fatal(err)
}
return nid
}
func assertUnmanaged(t *testing.T, c *Breml, id int64, when string) {
t.Helper()
var base monitor.Base
base, err := c.c.GetMonitor(t.Context(), id)
if err != nil {
t.Fatal(err)
}
if !base.UpsideDown || base.ResendInterval != 7 {
t.Errorf("%s: unmanaged state lost: upsideDown=%v resend=%d", when, base.UpsideDown, base.ResendInterval)
}
}
func TestUpdatePreservesUnmanagedAndClearsManaged(t *testing.T) {
ctx := context.Background()
url := startKuma(t)
b := setupClient(t, url)
gid, err := b.Create(ctx, Monitor{Type: "group", Name: "grp", Active: true, Interval: 60, RetryInterval: 60, OwnerKey: "default/grp"})
if err != nil {
t.Fatal(err)
}
orig := Monitor{
Type: "keyword", Name: "kw", Active: true, Parent: &gid,
Interval: 60, RetryInterval: 60, URL: "https://example.com",
Headers: map[string]string{"A": "1"}, Keyword: "up", OwnerKey: "default/kw",
}
id, err := b.Create(ctx, orig)
if err != nil {
t.Fatal(err)
}
nid := setUnmanaged(t, b, id)
// Clear managed fields.
cleared := orig
cleared.ID = id
cleared.Parent = nil
cleared.Headers = nil
cleared.Keyword = ""
cleared.Interval = 90
if err := b.Update(ctx, cleared); err != nil {
t.Fatal(err)
}
fresh := freshClient(t, url)
got, err := fresh.ListOwned(ctx)
if err != nil {
t.Fatal(err)
}
m := byID(got)[id]
if m.Parent != nil || m.Headers != nil || m.Keyword != "" || m.Interval != 90 || m.NotificationIDs != nil {
t.Errorf("managed fields not cleared: %+v", m)
}
assertUnmanaged(t, fresh, id, "after plain update")
// Type change must not wipe unmanaged state either.
changed := cleared
changed.Type = "http"
changed.NotificationIDs = []int64{nid}
if err := b.Update(ctx, changed); err != nil {
t.Fatal(err)
}
got, err = fresh.ListOwned(ctx)
if err != nil {
t.Fatal(err)
}
if m := byID(got)[id]; m.Type != "http" || !reflect.DeepEqual(m.NotificationIDs, []int64{nid}) {
t.Errorf("type = %q, notifications = %v, want http and [%d]", m.Type, m.NotificationIDs, nid)
}
assertUnmanaged(t, fresh, id, "after type change")
}
func TestDefaultNotificationsAndWrite(t *testing.T) {
ctx := context.Background()
url := startKuma(t)
b := setupClient(t, url)
other := freshClient(t, url)
if ids, err := other.DefaultNotifications(ctx); err != nil || ids != nil {
t.Fatalf("no notifications: ids=%v err=%v", ids, err)
}
def := createWebhook(t, b, "default", true)
createWebhook(t, b, "opt-in", false)
// Kuma pushes the list to every session of the user, not only the writer.
deadline := time.Now().Add(10 * time.Second)
for {
ids, err := other.DefaultNotifications(ctx)
if err != nil {
t.Fatal(err)
}
if reflect.DeepEqual(ids, []int64{def}) {
break
}
if time.Now().After(deadline) {
t.Fatalf("other session sees defaults %v, want [%d]", ids, def)
}
time.Sleep(100 * time.Millisecond)
}
m := Monitor{
Type: "group", Name: "grp", Active: true, Interval: 60, RetryInterval: 60,
NotificationIDs: []int64{def}, OwnerKey: "default/grp",
}
id, err := b.Create(ctx, m)
if err != nil {
t.Fatal(err)
}
got, err := freshClient(t, url).ListOwned(ctx)
if err != nil {
t.Fatal(err)
}
m.ID = id
if !reflect.DeepEqual(byID(got)[id], m.Normalized()) {
t.Errorf("read-back differs:\n got %+v\n want %+v", byID(got)[id], m.Normalized())
}
}
func TestRestartAdoptsByTag(t *testing.T) {
ctx := context.Background()
url := startKuma(t)
first := setupClient(t, url)
ids := map[int64]string{}
for _, key := range []string{"ns1/a", "ns2/b", "ns1/c"} {
id, err := first.Create(ctx, Monitor{
Type: "port", Name: key, Active: true, Interval: 60, RetryInterval: 60,
Hostname: "example.com", Port: 80, OwnerKey: key,
})
if err != nil {
t.Fatal(err)
}
ids[id] = key
}
if err := first.Close(); err != nil {
t.Fatal(err)
}
got, err := freshClient(t, url).ListOwned(ctx)
if err != nil {
t.Fatal(err)
}
gotKeys := map[int64]string{}
for _, m := range got {
gotKeys[m.ID] = m.OwnerKey
}
if !reflect.DeepEqual(gotKeys, ids) {
t.Fatalf("got %v, want %v", gotKeys, ids)
}
}
func TestUntaggedMonitorUntouched(t *testing.T) {
ctx := context.Background()
url := startKuma(t)
b := setupClient(t, url)
foreign := &monitor.TCPPort{}
foreign.Name = "foreign"
foreign.Interval, foreign.RetryInterval = 60, 60
foreign.IsActive = true
foreign.Hostname, foreign.Port = "example.com", 80
fid, err := b.c.CreateMonitor(ctx, foreign)
if err != nil {
t.Fatal(err)
}
mine, err := b.Create(ctx, Monitor{
Type: "port", Name: "mine", Active: true, Interval: 60, RetryInterval: 60,
Hostname: "example.com", Port: 80, OwnerKey: "default/mine",
})
if err != nil {
t.Fatal(err)
}
if err := b.Update(ctx, Monitor{ID: mine, Type: "port", Name: "x", Hostname: "example.com", Port: 80, OwnerKey: "default/other"}); !errors.Is(err, ErrNotOwned) {
t.Errorf("update with a different owner key: want ErrNotOwned, got %v", err)
}
if found, key, err := b.Probe(ctx, fid); err != nil || !found || key != "" {
t.Errorf("probe untagged: %v %q %v", found, key, err)
}
if found, key, err := b.Probe(ctx, mine); err != nil || !found || key != "default/mine" {
t.Errorf("probe owned: %v %q %v", found, key, err)
}
if found, _, err := b.Probe(ctx, 99999); err != nil || found {
t.Errorf("probe unknown: %v %v", found, err)
}
got, err := b.ListOwned(ctx)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0].Name != "mine" {
t.Fatalf("ListOwned = %+v", got)
}
if err := b.Update(ctx, Monitor{ID: fid, Type: "port", Name: "hijacked", Hostname: "example.com", Port: 80}); !errors.Is(err, ErrNotOwned) {
t.Errorf("update: want ErrNotOwned, got %v", err)
}
if err := b.Delete(ctx, fid); !errors.Is(err, ErrNotOwned) {
t.Errorf("delete: want ErrNotOwned, got %v", err)
}
if err := b.Delete(ctx, 99999); !errors.Is(err, ErrNotFound) {
t.Errorf("delete unknown: want ErrNotFound, got %v", err)
}
if err := b.Update(ctx, Monitor{ID: 99999, Type: "port"}); !errors.Is(err, ErrNotFound) {
t.Errorf("update unknown: want ErrNotFound, got %v", err)
}
still, err := b.c.GetMonitor(ctx, fid)
if err != nil {
t.Fatal(err)
}
if still.Name != "foreign" {
t.Errorf("foreign monitor changed: %q", still.Name)
}
}
func TestOwnerTagMissing(t *testing.T) {
ctx := context.Background()
url := startKuma(t)
b := setupClient(t, url)
if _, err := b.Create(ctx, Monitor{
Type: "port", Name: "mine", Active: true, Interval: 60, RetryInterval: 60,
Hostname: "example.com", Port: 80, OwnerKey: "default/mine",
}); err != nil {
t.Fatal(err)
}
if err := b.c.DeleteTag(ctx, b.tagID); err != nil {
t.Fatal(err)
}
got, err := b.ListOwned(ctx)
if !errors.Is(err, ErrOwnerTagMissing) || got != nil {
t.Fatalf("got %+v, %v; want nil list and ErrOwnerTagMissing", got, err)
}
}
func TestOwnerConflictExcluded(t *testing.T) {
ctx := context.Background()
url := startKuma(t)
b := setupClient(t, url)
create := func(key string) int64 {
id, err := b.Create(ctx, Monitor{
Type: "port", Name: key, Active: true, Interval: 60, RetryInterval: 60,
Hostname: "example.com", Port: 80, OwnerKey: key,
})
if err != nil {
t.Fatal(err)
}
return id
}
good, bad := create("default/good"), create("default/bad")
if _, err := b.c.AddMonitorTag(ctx, b.tagID, bad, "default/other"); err != nil {
t.Fatal(err)
}
got, err := b.ListOwned(ctx)
if !errors.Is(err, ErrOwnerConflict) {
t.Fatalf("want ErrOwnerConflict, got %v", err)
}
if len(got) != 1 || got[0].ID != good {
t.Fatalf("conflicting monitor must be excluded: %+v", got)
}
if err := b.Delete(ctx, bad); !errors.Is(err, ErrOwnerConflict) {
t.Errorf("delete of conflicting monitor: want ErrOwnerConflict, got %v", err)
}
if err := b.Update(ctx, Monitor{ID: bad, Type: "port", Name: "x", Hostname: "example.com", Port: 80}); !errors.Is(err, ErrOwnerConflict) {
t.Errorf("update of conflicting monitor: want ErrOwnerConflict, got %v", err)
}
if _, _, err := b.Probe(ctx, bad); !errors.Is(err, ErrOwnerConflict) {
t.Errorf("probe of conflicting monitor: want ErrOwnerConflict, got %v", err)
}
}
func TestReconnectAfterKumaRestart(t *testing.T) {
ctr, _ := startKumaCtr(t)
url := startProxy(t, ctr)
b := setupClient(t, url)
id, err := b.Create(t.Context(), Monitor{
Type: "port", Name: "survivor", Active: true, Interval: 60, RetryInterval: 60,
Hostname: "example.com", Port: 80, OwnerKey: "default/survivor",
})
if err != nil {
t.Fatal(err)
}
stop := 10 * time.Second
if err := ctr.Stop(t.Context(), &stop); err != nil {
t.Fatal(err)
}
if err := ctr.Start(t.Context()); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(t.Context(), 60*time.Second)
defer cancel()
got, err := b.ListOwned(ctx)
if err != nil {
t.Fatalf("ListOwned after restart: %v", err)
}
if len(got) != 1 || got[0].ID != id {
t.Fatalf("ListOwned = %+v, want monitor %d", got, id)
}
if _, err := b.Create(ctx, Monitor{
Type: "port", Name: "after", Active: true, Interval: 60, RetryInterval: 60,
Hostname: "example.com", Port: 80, OwnerKey: "default/after",
}); err != nil {
t.Fatalf("Create after restart: %v", err)
}
}
@@ -0,0 +1,186 @@
// Package kumaclient is the boundary to Uptime Kuma.
package kumaclient
import (
"context"
"errors"
"fmt"
"maps"
"math"
"slices"
)
var (
ErrNotFound = errors.New("monitor not found")
ErrNotOwned = errors.New("monitor not owned by kuma-sync")
// ErrOwnerTagMissing means the kuma-sync tag no longer exists in Kuma.
ErrOwnerTagMissing = errors.New("kuma-sync tag missing in Kuma")
// ErrOwnerConflict means a monitor carries more than one kuma-sync tag value.
ErrOwnerConflict = errors.New("monitor carries conflicting kuma-sync tag values")
// ErrPossibleOrphan means Create may have left an untagged monitor in Kuma.
ErrPossibleOrphan = errors.New("possible untagged orphan monitor left in Kuma")
)
// ConflictError reports a monitor carrying several different kuma-sync tag
// values. Keys lists all of them, sorted. It matches ErrOwnerConflict.
type ConflictError struct {
MonitorID int64
Keys []string
}
func (e *ConflictError) Error() string {
return fmt.Sprintf("monitor %d carries %d owner keys %q", e.MonitorID, len(e.Keys), e.Keys)
}
func (e *ConflictError) Unwrap() error { return ErrOwnerConflict }
// Conflicts returns every ConflictError inside err, and whether err consists
// of nothing but conflicts. ListOwned joins one per conflicting monitor.
func Conflicts(err error) (conflicts []*ConflictError, only bool) {
only = err != nil
var walk func(error)
walk = func(e error) {
switch x := e.(type) {
case nil:
case *ConflictError:
conflicts = append(conflicts, x)
case interface{ Unwrap() []error }:
for _, s := range x.Unwrap() {
walk(s)
}
default:
if u, ok := e.(interface{ Unwrap() error }); ok && u.Unwrap() != nil {
walk(u.Unwrap())
return
}
only = false
}
}
walk(err)
return conflicts, only && len(conflicts) > 0
}
func (m Monitor) clone() Monitor {
m.Headers = maps.Clone(m.Headers)
m.AcceptedStatusCodes = slices.Clone(m.AcceptedStatusCodes)
m.NotificationIDs = slices.Clone(m.NotificationIDs)
if m.Parent != nil {
p := *m.Parent
m.Parent = &p
}
return m
}
func isHTTPFamily(typ string) bool {
return typ == "http" || typ == "keyword" || typ == "json-query"
}
// Normalized returns m with the defaults the real client applies on write, so
// a desired monitor can be compared with an observed one.
//
// For http, keyword and json-query: an empty Method becomes GET, empty
// AcceptedStatusCodes become ["200-299"], and Timeout 0 becomes
// round(0.8*Interval). For all other types Timeout is stored as 0 and the
// HTTP-only slices are nil. Empty Headers become nil. NotificationIDs are
// sorted and deduplicated, empty becomes nil. It is idempotent.
func (m Monitor) Normalized() Monitor {
m = m.clone()
if len(m.Headers) == 0 {
m.Headers = nil
}
slices.Sort(m.NotificationIDs)
m.NotificationIDs = slices.Compact(m.NotificationIDs)
if len(m.NotificationIDs) == 0 {
m.NotificationIDs = nil
}
if len(m.AcceptedStatusCodes) == 0 {
m.AcceptedStatusCodes = nil
}
if !isHTTPFamily(m.Type) {
m.Timeout = 0
return m
}
if m.Method == "" {
m.Method = "GET"
}
if m.AcceptedStatusCodes == nil {
m.AcceptedStatusCodes = []string{"200-299"}
}
if m.Timeout == 0 {
m.Timeout = int32(math.Round(0.8 * float64(m.Interval)))
}
return m
}
// Monitor is the domain view of a Kuma monitor.
type Monitor struct {
ID int64
Type string
Name string
Description string
Parent *int64
Active bool
Interval int32
RetryInterval int32
MaxRetries int32
// Timeout is in seconds and only applies to http, keyword and json-query.
// 0 is written as round(0.8*Interval), because Kuma treats a stored 0 as a
// huge request timeout. For other types it is ignored and reads back as 0.
Timeout int32
URL string
Method string
Body string
Headers map[string]string
AcceptedStatusCodes []string
MaxRedirects int32
IgnoreTLS bool
ExpiryNotification bool
Keyword string
InvertKeyword bool
JSONPath string
JSONPathOperator string
ExpectedValue string
Hostname string
Port int32
DNSResolveType string
DNSResolveServer string
// NotificationIDs are the Kuma notifications attached to the monitor.
NotificationIDs []int64
// OwnerKey is the value of the kuma-sync tag; "" means not ours.
OwnerKey string
}
// Client manages only monitors carrying the kuma-sync tag.
type Client interface {
// ListOwned returns monitors carrying the kuma-sync tag, normalized.
// It returns ErrOwnerTagMissing, with no list, if the tag was deleted in
// Kuma, so callers never mistake that for "no monitors". A monitor with
// conflicting tag values is left out and reported as a *ConflictError (all
// of them joined; see Conflicts) together with the rest of the list. Any
// error means the list is incomplete: callers must not conclude a monitor
// is absent from it.
ListOwned(ctx context.Context) ([]Monitor, error)
// Create creates the monitor and tags it with m.OwnerKey.
Create(ctx context.Context, m Monitor) (int64, error)
// Probe reports whether a monitor with this ID exists, owned or not, and
// the owner key it carries ("" when untagged). A monitor with conflicting
// tag values returns an error that wraps ErrOwnerConflict.
Probe(ctx context.Context, id int64) (found bool, ownerKey string, err error)
// Update overwrites an owned monitor. It wraps ErrNotFound for an unknown ID
// and ErrNotOwned for a monitor without the kuma-sync tag or whose tag
// value differs from m.OwnerKey.
Update(ctx context.Context, m Monitor) error
// Delete removes an owned monitor. It wraps ErrNotFound for an unknown ID
// and ErrNotOwned for a monitor without the kuma-sync tag.
Delete(ctx context.Context, id int64) error
// DefaultNotifications returns the IDs of all notifications marked
// "default enabled" in Kuma, sorted. An empty result is valid.
DefaultNotifications(ctx context.Context) ([]int64, error)
}
@@ -0,0 +1,195 @@
package kumaclient
import (
"context"
"fmt"
"slices"
"sort"
"sync"
)
// Fake is an in-memory Client safe for concurrent use.
// ListErr, CreateErr, UpdateErr and DeleteErr, when set, are returned by the
// matching method; set them via the Set*Err methods while the fake is shared.
type Fake struct {
mu sync.Mutex
monitors map[int64]Monitor
nextID int64
ListErr error
CreateErr error
UpdateErr error
DeleteErr error
ProbeErr error
// NotificationsErr is returned by DefaultNotifications.
NotificationsErr error
// Defaults are the IDs DefaultNotifications returns.
Defaults []int64
// ListPartial makes ListOwned return the stored monitors together with
// ListErr, as the real client does for owner conflicts.
ListPartial bool
}
// SetListPartial makes ListOwned return the stored monitors and err.
func (f *Fake) SetListPartial(err error) {
f.mu.Lock()
defer f.mu.Unlock()
f.ListErr, f.ListPartial = err, true
}
var _ Client = (*Fake)(nil)
func NewFake() *Fake {
return &Fake{monitors: map[int64]Monitor{}, nextID: 1}
}
// AddForeign adds an untagged monitor. A zero ID is assigned the next free one.
func (f *Fake) AddForeign(m Monitor) int64 {
f.mu.Lock()
defer f.mu.Unlock()
m.OwnerKey = ""
if m.ID == 0 {
m.ID = f.allocID()
} else if m.ID >= f.nextID {
f.nextID = m.ID + 1
}
f.monitors[m.ID] = m.clone()
return m.ID
}
// Get returns a copy of the monitor with the given ID, owned or not, or nil.
func (f *Fake) Get(id int64) *Monitor {
f.mu.Lock()
defer f.mu.Unlock()
m, ok := f.monitors[id]
if !ok {
return nil
}
c := m.clone()
return &c
}
// SetErrs sets all four injected errors under the lock.
func (f *Fake) SetErrs(list, create, update, del error) {
f.mu.Lock()
defer f.mu.Unlock()
f.ListErr, f.CreateErr, f.UpdateErr, f.DeleteErr = list, create, update, del
f.ListPartial = false
}
// SetProbeErr makes Probe return err.
func (f *Fake) SetProbeErr(err error) {
f.mu.Lock()
defer f.mu.Unlock()
f.ProbeErr = err
}
// Probe reports whether a monitor exists, owned or not, and its owner key.
func (f *Fake) Probe(_ context.Context, id int64) (bool, string, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.ProbeErr != nil {
return false, "", f.ProbeErr
}
m, ok := f.monitors[id]
return ok, m.OwnerKey, nil
}
// SetDefaultNotifications sets the IDs DefaultNotifications returns.
func (f *Fake) SetDefaultNotifications(ids ...int64) {
f.mu.Lock()
defer f.mu.Unlock()
f.Defaults = slices.Clone(ids)
}
// SetNotificationsErr makes DefaultNotifications return err.
func (f *Fake) SetNotificationsErr(err error) {
f.mu.Lock()
defer f.mu.Unlock()
f.NotificationsErr = err
}
// DefaultNotifications returns Defaults, sorted.
func (f *Fake) DefaultNotifications(_ context.Context) ([]int64, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.NotificationsErr != nil {
return nil, f.NotificationsErr
}
ids := slices.Clone(f.Defaults)
slices.Sort(ids)
return ids, nil
}
func (f *Fake) allocID() int64 {
id := f.nextID
f.nextID++
return id
}
func (f *Fake) ListOwned(_ context.Context) ([]Monitor, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.ListErr != nil && !f.ListPartial {
return nil, f.ListErr
}
var out []Monitor
for _, m := range f.monitors {
if m.OwnerKey != "" {
out = append(out, m.clone())
}
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out, f.ListErr
}
func (f *Fake) Create(_ context.Context, m Monitor) (int64, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.CreateErr != nil {
return 0, f.CreateErr
}
if m.OwnerKey == "" {
return 0, fmt.Errorf("create: empty OwnerKey")
}
m.ID = f.allocID()
f.monitors[m.ID] = m.Normalized()
return m.ID, nil
}
func (f *Fake) Update(_ context.Context, m Monitor) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.UpdateErr != nil {
return f.UpdateErr
}
cur, ok := f.monitors[m.ID]
if !ok {
return fmt.Errorf("update %d: %w", m.ID, ErrNotFound)
}
if cur.OwnerKey == "" {
return fmt.Errorf("update %d: %w", m.ID, ErrNotOwned)
}
if m.OwnerKey != cur.OwnerKey {
return fmt.Errorf("update %d: owner key %q, not %q: %w", m.ID, cur.OwnerKey, m.OwnerKey, ErrNotOwned)
}
f.monitors[m.ID] = m.Normalized()
return nil
}
func (f *Fake) Delete(_ context.Context, id int64) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.DeleteErr != nil {
return f.DeleteErr
}
cur, ok := f.monitors[id]
if !ok {
return fmt.Errorf("delete %d: %w", id, ErrNotFound)
}
if cur.OwnerKey == "" {
return fmt.Errorf("delete %d: %w", id, ErrNotOwned)
}
delete(f.monitors, id)
return nil
}
@@ -0,0 +1,238 @@
package kumaclient
import (
"context"
"errors"
"testing"
)
func TestFakeCreateAssignsIDAndTag(t *testing.T) {
f := NewFake()
id, err := f.Create(context.Background(), Monitor{Name: "a", OwnerKey: "ns/a"})
if err != nil {
t.Fatal(err)
}
if id == 0 {
t.Fatal("expected non-zero id")
}
got, err := f.ListOwned(context.Background())
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0].ID != id || got[0].OwnerKey != "ns/a" {
t.Fatalf("unexpected list: %+v", got)
}
id2, err := f.Create(context.Background(), Monitor{Name: "b", OwnerKey: "ns/b"})
if err != nil {
t.Fatal(err)
}
if id2 == id {
t.Fatal("ids must be unique")
}
}
func TestFakeCreateRequiresOwnerKey(t *testing.T) {
f := NewFake()
if _, err := f.Create(context.Background(), Monitor{Name: "a"}); err == nil {
t.Fatal("expected error for empty OwnerKey")
}
}
func TestFakeListOwnedHidesForeign(t *testing.T) {
f := NewFake()
f.AddForeign(Monitor{ID: 99, Name: "foreign"})
if _, err := f.Create(context.Background(), Monitor{Name: "mine", OwnerKey: "ns/m"}); err != nil {
t.Fatal(err)
}
got, err := f.ListOwned(context.Background())
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0].Name != "mine" {
t.Fatalf("unexpected list: %+v", got)
}
}
func TestFakeUpdateRejectsForeign(t *testing.T) {
f := NewFake()
f.AddForeign(Monitor{ID: 99, Name: "foreign"})
if err := f.Update(context.Background(), Monitor{ID: 99, Name: "changed", OwnerKey: "ns/x"}); !errors.Is(err, ErrNotOwned) {
t.Fatalf("expected ErrNotOwned, got %v", err)
}
if got := f.Get(99); got == nil || got.Name != "foreign" {
t.Fatalf("foreign monitor modified: %+v", got)
}
}
func TestFakeUpdateOverwritesOwned(t *testing.T) {
f := NewFake()
id, _ := f.Create(context.Background(), Monitor{Name: "a", URL: "u", OwnerKey: "ns/a"})
if err := f.Update(context.Background(), Monitor{ID: id, Name: "b", OwnerKey: "ns/a"}); err != nil {
t.Fatal(err)
}
got := f.Get(id)
if got.Name != "b" || got.URL != "" {
t.Fatalf("not overwritten: %+v", got)
}
}
func TestFakeDeleteRejectsForeign(t *testing.T) {
f := NewFake()
f.AddForeign(Monitor{ID: 99, Name: "foreign"})
if err := f.Delete(context.Background(), 99); !errors.Is(err, ErrNotOwned) {
t.Fatalf("expected ErrNotOwned, got %v", err)
}
if f.Get(99) == nil {
t.Fatal("foreign monitor deleted")
}
}
func TestFakeDeleteOwned(t *testing.T) {
f := NewFake()
id, _ := f.Create(context.Background(), Monitor{Name: "a", OwnerKey: "ns/a"})
if err := f.Delete(context.Background(), id); err != nil {
t.Fatal(err)
}
if f.Get(id) != nil {
t.Fatal("not deleted")
}
}
func TestFakeInjectedErrors(t *testing.T) {
boom := errors.New("boom")
f := NewFake()
id, _ := f.Create(context.Background(), Monitor{Name: "a", OwnerKey: "ns/a"})
f.ListErr, f.CreateErr, f.UpdateErr, f.DeleteErr = boom, boom, boom, boom
ctx := context.Background()
if _, err := f.ListOwned(ctx); !errors.Is(err, boom) {
t.Fatalf("list: %v", err)
}
if _, err := f.Create(ctx, Monitor{OwnerKey: "x"}); !errors.Is(err, boom) {
t.Fatalf("create: %v", err)
}
if err := f.Update(ctx, Monitor{ID: id, OwnerKey: "ns/a"}); !errors.Is(err, boom) {
t.Fatalf("update: %v", err)
}
if err := f.Delete(ctx, id); !errors.Is(err, boom) {
t.Fatalf("delete: %v", err)
}
}
func TestFakeDefaultNotifications(t *testing.T) {
f := NewFake()
if ids, err := f.DefaultNotifications(context.Background()); err != nil || ids != nil {
t.Fatalf("empty: %v %v", ids, err)
}
f.SetDefaultNotifications(4, 1)
if ids, err := f.DefaultNotifications(context.Background()); err != nil || len(ids) != 2 || ids[0] != 1 || ids[1] != 4 {
t.Fatalf("set: %v %v", ids, err)
}
f.SetNotificationsErr(errors.New("boom"))
if _, err := f.DefaultNotifications(context.Background()); err == nil {
t.Error("expected injected error")
}
}
func TestFakeUpdateRejectsOtherOwnerKey(t *testing.T) {
f := NewFake()
id, _ := f.Create(context.Background(), Monitor{Name: "a", OwnerKey: "ns/a"})
if err := f.Update(context.Background(), Monitor{ID: id, Name: "b", OwnerKey: "ns/other"}); !errors.Is(err, ErrNotOwned) {
t.Fatalf("want ErrNotOwned, got %v", err)
}
if got := f.Get(id); got.Name != "a" {
t.Errorf("monitor changed: %+v", got)
}
}
func TestFakeProbe(t *testing.T) {
f := NewFake()
owned, _ := f.Create(context.Background(), Monitor{Name: "a", OwnerKey: "ns/a"})
foreign := f.AddForeign(Monitor{Name: "x"})
if found, key, err := f.Probe(context.Background(), owned); err != nil || !found || key != "ns/a" {
t.Errorf("owned: %v %q %v", found, key, err)
}
if found, key, err := f.Probe(context.Background(), foreign); err != nil || !found || key != "" {
t.Errorf("foreign: %v %q %v", found, key, err)
}
if found, _, err := f.Probe(context.Background(), 999); err != nil || found {
t.Errorf("missing: %v %v", found, err)
}
f.SetProbeErr(errors.New("boom"))
if _, _, err := f.Probe(context.Background(), owned); err == nil {
t.Error("expected injected error")
}
}
func TestFakeDeleteTwiceNotFound(t *testing.T) {
f := NewFake()
id, _ := f.Create(context.Background(), Monitor{Name: "a", OwnerKey: "ns/a"})
if err := f.Delete(context.Background(), id); err != nil {
t.Fatal(err)
}
if err := f.Delete(context.Background(), id); !errors.Is(err, ErrNotFound) {
t.Fatalf("expected ErrNotFound, got %v", err)
}
}
func TestFakeUpdateUnknownNotFound(t *testing.T) {
f := NewFake()
if err := f.Update(context.Background(), Monitor{ID: 42, OwnerKey: "ns/a"}); !errors.Is(err, ErrNotFound) {
t.Fatalf("expected ErrNotFound, got %v", err)
}
}
func TestFakeDoesNotAlias(t *testing.T) {
ctx := context.Background()
f := NewFake()
parent := int64(7)
in := Monitor{
Name: "a", OwnerKey: "ns/a", Parent: &parent,
Headers: map[string]string{"k": "v"},
AcceptedStatusCodes: []string{"200-299"},
}
id, _ := f.Create(ctx, in)
check := func(m Monitor, where string) {
t.Helper()
if m.Headers["k"] != "v" || m.AcceptedStatusCodes[0] != "200-299" || *m.Parent != 7 {
t.Fatalf("%s: state aliased: %+v parent=%d", where, m, *m.Parent)
}
}
// Mutating the input after Create must not affect stored state.
in.Headers["k"] = "changed"
in.AcceptedStatusCodes[0] = "changed"
parent = 8
check(*f.Get(id), "after create mutation")
// Mutating returned values must not affect stored state.
got := f.Get(id)
got.Headers["k"] = "changed"
got.AcceptedStatusCodes[0] = "changed"
*got.Parent = 9
listed, _ := f.ListOwned(ctx)
listed[0].Headers["k"] = "changed"
listed[0].AcceptedStatusCodes[0] = "changed"
*listed[0].Parent = 9
check(*f.Get(id), "after return mutation")
// Update copies too.
up := Monitor{ID: id, OwnerKey: "ns/a", Parent: new(int64), Headers: map[string]string{"k": "v"}, AcceptedStatusCodes: []string{"200-299"}}
*up.Parent = 7
if err := f.Update(ctx, up); err != nil {
t.Fatal(err)
}
up.Headers["k"] = "changed"
up.AcceptedStatusCodes[0] = "changed"
*up.Parent = 10
check(*f.Get(id), "after update mutation")
// AddForeign copies too.
fm := Monitor{Parent: new(int64), Headers: map[string]string{"k": "v"}, AcceptedStatusCodes: []string{"200-299"}}
*fm.Parent = 7
fid := f.AddForeign(fm)
fm.Headers["k"] = "changed"
fm.AcceptedStatusCodes[0] = "changed"
*fm.Parent = 10
check(*f.Get(fid), "after foreign mutation")
}
@@ -0,0 +1,196 @@
package kumaclient
import (
"errors"
"fmt"
"reflect"
"testing"
kuma "github.com/breml/go-uptime-kuma-client"
"github.com/breml/go-uptime-kuma-client/notification"
"github.com/breml/go-uptime-kuma-client/tag"
)
func TestPickOwnerTagLowestID(t *testing.T) {
tags := []tag.Tag{
{ID: 9, Name: "kuma-sync"},
{ID: 3, Name: "other"},
{ID: 4, Name: "kuma-sync"},
{ID: 7, Name: "kuma-sync"},
}
id, ok := pickOwnerTag(tags)
if !ok || id != 4 {
t.Fatalf("got %d %v, want 4 true", id, ok)
}
if _, ok := pickOwnerTag([]tag.Tag{{ID: 1, Name: "other"}}); ok {
t.Fatal("expected no match")
}
}
func TestOwnerKeyOf(t *testing.T) {
mt := func(tagID int64, v string) tag.MonitorTag { return tag.MonitorTag{TagID: tagID, Value: v} }
cases := []struct {
name string
tags []tag.MonitorTag
want string
conflict bool
}{
{"none", nil, "", false},
{"other tag only", []tag.MonitorTag{mt(2, "x")}, "", false},
{"single", []tag.MonitorTag{mt(1, "ns/a"), mt(2, "x")}, "ns/a", false},
{"same value twice", []tag.MonitorTag{mt(1, "ns/a"), mt(1, "ns/a")}, "ns/a", false},
{"conflict", []tag.MonitorTag{mt(1, "ns/a"), mt(1, "ns/b")}, "", true},
}
for _, c := range cases {
got, err := ownerKeyOf(5, c.tags, 1)
if c.conflict != errors.Is(err, ErrOwnerConflict) {
t.Errorf("%s: err = %v, want conflict=%v", c.name, err, c.conflict)
}
if got != c.want {
t.Errorf("%s: key = %q, want %q", c.name, got, c.want)
}
}
}
func TestConflictErrorListsAllKeys(t *testing.T) {
mt := func(v string) tag.MonitorTag { return tag.MonitorTag{TagID: 1, Value: v} }
_, err := ownerKeyOf(7, []tag.MonitorTag{mt("default/x"), mt("default/y"), mt("default/a"), mt("default/x")}, 1)
var ce *ConflictError
if !errors.As(err, &ce) || !errors.Is(err, ErrOwnerConflict) {
t.Fatalf("err = %v", err)
}
if ce.MonitorID != 7 || !reflect.DeepEqual(ce.Keys, []string{"default/a", "default/x", "default/y"}) {
t.Errorf("conflict = %+v", ce)
}
}
func TestConflicts(t *testing.T) {
a := &ConflictError{MonitorID: 1, Keys: []string{"a", "b"}}
b := &ConflictError{MonitorID: 2, Keys: []string{"c", "d", "e"}}
got, only := Conflicts(errors.Join(a, fmt.Errorf("wrapped: %w", b)))
if !only || len(got) != 2 || got[0] != a || got[1] != b {
t.Errorf("got %v only=%v", got, only)
}
if _, only := Conflicts(errors.Join(a, errors.New("other"))); only {
t.Error("mixed error must not be conflict-only")
}
if got, only := Conflicts(errors.New("plain")); len(got) != 0 || only {
t.Errorf("plain: %v %v", got, only)
}
if _, only := Conflicts(nil); only {
t.Error("nil is not conflict-only")
}
}
func TestCreatedDespite(t *testing.T) {
timeout := fmt.Errorf("create monitor: %w", kuma.ErrUpdateEventTimeout)
if !createdDespite(5, nil) {
t.Error("success must proceed")
}
if !createdDespite(5, timeout) {
t.Error("update-event timeout with an ID must proceed to tagging")
}
if createdDespite(0, timeout) {
t.Error("no ID must not proceed")
}
if createdDespite(0, errors.New("boom")) || createdDespite(5, errors.New("boom")) {
t.Error("other errors must not proceed")
}
}
func TestFlagUnknownCreate(t *testing.T) {
run := func(id int64, err error, dead bool) (int64, error, int) {
calls := 0
gotID, gotErr := flagUnknownCreate(
func() (int64, error) { calls++; return id, err },
func(error) bool { return dead },
)
return gotID, gotErr, calls
}
transport := errors.New("use of closed network connection")
if id, err, calls := run(0, transport, true); !errors.Is(err, ErrPossibleOrphan) || !errors.Is(err, transport) || id != 0 || calls != 1 {
t.Errorf("transport failure: id=%d err=%v calls=%d; want ErrPossibleOrphan and exactly one create", id, err, calls)
}
timeout := fmt.Errorf("add: %w", kuma.ErrOperationTimeout)
if _, err, calls := run(0, timeout, false); !errors.Is(err, ErrPossibleOrphan) || calls != 1 {
t.Errorf("operation timeout: err=%v calls=%d; want ErrPossibleOrphan and exactly one create", err, calls)
}
app := errors.New("add: validation failed")
if _, err, _ := run(0, app, false); errors.Is(err, ErrPossibleOrphan) || !errors.Is(err, app) {
t.Errorf("application error must pass through unflagged: %v", err)
}
if id, err, _ := run(5, nil, false); err != nil || id != 5 {
t.Errorf("success: id=%d err=%v", id, err)
}
already := fmt.Errorf("x: %w", ErrPossibleOrphan)
if _, err, _ := run(0, already, true); !errors.Is(err, ErrPossibleOrphan) {
t.Errorf("existing orphan error lost: %v", err)
}
}
func TestNormalized(t *testing.T) {
in := Monitor{Type: "keyword", Interval: 60, Headers: map[string]string{}}
got := in.Normalized()
if got.Method != "GET" || !reflect.DeepEqual(got.AcceptedStatusCodes, []string{"200-299"}) {
t.Errorf("defaults not applied: %+v", got)
}
if got.Timeout != 48 {
t.Errorf("timeout = %d, want 48", got.Timeout)
}
if got.Headers != nil {
t.Errorf("empty headers must be nil: %v", got.Headers)
}
if !reflect.DeepEqual(got.Normalized(), got) {
t.Error("Normalized is not idempotent")
}
explicit := Monitor{Type: "http", Interval: 60, Timeout: 10, Method: "POST", AcceptedStatusCodes: []string{"301"}}
if e := explicit.Normalized(); e.Timeout != 10 || e.Method != "POST" || e.AcceptedStatusCodes[0] != "301" {
t.Errorf("explicit values changed: %+v", e)
}
dns := Monitor{Type: "dns", Interval: 60, Timeout: 30}.Normalized()
if dns.Timeout != 0 || dns.Method != "" || dns.AcceptedStatusCodes != nil {
t.Errorf("non-http type must not gain http defaults: %+v", dns)
}
if !reflect.DeepEqual(dns.Normalized(), dns) {
t.Error("Normalized is not idempotent for dns")
}
}
func TestFakeStoresNormalized(t *testing.T) {
f := NewFake()
id, err := f.Create(t.Context(), Monitor{Type: "http", Interval: 100, OwnerKey: "a"})
if err != nil {
t.Fatal(err)
}
got := f.Get(id)
if got.Method != "GET" || got.Timeout != 80 {
t.Fatalf("fake did not normalize: %+v", got)
}
}
func TestNormalizedNotificationIDs(t *testing.T) {
in := Monitor{Type: "group", NotificationIDs: []int64{10, 2, 10}}
got := in.Normalized()
if !reflect.DeepEqual(got.NotificationIDs, []int64{2, 10}) {
t.Errorf("notifications = %v, want [2 10]", got.NotificationIDs)
}
if !reflect.DeepEqual(in.NotificationIDs, []int64{10, 2, 10}) {
t.Errorf("Normalized changed its receiver: %v", in.NotificationIDs)
}
if e := (Monitor{Type: "group", NotificationIDs: []int64{}}).Normalized(); e.NotificationIDs != nil {
t.Errorf("empty notifications must be nil: %v", e.NotificationIDs)
}
}
func TestDefaultIDs(t *testing.T) {
got := defaultIDs([]notification.Base{
{ID: 5, IsDefault: true}, {ID: 1}, {ID: 3, IsDefault: true},
})
if !reflect.DeepEqual(got, []int64{3, 5}) {
t.Errorf("defaultIDs = %v, want [3 5]", got)
}
if got := defaultIDs(nil); got != nil {
t.Errorf("defaultIDs(nil) = %v, want nil", got)
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
# domains -> TLS passthrough -> Traefik ClusterIP) from Ingresses carrying the
# public label. Standard library only, so the image is the interpreter plus one
# file; runs unprivileged as its own user.
FROM python:3.14-slim@sha256:a2b82f3c48559aa0a8446d9af49826b6e2b2016f4cd2afabfe6013ec53729170
FROM python:3.15-slim@sha256:2da676046630d7bf14c6764ae1143dc7fa0377a53f94bf5a642f0b214aca5bce
RUN useradd --system --no-create-home --shell /usr/sbin/nologin controller
COPY controller.py /opt/controller.py
-95
View File
@@ -1,95 +0,0 @@
# Paperclip server image (REN-401)
Upstream `paperclipai/paperclip` at the commit in `upstream.env`, plus every
`*.patch` in this directory. Built by `.woodpecker/build-paperclip.yml`,
pushed as `gitea.renner.uno/konairius/paperclip:build.<unix-ts>`, rolled out
by Flux image automation (`kubernetes/infrastructure/image-automation/paperclip.yaml`).
## Why
k8s sandbox runs fail: the run path never sets the lease `remoteCwd`, so file
sync into the pod fails (paperclipai/paperclip#13864). The fix is
paperclipai/paperclip#13733, open and not merged.
`pr-13733-lease-remote-cwd.patch` is that PR's diff (head `5f320bf6`).
#13733 covers only the run path. Adapter environment tests (binding an agent
to a k8s environment) sync managed config before any workspace is realized,
so the lease still has no `remoteCwd` and the Claude test fails with
`claude_managed_config_dir_failed` (REN-409). The fix is
paperclipai/paperclip#14769, also open: the plugin sets `remoteCwd:
/workspace` when it acquires or resumes a lease.
`pr-14769-probe-remote-cwd.patch` is that PR's diff (head `10dc7371`).
Claude auto memory is lost after every run (REN-541): it lives under
`CLAUDE_CONFIG_DIR`, which Paperclip makes fresh per run and deletes.
`ren-541-claude-auto-memory.patch` makes the ACPX engine write
`autoMemoryDirectory: <AGENT_HOME>/memory` into the per-run
`.claude/settings.local.json`. `AGENT_HOME` is restored before and saved after
every run, local and in k8s pods. The value is not part of the session
fingerprint, so resume is unchanged. No upstream PR yet; rename the patch to
`pr-<n>-…` once there is one.
Claude sessions never resume with a managed AI connection (REN-563): the
server gives every run a fresh temp home (`HOME`, `XDG_*`,
`CLAUDE_CONFIG_DIR`, …) and deletes it after the run.
`ren-563-acpx-claude-session-resume.patch` fixes both halves for local runs:
the ACPX engine hashes a fixed placeholder for those paths in the session
fingerprint (`adapterEnvHash`), as the server's own fingerprint already does,
and links `CLAUDE_CONFIG_DIR/projects` (the Claude transcripts) to
`<stateDir>/claude-projects`, which persists. Both must ship together: a
stable fingerprint without the transcript makes the resumed turn fail with
"Resource not found" (homelab#1111, reverted in #1113). Remote runs are
unchanged. No upstream PR yet.
A cancelled or failed k8s pod run blocks its card for good (REN-841). The
owner's next comment waits on `remote_cleanup`, because the server continues
only when every remote lease carries a provider termination receipt
(`explicit-native-continuation.ts`), and the Kubernetes plugin's
`onEnvironmentReleaseLease` / `onEnvironmentDestroyLease` returned `void`.
`ren-841-k8s-termination-receipt.patch` makes both return
`{ providerLeaseId, state: "destroyed" }`, but only after the Sandbox CR or
Job (and, on destroy, the pod) reads 404, polling for up to 20 s, below the
server's 30 s plugin RPC timeout. If that check times out or fails, the hook
returns no receipt, as before. No upstream PR yet.
Agents in k8s pods cannot read their own secrets (REN-1345): the sandbox
callback bridge answers `403 Route not allowed` for `/api/agents/me/secrets`,
although upstream's own Paperclip skill documents those routes for runs.
`ren-1345-bridge-agent-secrets.patch` adds the five `/api/agents/me` secret
routes (list, read one value, list/create/withdraw proposals) to the bridge
allowlist. The server still resolves the agent from the run JWT and checks
the binding; company-wide secret routes stay denied. No upstream PR yet.
## Files
- `upstream.env` — pinned commit and the version string stamped into the
image. Bump both together.
- `prepare.sh` — fetches the commit, applies the patches, writes
`Dockerfile.kaniko` (upstream's `COPY --parents` line split into plain
`COPY` lines; kaniko does not support `--parents`), and adds the build of
the Kubernetes sandbox provider plugin to the `build` stage (REN-405):
upstream's `production` target ships only its source, so the plugin fails
with "no longer exposes a Paperclip manifest". The build fails if
`dist/manifest.js` is missing.
- `*.patch` — applied in name order with `git apply`.
## Moving to a newer upstream commit
Set `PAPERCLIP_COMMIT` to the new master commit and `PAPERCLIP_VERSION` to
`git describe --tags --match 'v*' --long <commit>`, then run
`sh prepare.sh /tmp/src`: it fails if a patch no longer applies.
## Exit: when every patch here is merged upstream
Renovate cannot watch an upstream PR, so this is a manual step. Once
upstream nightly contains all of them:
1. `kubernetes/infrastructure/image-automation/paperclip.yaml`: back to
`ghcr.io/paperclipai/paperclip` with `filterTags.pattern: '^nightly$'`
(the version before REN-401 in git history).
2. `renovate.json`: swap `gitea.renner.uno/konairius/paperclip` back to
`ghcr.io/paperclipai/paperclip` in the image-automation exclusion list.
3. Delete this directory and `.woodpecker/build-paperclip.yml`.
Image automation then rewrites `kubernetes/apps/paperclip/paperclip.yaml`
to the nightly digest by itself.
@@ -1,93 +0,0 @@
diff --git a/server/src/__tests__/environment-run-orchestrator.test.ts b/server/src/__tests__/environment-run-orchestrator.test.ts
index f3d1fc659..812cae357 100644
--- a/server/src/__tests__/environment-run-orchestrator.test.ts
+++ b/server/src/__tests__/environment-run-orchestrator.test.ts
@@ -430,6 +430,51 @@ describe("environmentRunOrchestrator — realizeForRun", () => {
expect(result.persistedExecutionWorkspace).toEqual(updatedEw);
});
+ it("persists the realized cwd as remoteCwd when realization returns no workspaceRealization payload", async () => {
+ const runtime = makeMockRuntime({
+ realizeWorkspace: vi.fn().mockResolvedValue({
+ cwd: "/workspace",
+ metadata: { provider: "kubernetes", remoteCwd: "/workspace" },
+ }),
+ } as Partial<EnvironmentRuntimeService>);
+ mockResolveEnvironmentExecutionTarget.mockResolvedValue({ kind: "local", environmentId: "env-1", leaseId: "lease-1" });
+
+ const orchestrator = environmentRunOrchestrator(mockDb, { environmentRuntime: runtime });
+
+ await orchestrator.realizeForRun(
+ makeRealizeInput({ environment: makeEnvironment("sandbox") }),
+ );
+
+ expect(mockUpdateLeaseMetadata).toHaveBeenCalledWith(
+ "lease-1",
+ expect.objectContaining({ remoteCwd: "/workspace" }),
+ );
+ // Nothing to record about the realization shape, so the execution workspace
+ // row is left untouched.
+ expect(mockUpdateExecutionWorkspace).not.toHaveBeenCalled();
+ });
+
+ it("does not rewrite lease metadata when the realized cwd already matches the lease", async () => {
+ const runtime = makeMockRuntime({
+ realizeWorkspace: vi.fn().mockResolvedValue({
+ cwd: "/workspace",
+ metadata: { provider: "kubernetes", remoteCwd: "/workspace" },
+ }),
+ } as Partial<EnvironmentRuntimeService>);
+ mockResolveEnvironmentExecutionTarget.mockResolvedValue({ kind: "local", environmentId: "env-1", leaseId: "lease-1" });
+
+ const orchestrator = environmentRunOrchestrator(mockDb, { environmentRuntime: runtime });
+
+ await orchestrator.realizeForRun(
+ makeRealizeInput({
+ environment: makeEnvironment("sandbox"),
+ lease: makeLease({ metadata: { remoteCwd: "/workspace" } }),
+ }),
+ );
+
+ expect(mockUpdateLeaseMetadata).not.toHaveBeenCalled();
+ });
+
it("runs a remote provision command after workspace realization when configured", async () => {
mockBuildWorkspaceRealizationRequest.mockReturnValue({
version: 1,
diff --git a/server/src/services/environment-run-orchestrator.ts b/server/src/services/environment-run-orchestrator.ts
index 97cca667e..e76f64913 100644
--- a/server/src/services/environment-run-orchestrator.ts
+++ b/server/src/services/environment-run-orchestrator.ts
@@ -485,16 +485,29 @@ export function environmentRunOrchestrator(
}
// Step 3: Persist realization metadata on lease and execution workspace
- if (Object.keys(workspaceRealization).length > 0) {
+ const hasWorkspaceRealization = Object.keys(workspaceRealization).length > 0;
+ // A driver that realizes the workspace reports the directory it prepared
+ // through the realization `cwd`. Persist it on the lease as `remoteCwd`, so
+ // every later step that receives only the lease — native file sync, a
+ // resumed lease, a second run against the same lease — confines itself to
+ // the same root. Without it a driver whose realization returns no
+ // `workspaceRealization` payload leaves the lease without a root at all,
+ // and the sync step fails even though realization succeeded.
+ const currentRemoteCwd =
+ typeof lease.metadata?.remoteCwd === "string" ? lease.metadata.remoteCwd.trim() : "";
+ const realizedRemoteCwd =
+ realizedWorkspaceCwd && realizedWorkspaceCwd !== currentRemoteCwd ? realizedWorkspaceCwd : null;
+ if (hasWorkspaceRealization || realizedRemoteCwd) {
const nextLeaseMetadata = {
...(lease.metadata ?? {}),
- workspaceRealization,
+ ...(hasWorkspaceRealization ? { workspaceRealization } : {}),
+ ...(realizedRemoteCwd ? { remoteCwd: realizedRemoteCwd } : {}),
};
const updatedLease = await environmentsSvc.updateLeaseMetadata(lease.id, nextLeaseMetadata);
if (updatedLease) {
lease = updatedLease;
}
- if (persistedExecutionWorkspace) {
+ if (hasWorkspaceRealization && persistedExecutionWorkspace) {
const updatedEw = await executionWorkspacesSvc.update(persistedExecutionWorkspace.id, {
metadata: {
...(persistedExecutionWorkspace.metadata ?? {}),
@@ -1,86 +0,0 @@
diff --git a/packages/plugins/sandbox-providers/kubernetes/README.md b/packages/plugins/sandbox-providers/kubernetes/README.md
index a51c6ecee60e..72618cb11296 100644
--- a/packages/plugins/sandbox-providers/kubernetes/README.md
+++ b/packages/plugins/sandbox-providers/kubernetes/README.md
@@ -140,6 +140,8 @@ Every agent pod is:
Plus per-namespace `pod-security.kubernetes.io/enforce: restricted` and a deny-all NetworkPolicy baseline with explicit egress allow-list (DNS, paperclip-server, configured FQDNs/CIDRs).
+The lease starts with `/workspace` as the native file-sync confinement root. Adapter environment tests can upload managed config before the server realizes a workspace. When resuming a lease, the provider keeps a previously recorded `remoteCwd` so a custom realized workspace is not replaced with the default.
+
The per-run Secret carrying the bootstrap token and adapter API keys has `ownerReferences` pointing at the owning Job, so a single `kubectl delete job …` cascades cleanly to the Pod and Secret.
## Optional Kata-FC microVM isolation
diff --git a/packages/plugins/sandbox-providers/kubernetes/src/plugin.ts b/packages/plugins/sandbox-providers/kubernetes/src/plugin.ts
index 8ae47de8ecac..98016385da05 100644
--- a/packages/plugins/sandbox-providers/kubernetes/src/plugin.ts
+++ b/packages/plugins/sandbox-providers/kubernetes/src/plugin.ts
@@ -454,6 +454,9 @@ const plugin = definePlugin({
secretName,
phase: "Pending",
backend: config.backend,
+ // Adapter environment probes sync managed assets before workspace
+ // realization. Give native sync the pod's mounted confinement root now.
+ remoteCwd: "/workspace",
scopedNetworkPolicyName,
scopedNetworkEgress,
// Native file sync streams over a pod exec; only the sandbox-cr backend
@@ -529,6 +532,10 @@ const plugin = definePlugin({
secretName,
phase: check.phase,
backend: leaseBackend,
+ remoteCwd:
+ typeof params.leaseMetadata?.remoteCwd === "string" && params.leaseMetadata.remoteCwd.trim()
+ ? params.leaseMetadata.remoteCwd.trim()
+ : "/workspace",
scopedNetworkPolicyName:
typeof params.leaseMetadata?.scopedNetworkPolicyName === "string"
? params.leaseMetadata.scopedNetworkPolicyName
diff --git a/packages/plugins/sandbox-providers/kubernetes/src/types.ts b/packages/plugins/sandbox-providers/kubernetes/src/types.ts
index 5626daa93846..ef3d0d889a4c 100644
--- a/packages/plugins/sandbox-providers/kubernetes/src/types.ts
+++ b/packages/plugins/sandbox-providers/kubernetes/src/types.ts
@@ -90,6 +90,8 @@ export interface KubernetesLeaseMetadata {
phase: "Pending" | "Running" | "Succeeded" | "Failed";
/** Which backend provisioned this lease. */
backend: "sandbox-cr" | "job";
+ /** Native sync confinement root; present before workspace realization for probes. */
+ remoteCwd: string;
scopedNetworkPolicyName: string | null;
scopedNetworkEgress: {
allowFqdns: string[];
diff --git a/packages/plugins/sandbox-providers/kubernetes/test/unit/plugin-lease-lifecycle.test.ts b/packages/plugins/sandbox-providers/kubernetes/test/unit/plugin-lease-lifecycle.test.ts
index 7ad0ca4807e5..4c23410d4d15 100644
--- a/packages/plugins/sandbox-providers/kubernetes/test/unit/plugin-lease-lifecycle.test.ts
+++ b/packages/plugins/sandbox-providers/kubernetes/test/unit/plugin-lease-lifecycle.test.ts
@@ -80,6 +80,7 @@ describe("onEnvironmentResumeLease", () => {
secretName: "pc-abc-env",
phase: "Running",
backend: "sandbox-cr",
+ remoteCwd: "/workspace",
resumedLease: true,
// sandbox-cr has a pod-exec channel, so native file sync stays enabled.
nativeFileSyncUnsupported: false,
@@ -87,6 +88,22 @@ describe("onEnvironmentResumeLease", () => {
);
});
+ it("keeps a custom realized cwd when resuming a sandbox lease", async () => {
+ h.clients = {
+ custom: { getNamespacedCustomObject: vi.fn().mockResolvedValue(readySandboxCr("pc-abc-pod")) },
+ core: { readNamespacedPod: vi.fn().mockResolvedValue({ status: { phase: "Running" } }) },
+ };
+ const lease = await plugin.definition.onEnvironmentResumeLease!({
+ driverKey: "kubernetes",
+ companyId: "acme",
+ environmentId: "env-1",
+ config: CONFIG,
+ providerLeaseId: "pc-abc",
+ leaseMetadata: leaseMetadata({ remoteCwd: "/workspace/custom" }),
+ });
+ expect(lease.metadata?.remoteCwd).toBe("/workspace/custom");
+ });
+
it("flags a resumed job-backend lease as native-sync-unsupported so the server keeps the base64 fallback", async () => {
h.clients = {
batch: {
-59
View File
@@ -1,59 +0,0 @@
#!/bin/sh
# Fetch upstream Paperclip at the pinned commit, apply our patches and make
# the Dockerfile buildable with kaniko. Usage: prepare.sh <target-dir>
set -eu
HERE=$(cd "$(dirname "$0")" && pwd)
. "$HERE/upstream.env"
OUT=$1
rm -rf "$OUT"
git init -q "$OUT"
cd "$OUT"
git fetch -q --depth 1 https://github.com/paperclipai/paperclip.git "$PAPERCLIP_COMMIT"
git -c advice.detachedHead=false checkout -q FETCH_HEAD
test "$(git rev-parse HEAD)" = "$PAPERCLIP_COMMIT"
for p in "$HERE"/*.patch; do
echo "applying $(basename "$p")"
git apply "$p"
done
# kaniko does not know `COPY --parents` (BuildKit only). Upstream uses it for
# one line: every sandbox provider's package.json. Replace it with one plain
# COPY per provider directory, which copies the same files to the same place.
grep -c -- '^COPY --parents packages/plugins/sandbox-providers/\./\*/package.json packages/plugins/sandbox-providers/$' Dockerfile | grep -qx 1
awk '
/^COPY --parents packages\/plugins\/sandbox-providers\// {
while (("ls -d packages/plugins/sandbox-providers/*/package.json" | getline f) > 0) {
d = f; sub(/package.json$/, "", d)
print "COPY " f " " d
}
next
}
{ print }
' Dockerfile > Dockerfile.kaniko
if grep -q -- '--parents' Dockerfile.kaniko; then
echo "Dockerfile.kaniko still uses COPY --parents" >&2
exit 1
fi
# Build the Kubernetes sandbox provider into the image (REN-405). Upstream's
# `production` target ships only its source; the plugin loader needs
# dist/manifest.js. Same commands as upstream's `cloud-plugins` stage, run in
# the `build` stage right after the server build, so production's
# `COPY --from=build /app /app` carries dist/ and node_modules along. No extra
# stage: kaniko would snapshot the whole build stage again for it.
export ANCHOR='RUN test -f server/dist/index.js || (echo "ERROR: server build output missing" && exit 1)'
grep -c -F -x -- "$ANCHOR" Dockerfile.kaniko | grep -qx 1
K=packages/plugins/sandbox-providers/kubernetes
awk -v k="$K" '
{ print }
$0 == ENVIRON["ANCHOR"] {
print "RUN pnpm -C " k " install --ignore-workspace --no-lockfile \\"
print " && pnpm -C " k " build \\"
print " && test -f " k "/dist/manifest.js"
}
' Dockerfile.kaniko > Dockerfile.kaniko.new
mv Dockerfile.kaniko.new Dockerfile.kaniko
grep -q -- "test -f $K/dist/manifest.js" Dockerfile.kaniko
rm -rf .git
@@ -1,48 +0,0 @@
diff --git a/packages/adapter-utils/src/sandbox-callback-bridge.test.ts b/packages/adapter-utils/src/sandbox-callback-bridge.test.ts
index 4419bee..d57de8d 100644
--- a/packages/adapter-utils/src/sandbox-callback-bridge.test.ts
+++ b/packages/adapter-utils/src/sandbox-callback-bridge.test.ts
@@ -1452,6 +1452,11 @@ describe("sandbox callback bridge", () => {
{ method: "POST", path: "/api/routines/r-1/triggers" },
{ method: "PATCH", path: "/api/routine-triggers/t-1" },
{ method: "DELETE", path: "/api/routine-triggers/t-1" },
+ { method: "GET", path: "/api/agents/me/secrets" },
+ { method: "POST", path: "/api/agents/me/secrets/github_token/value" },
+ { method: "GET", path: "/api/agents/me/secret-proposals" },
+ { method: "POST", path: "/api/agents/me/secret-proposals" },
+ { method: "DELETE", path: "/api/agents/me/secret-proposals/prop-1" },
];
for (const request of allowed) {
expect(authorizeSandboxCallbackBridgeRequestWithRoutes(request)).toBeNull();
@@ -1491,6 +1496,11 @@ describe("sandbox callback bridge", () => {
{ method: "POST", path: "/api/companies/co-1/logo" },
{ method: "GET", path: "/api/companies/co-1/secrets" },
{ method: "PATCH", path: "/api/secrets/secret-1" },
+ { method: "GET", path: "/api/agents/agent-1/secrets" },
+ { method: "POST", path: "/api/agents/me/secrets/a/b/value" },
+ { method: "GET", path: "/api/agents/me/secrets/github_token/value" },
+ { method: "GET", path: "/api/companies/co-1/secret-proposals" },
+ { method: "POST", path: "/api/companies/co-1/secret-proposals/prop-1/approve" },
];
for (const request of denied) {
expect(authorizeSandboxCallbackBridgeRequestWithRoutes(request)).toBe(
diff --git a/packages/adapter-utils/src/sandbox-callback-bridge.ts b/packages/adapter-utils/src/sandbox-callback-bridge.ts
index 40776a3..e825197 100644
--- a/packages/adapter-utils/src/sandbox-callback-bridge.ts
+++ b/packages/adapter-utils/src/sandbox-callback-bridge.ts
@@ -133,6 +133,15 @@ export const DEFAULT_SANDBOX_CALLBACK_BRIDGE_ROUTE_ALLOWLIST: readonly SandboxCa
{ method: "POST", path: /^\/api\/agents\/[^/]+\/skills\/sync$/ },
{ method: "PATCH", path: /^\/api\/agents\/[^/]+\/instructions-path$/ },
+ // Run-scoped secrets of the calling agent (`/agents/me` only). The server
+ // resolves the agent from the run JWT and checks bindings; values are
+ // registered for run-log redaction. Company-wide secret routes stay denied.
+ { method: "GET", path: /^\/api\/agents\/me\/secrets$/ },
+ { method: "POST", path: /^\/api\/agents\/me\/secrets\/[^/]+\/value$/ },
+ { method: "GET", path: /^\/api\/agents\/me\/secret-proposals$/ },
+ { method: "POST", path: /^\/api\/agents\/me\/secret-proposals$/ },
+ { method: "DELETE", path: /^\/api\/agents\/me\/secret-proposals\/[^/]+$/ },
+
// Read-only schema discovery for validated control-plane requests.
{ method: "GET", path: /^\/api\/openapi\.json$/ },
@@ -1,118 +0,0 @@
diff --git a/packages/adapter-utils/src/acpx-engine/execute.test.ts b/packages/adapter-utils/src/acpx-engine/execute.test.ts
index a26f980..112e2eb 100644
--- a/packages/adapter-utils/src/acpx-engine/execute.test.ts
+++ b/packages/adapter-utils/src/acpx-engine/execute.test.ts
@@ -2544,6 +2544,37 @@ describe("shared ACPX engine runtime behavior", () => {
expect(note).toBeTruthy();
});
+ it("points Claude auto memory at the run's AGENT_HOME without changing the session fingerprint", async () => {
+ const root = await makeTempRoot();
+ const stateDir = path.join(root, "state");
+ const cwd = path.join(root, "worktree");
+ await fs.mkdir(cwd, { recursive: true });
+ const stableHome = path.join(root, "agent-home");
+ const runAgentHome = (runId: string) => path.join(root, "file-sync", "runs", runId, "live");
+ const readSettings = async () =>
+ JSON.parse(await fs.readFile(path.join(cwd, ".claude", "settings.local.json"), "utf8")) as {
+ autoMemoryDirectory?: string;
+ permissions?: { additionalDirectories?: string[] };
+ };
+
+ const first = await runExecutor(
+ { agent: "claude", stateDir, cwd },
+ { context: { paperclipWorkspace: { cwd, agentHome: runAgentHome("a"), agentHomeForPermissions: stableHome } } },
+ );
+ expect((await readSettings()).autoMemoryDirectory).toBe(path.join(runAgentHome("a"), "memory"));
+
+ const second = await runExecutor(
+ { agent: "claude", stateDir, cwd },
+ { context: { paperclipWorkspace: { cwd, agentHome: runAgentHome("b"), agentHomeForPermissions: stableHome } } },
+ );
+ const written = await readSettings();
+ expect(written.autoMemoryDirectory).toBe(path.join(runAgentHome("b"), "memory"));
+ expect(written.permissions?.additionalDirectories).toContain(stableHome);
+ expect(written.permissions?.additionalDirectories).not.toContain(runAgentHome("b"));
+ expect(second.result.sessionParams?.configFingerprint).toBeTypeOf("string");
+ expect(second.result.sessionParams?.configFingerprint).toBe(first.result.sessionParams?.configFingerprint);
+ });
+
it("merges Paperclip allowlist into an existing .claude/settings.local.json without losing user entries", async () => {
const root = await makeTempRoot();
const stateDir = path.join(root, "state");
diff --git a/packages/adapter-utils/src/acpx-engine/execute.ts b/packages/adapter-utils/src/acpx-engine/execute.ts
index b1dc569..e5681b8 100644
--- a/packages/adapter-utils/src/acpx-engine/execute.ts
+++ b/packages/adapter-utils/src/acpx-engine/execute.ts
@@ -1558,6 +1558,7 @@ interface PaperclipClaudeSettingsResult {
additionalDirectories: string[];
defaultMode: string;
overrodeDontAsk: boolean;
+ autoMemoryDirectory: string | null;
}
function uniqueSorted(values: Array<string | null | undefined>): string[] {
@@ -1571,10 +1572,19 @@ function uniqueSorted(values: Array<string | null | undefined>): string[] {
// denies every non-allowlisted tool and never reaches `canUseTool`), and we
// widen the SDK's Read sandbox to include the Paperclip state dirs the agent
// needs to talk to its own control plane.
+//
+// It also points Claude's auto memory at `<AGENT_HOME>/memory`. The default
+// location lives under CLAUDE_CONFIG_DIR, which is a per-run temp home, so
+// memories were lost after every run. AGENT_HOME is restored before and saved
+// after each run, locally and in remote sandboxes. Claude ignores
+// `autoMemoryDirectory` only in checked-in project settings, not local ones.
async function writePaperclipClaudeSettings(input: {
cwd: string;
stateDir: string;
agentHome: string;
+ // The run's actual AGENT_HOME (the in-sandbox path for remote runs). It may
+ // differ from `agentHome`, which stays stable for session identity.
+ runAgentHome: string;
companyId: string;
}): Promise<PaperclipClaudeSettingsResult> {
const filePath = path.join(input.cwd, ".claude", "settings.local.json");
@@ -1630,7 +1640,14 @@ async function writePaperclipClaudeSettings(input: {
additionalDirectories: mergedAdditionalDirectories,
defaultMode,
};
- const next: Record<string, unknown> = { ...existing, permissions: nextPermissions };
+ const autoMemoryDirectory = input.runAgentHome
+ ? path.posix.join(input.runAgentHome, "memory")
+ : null;
+ const next: Record<string, unknown> = {
+ ...existing,
+ permissions: nextPermissions,
+ ...(autoMemoryDirectory ? { autoMemoryDirectory } : {}),
+ };
await writeFileAtomically({
target: filePath,
contents: `${JSON.stringify(next, null, 2)}\n`,
@@ -1642,6 +1659,7 @@ async function writePaperclipClaudeSettings(input: {
additionalDirectories: mergedAdditionalDirectories,
defaultMode,
overrodeDontAsk,
+ autoMemoryDirectory,
};
}
@@ -2064,12 +2082,18 @@ async function buildRuntime(input: {
// by the company root already granted in these settings; the process env
// still receives this run's actual AGENT_HOME.
agentHome: asString(workspaceContext.agentHomeForPermissions, agentHome),
+ // Auto memory follows the actual AGENT_HOME so it is saved with the run.
+ // It is not part of the session fingerprint: a moved memory directory
+ // must not invalidate a resumable conversation.
+ runAgentHome: agentHome,
companyId: agent.companyId,
});
skillCommandNotes.push(
`Wrote Paperclip-managed Claude settings to ${paperclipClaudeSettings.filePath} (defaultMode=${paperclipClaudeSettings.defaultMode}${
paperclipClaudeSettings.overrodeDontAsk ? "; overrode user dontAsk" : ""
- }, +${paperclipClaudeSettings.additionalDirectories.length} read root(s), +${paperclipClaudeSettings.allow.length} allow rule(s)).`,
+ }, +${paperclipClaudeSettings.additionalDirectories.length} read root(s), +${paperclipClaudeSettings.allow.length} allow rule(s)${
+ paperclipClaudeSettings.autoMemoryDirectory ? `, autoMemoryDirectory=${paperclipClaudeSettings.autoMemoryDirectory}` : ""
+ }).`,
);
} else if (acpxAgent === "codex") {
// Step 2 — codex-home.seed: the codex managed-home + skills preparation.
@@ -1,128 +0,0 @@
diff --git a/packages/adapter-utils/src/acpx-engine/execute.test.ts b/packages/adapter-utils/src/acpx-engine/execute.test.ts
index 112e2eb..004baf0 100644
--- a/packages/adapter-utils/src/acpx-engine/execute.test.ts
+++ b/packages/adapter-utils/src/acpx-engine/execute.test.ts
@@ -1857,6 +1857,45 @@ describe("shared ACPX engine runtime behavior", () => {
expect(changed.result.sessionParams?.configFingerprint).not.toBe(first.result.sessionParams?.configFingerprint);
});
+ it("keeps the per-run managed AI home out of session identity and keeps Claude transcripts", async () => {
+ const root = await makeTempRoot();
+ const stateDir = path.join(root, "state");
+ const config = { agentCommand: "node ./fake-acp.js", stateDir };
+ async function withManagedHome(home: string, claudeConfigDir = path.join(home, "provider")) {
+ return runExecutor({
+ ...config,
+ managedAiConnection: { identity: "grant-1:shared:abc" },
+ env: {
+ HOME: home,
+ XDG_CONFIG_HOME: path.join(home, "config"),
+ XDG_DATA_HOME: path.join(home, "data"),
+ CODEX_HOME: path.join(home, "provider"),
+ GROK_HOME: path.join(home, "provider"),
+ CLAUDE_CONFIG_DIR: claudeConfigDir,
+ },
+ }, { context: { taskId: "issue-1" } });
+ }
+ const fp = (run: Awaited<ReturnType<typeof runExecutor>>) => run.result.sessionParams?.configFingerprint;
+ const first = await withManagedHome(path.join(root, "paperclip-ai-a"));
+ const second = await withManagedHome(path.join(root, "paperclip-ai-b"));
+ const overridden = await withManagedHome(path.join(root, "paperclip-ai-c"), path.join(root, "custom-claude"));
+ expect(fp(first)).toBeTypeOf("string");
+ expect(fp(second)).toBe(fp(first));
+ expect(fp(overridden)).not.toBe(fp(first));
+
+ // The transcript dir of each managed home points at one dir in the state
+ // dir, so a resumed session finds its transcript after the home is deleted.
+ const transcripts = path.join(stateDir, "claude-projects");
+ expect(await fs.readlink(path.join(root, "paperclip-ai-a", "provider", "projects"))).toBe(transcripts);
+ expect(await fs.readlink(path.join(root, "paperclip-ai-b", "provider", "projects"))).toBe(transcripts);
+ await expect(fs.lstat(path.join(root, "custom-claude", "projects"))).rejects.toThrow();
+
+ // Without a managed AI connection the same paths are user config and still count.
+ const plainA = await runExecutor({ ...config, env: { HOME: path.join(root, "home-a") } }, { context: { taskId: "issue-1" } });
+ const plainB = await runExecutor({ ...config, env: { HOME: path.join(root, "home-b") } }, { context: { taskId: "issue-1" } });
+ expect(fp(plainB)).not.toBe(fp(plainA));
+ });
+
it("busts the session fingerprint when a stable configured PAPERCLIP_* value rotates", async () => {
const root = await makeTempRoot();
const stateDir = path.join(root, "state");
diff --git a/packages/adapter-utils/src/acpx-engine/execute.ts b/packages/adapter-utils/src/acpx-engine/execute.ts
index e5681b8..0fd486c 100644
--- a/packages/adapter-utils/src/acpx-engine/execute.ts
+++ b/packages/adapter-utils/src/acpx-engine/execute.ts
@@ -1663,6 +1663,26 @@ async function writePaperclipClaudeSettings(input: {
};
}
+// Claude keeps conversation transcripts in CLAUDE_CONFIG_DIR/projects. A
+// managed AI home is a per-run temp dir that the server deletes after the run,
+// so the next run could not resume ("Resource not found"). Point `projects` at
+// a directory in the agent's state dir, like ~/.claude/projects on a normal
+// install. The server's recursive cleanup removes the link, not its target.
+async function linkManagedClaudeTranscripts(input: {
+ claudeConfigDir: string;
+ stateDir: string;
+}): Promise<string> {
+ const target = path.join(input.stateDir, "claude-projects");
+ await fs.mkdir(input.claudeConfigDir, { recursive: true, mode: 0o700 });
+ await fs.mkdir(target, { recursive: true, mode: 0o700 });
+ const link = path.join(input.claudeConfigDir, "projects");
+ const current = await fs.readlink(link).catch(() => null);
+ if (current === target) return target;
+ await fs.rm(link, { recursive: true, force: true });
+ await fs.symlink(target, link, "dir");
+ return target;
+}
+
// Cross the CLI's staging seam for a runner-backed remote sandbox: ship the
// workspace (and, in PR 2, the per-adapter managed-home `assets`) into the
// sandbox and obtain the in-sandbox `workspaceRemoteDir` plus the non-null
@@ -2027,6 +2047,30 @@ async function buildRuntime(input: {
// are absent from tempKeysApplied and keep their compatibility protection.
if (!scratchKeys.has(key) || value !== scratch.dir) resolvedAdapterEnv[key] = value;
}
+ // A managed AI connection gets a fresh server-made temp home on every run
+ // (HOME, XDG_*, and the provider home under it). Hash a fixed placeholder for
+ // those paths so the session can resume; the server's own fingerprint does
+ // the same (managedAiSessionFingerprintConfig). Values that differ from the
+ // managed layout are real overrides and stay in the hash. A Claude session
+ // can only resume if its transcript survives the run, so this pairs with
+ // linkManagedClaudeTranscripts below. Remote runs keep their transcript in
+ // the sandbox and are left as they are.
+ const managedHome = config.managedAiConnection && !executionTargetIsRemote ? resolvedAdapterEnv.HOME : undefined;
+ if (managedHome) {
+ const managedLayout: Record<string, string> = {
+ HOME: "",
+ XDG_CONFIG_HOME: "config",
+ XDG_DATA_HOME: "data",
+ CODEX_HOME: "provider",
+ GROK_HOME: "provider",
+ CLAUDE_CONFIG_DIR: "provider",
+ };
+ for (const [key, sub] of Object.entries(managedLayout)) {
+ if (resolvedAdapterEnv[key] === (sub ? path.join(managedHome, sub) : managedHome)) {
+ resolvedAdapterEnv[key] = sub ? `<managed-ai-home>/${sub}` : "<managed-ai-home>";
+ }
+ }
+ }
if (authToken) env.PAPERCLIP_API_KEY = authToken;
// For the claude agent, set model via ANTHROPIC_MODEL at startup rather than
// via session/set_config_option — the ACP server's set_config_option handler
@@ -2088,6 +2132,15 @@ async function buildRuntime(input: {
runAgentHome: agentHome,
companyId: agent.companyId,
});
+ // Same condition as the fingerprint placeholder above: a resumable session
+ // must find its transcript in the next run's managed home.
+ if (resolvedAdapterEnv.CLAUDE_CONFIG_DIR === "<managed-ai-home>/provider" && env.CLAUDE_CONFIG_DIR) {
+ const transcriptsDir = await linkManagedClaudeTranscripts({
+ claudeConfigDir: env.CLAUDE_CONFIG_DIR,
+ stateDir,
+ });
+ skillCommandNotes.push(`Linked Claude transcripts to ${transcriptsDir}.`);
+ }
skillCommandNotes.push(
`Wrote Paperclip-managed Claude settings to ${paperclipClaudeSettings.filePath} (defaultMode=${paperclipClaudeSettings.defaultMode}${
paperclipClaudeSettings.overrodeDontAsk ? "; overrode user dontAsk" : ""
@@ -1,384 +0,0 @@
diff --git a/packages/plugins/sandbox-providers/kubernetes/src/lease-lifecycle.ts b/packages/plugins/sandbox-providers/kubernetes/src/lease-lifecycle.ts
index 87e18ae..2224e60 100644
--- a/packages/plugins/sandbox-providers/kubernetes/src/lease-lifecycle.ts
+++ b/packages/plugins/sandbox-providers/kubernetes/src/lease-lifecycle.ts
@@ -21,6 +21,7 @@ import { deleteJob, findPodForJob, getJobStatus } from "./job-orchestrator.js";
import {
deleteSandboxCr,
findPodForSandbox,
+ getSandboxCrStatus,
waitForSandboxReady,
} from "./sandbox-cr-orchestrator.js";
@@ -191,3 +192,60 @@ export async function destroyLeaseResources(
);
}
}
+
+export interface WorkloadGoneInput {
+ namespace: string;
+ /** Workload resource name (Sandbox CR name or Job name) == providerLeaseId. */
+ name: string;
+ backend: "sandbox-cr" | "job";
+ podName?: string | null;
+ /** Stays below the server's 30 s default plugin RPC timeout. */
+ timeoutMs?: number;
+ pollMs?: number;
+}
+
+/**
+ * Poll until the workload (and the named pod, if any) returns 404. Deletes use
+ * Foreground propagation, so the workload disappears only after its pod is
+ * gone. True is the evidence behind a `destroyed` termination receipt; false
+ * (still present at the deadline, or a read failed) means the caller must not
+ * claim one. Never throws: the delete itself already succeeded.
+ */
+export async function waitForWorkloadGone(
+ clients: KubeClients,
+ input: WorkloadGoneInput,
+): Promise<boolean> {
+ const deadline = Date.now() + (input.timeoutMs ?? 20_000);
+ const pollMs = input.pollMs ?? 500;
+ const gone = async (read: () => Promise<unknown>): Promise<boolean> => {
+ try {
+ await read();
+ return false;
+ } catch (err) {
+ if (isKubeNotFoundError(err)) return true;
+ throw err;
+ }
+ };
+ try {
+ return await pollUntilGone();
+ } catch {
+ return false;
+ }
+
+ async function pollUntilGone(): Promise<boolean> {
+ for (;;) {
+ const workloadGone = await gone(() =>
+ input.backend === "sandbox-cr"
+ ? getSandboxCrStatus(clients, input.namespace, input.name)
+ : getJobStatus(clients, input.namespace, input.name),
+ );
+ const podName = input.podName;
+ const podGone = !podName || await gone(() =>
+ clients.core.readNamespacedPod({ namespace: input.namespace, name: podName }),
+ );
+ if (workloadGone && podGone) return true;
+ if (Date.now() >= deadline) return false;
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
+ }
+ }
+}
diff --git a/packages/plugins/sandbox-providers/kubernetes/src/plugin.ts b/packages/plugins/sandbox-providers/kubernetes/src/plugin.ts
index 9801638..bf5ce0a 100644
--- a/packages/plugins/sandbox-providers/kubernetes/src/plugin.ts
+++ b/packages/plugins/sandbox-providers/kubernetes/src/plugin.ts
@@ -15,6 +15,7 @@ import type {
PluginEnvironmentSyncInParams,
PluginEnvironmentSyncOutParams,
PluginEnvironmentSyncResult,
+ PluginEnvironmentTerminationReceipt,
PluginEnvironmentValidateConfigParams,
PluginEnvironmentValidationResult,
} from "@paperclipai/plugin-sdk";
@@ -38,7 +39,11 @@ import {
} from "./sandbox-cr-orchestrator.js";
import { execInPod, execInPodStreaming, wrapCommandWithEnv } from "./pod-exec.js";
import { performSyncIn, performSyncOut, type PodStreamExec } from "./file-sync.js";
-import { checkLeaseResumable, destroyLeaseResources } from "./lease-lifecycle.js";
+import {
+ checkLeaseResumable,
+ destroyLeaseResources,
+ waitForWorkloadGone,
+} from "./lease-lifecycle.js";
import {
appendNetworkEgressDenyHint,
createScopedNetworkEgressPolicyOrReleaseWorkload,
@@ -578,7 +583,7 @@ const plugin = definePlugin({
async onEnvironmentReleaseLease(
params: PluginEnvironmentReleaseLeaseParams,
- ): Promise<void> {
+ ): Promise<PluginEnvironmentTerminationReceipt | void> {
if (!params.providerLeaseId) return;
const config = kubernetesProviderConfigSchema.parse(params.config);
const namespace =
@@ -613,11 +618,22 @@ const plugin = definePlugin({
?? (err as { code?: number; statusCode?: number }).statusCode;
if (code !== 404) throw err;
}
+
+ // The server grants continuation after a stopped run only with a
+ // termination receipt; without one a cancelled run's queued comment
+ // waits on `remote_cleanup` forever (REN-841). Claim `destroyed` only
+ // once the workload is confirmed gone.
+ const gone = await waitForWorkloadGone(clients, {
+ namespace,
+ name: params.providerLeaseId,
+ backend: leaseBackend,
+ });
+ if (gone) return { providerLeaseId: params.providerLeaseId, state: "destroyed" };
},
async onEnvironmentDestroyLease(
params: PluginEnvironmentDestroyLeaseParams,
- ): Promise<void> {
+ ): Promise<PluginEnvironmentTerminationReceipt | void> {
if (!params.providerLeaseId) return;
const config = kubernetesProviderConfigSchema.parse(params.config);
const namespace =
@@ -658,6 +674,13 @@ const plugin = definePlugin({
podName,
secretName,
});
+ const gone = await waitForWorkloadGone(clients, {
+ namespace,
+ name: params.providerLeaseId,
+ backend: leaseBackend,
+ podName,
+ });
+ if (gone) return { providerLeaseId: params.providerLeaseId, state: "destroyed" };
},
async onEnvironmentExecute(
diff --git a/packages/plugins/sandbox-providers/kubernetes/test/unit/lease-lifecycle.test.ts b/packages/plugins/sandbox-providers/kubernetes/test/unit/lease-lifecycle.test.ts
index af0875f..d775799 100644
--- a/packages/plugins/sandbox-providers/kubernetes/test/unit/lease-lifecycle.test.ts
+++ b/packages/plugins/sandbox-providers/kubernetes/test/unit/lease-lifecycle.test.ts
@@ -3,6 +3,7 @@ import {
checkLeaseResumable,
destroyLeaseResources,
isKubeNotFoundError,
+ waitForWorkloadGone,
} from "../../src/lease-lifecycle.js";
const SANDBOX_GROUP = "agents.x-k8s.io";
@@ -346,3 +347,64 @@ describe("destroyLeaseResources", () => {
).rejects.toThrow("forbidden");
});
});
+
+describe("waitForWorkloadGone", () => {
+ const input = { namespace: "ns", name: "pc-abc", backend: "sandbox-cr" as const, pollMs: 1 };
+
+ it("is true once the Sandbox CR and the pod return 404", async () => {
+ const getCr = vi.fn()
+ .mockResolvedValueOnce(readySandboxCr("pc-abc-pod"))
+ .mockRejectedValue(notFound());
+ const readPod = vi.fn().mockRejectedValue(notFound());
+ const clients = { custom: { getNamespacedCustomObject: getCr }, core: { readNamespacedPod: readPod } };
+
+ await expect(
+ waitForWorkloadGone(clients as never, { ...input, podName: "pc-abc-pod" }),
+ ).resolves.toBe(true);
+ expect(getCr).toHaveBeenCalledTimes(2);
+ expect(getCr).toHaveBeenCalledWith(expect.objectContaining({
+ group: SANDBOX_GROUP, version: SANDBOX_VERSION, plural: SANDBOX_PLURAL, namespace: "ns", name: "pc-abc",
+ }));
+ });
+
+ it("is false while the workload still exists at the deadline", async () => {
+ const clients = {
+ custom: { getNamespacedCustomObject: vi.fn().mockResolvedValue(readySandboxCr()) },
+ };
+
+ await expect(
+ waitForWorkloadGone(clients as never, { ...input, timeoutMs: 5 }),
+ ).resolves.toBe(false);
+ });
+
+ it("is false while the pod still exists even if the workload is gone", async () => {
+ const clients = {
+ custom: { getNamespacedCustomObject: vi.fn().mockRejectedValue(notFound()) },
+ core: { readNamespacedPod: vi.fn().mockResolvedValue({ status: { phase: "Running" } }) },
+ };
+
+ await expect(
+ waitForWorkloadGone(clients as never, { ...input, podName: "pc-abc-pod", timeoutMs: 5 }),
+ ).resolves.toBe(false);
+ });
+
+ it("checks the Job for job-backend leases", async () => {
+ const readJob = vi.fn().mockRejectedValue(notFound());
+ const clients = { batch: { readNamespacedJobStatus: readJob } };
+
+ await expect(
+ waitForWorkloadGone(clients as never, { ...input, backend: "job" }),
+ ).resolves.toBe(true);
+ expect(readJob).toHaveBeenCalledWith({ namespace: "ns", name: "pc-abc" });
+ });
+
+ it("is false (never throws) when a read fails with another error", async () => {
+ const clients = {
+ custom: {
+ getNamespacedCustomObject: vi.fn().mockRejectedValue(Object.assign(new Error("x"), { code: 403 })),
+ },
+ };
+
+ await expect(waitForWorkloadGone(clients as never, input)).resolves.toBe(false);
+ });
+});
diff --git a/packages/plugins/sandbox-providers/kubernetes/test/unit/plugin-lease-lifecycle.test.ts b/packages/plugins/sandbox-providers/kubernetes/test/unit/plugin-lease-lifecycle.test.ts
index 4c23410..f4fd073 100644
--- a/packages/plugins/sandbox-providers/kubernetes/test/unit/plugin-lease-lifecycle.test.ts
+++ b/packages/plugins/sandbox-providers/kubernetes/test/unit/plugin-lease-lifecycle.test.ts
@@ -184,12 +184,19 @@ describe("onEnvironmentDestroyLease", () => {
const deletePod = vi.fn().mockResolvedValue({});
const deleteSecret = vi.fn().mockResolvedValue({});
h.clients = {
- custom: { deleteNamespacedCustomObject: deleteCr },
- core: { deleteNamespacedPod: deletePod, deleteNamespacedSecret: deleteSecret },
+ custom: {
+ deleteNamespacedCustomObject: deleteCr,
+ getNamespacedCustomObject: vi.fn().mockRejectedValue(notFound()),
+ },
+ core: {
+ deleteNamespacedPod: deletePod,
+ deleteNamespacedSecret: deleteSecret,
+ readNamespacedPod: vi.fn().mockRejectedValue(notFound()),
+ },
batch: { deleteNamespacedJob: vi.fn() },
};
- await plugin.definition.onEnvironmentDestroyLease!({
+ const receipt = await plugin.definition.onEnvironmentDestroyLease!({
driverKey: "kubernetes",
companyId: "acme",
environmentId: "env-1",
@@ -209,14 +216,47 @@ describe("onEnvironmentDestroyLease", () => {
namespace: "paperclip-acme",
name: "pc-abc-env",
});
+ expect(receipt).toEqual({ providerLeaseId: "pc-abc", state: "destroyed" });
});
it("is idempotent: resolves cleanly when every resource is already gone (404)", async () => {
h.clients = {
- custom: { deleteNamespacedCustomObject: vi.fn().mockRejectedValue(notFound()) },
+ custom: {
+ deleteNamespacedCustomObject: vi.fn().mockRejectedValue(notFound()),
+ getNamespacedCustomObject: vi.fn().mockRejectedValue(notFound()),
+ },
core: {
deleteNamespacedPod: vi.fn().mockRejectedValue(notFound()),
deleteNamespacedSecret: vi.fn().mockRejectedValue(notFound()),
+ readNamespacedPod: vi.fn().mockRejectedValue(notFound()),
+ },
+ batch: { deleteNamespacedJob: vi.fn() },
+ };
+
+ await expect(
+ plugin.definition.onEnvironmentDestroyLease!({
+ driverKey: "kubernetes",
+ companyId: "acme",
+ environmentId: "env-1",
+ config: CONFIG,
+ providerLeaseId: "pc-abc",
+ leaseMetadata: leaseMetadata(),
+ }),
+ ).resolves.toEqual({ providerLeaseId: "pc-abc", state: "destroyed" });
+ });
+
+ it("returns no receipt when the gone check cannot read the cluster", async () => {
+ h.clients = {
+ custom: {
+ deleteNamespacedCustomObject: vi.fn().mockResolvedValue({}),
+ getNamespacedCustomObject: vi.fn().mockRejectedValue(
+ Object.assign(new Error("forbidden"), { code: 403 }),
+ ),
+ },
+ core: {
+ deleteNamespacedPod: vi.fn().mockResolvedValue({}),
+ deleteNamespacedSecret: vi.fn().mockResolvedValue({}),
+ readNamespacedPod: vi.fn().mockRejectedValue(notFound()),
},
batch: { deleteNamespacedJob: vi.fn() },
};
@@ -261,8 +301,12 @@ describe("onEnvironmentDestroyLease", () => {
core: {
deleteNamespacedPod: vi.fn().mockResolvedValue({}),
deleteNamespacedSecret: vi.fn().mockResolvedValue({}),
+ readNamespacedPod: vi.fn().mockRejectedValue(notFound()),
+ },
+ batch: {
+ deleteNamespacedJob: deleteJob,
+ readNamespacedJobStatus: vi.fn().mockRejectedValue(notFound()),
},
- batch: { deleteNamespacedJob: deleteJob },
};
await plugin.definition.onEnvironmentDestroyLease!({
@@ -280,3 +324,71 @@ describe("onEnvironmentDestroyLease", () => {
expect(deleteCr).not.toHaveBeenCalled();
});
});
+
+describe("onEnvironmentReleaseLease", () => {
+ it("returns a destroyed receipt once the Sandbox CR is gone (REN-841)", async () => {
+ const deleteCr = vi.fn().mockResolvedValue({});
+ h.clients = {
+ custom: {
+ deleteNamespacedCustomObject: deleteCr,
+ getNamespacedCustomObject: vi.fn().mockRejectedValue(notFound()),
+ },
+ };
+
+ const receipt = await plugin.definition.onEnvironmentReleaseLease!({
+ driverKey: "kubernetes",
+ companyId: "acme",
+ environmentId: "env-1",
+ config: CONFIG,
+ providerLeaseId: "pc-abc",
+ leaseMetadata: leaseMetadata(),
+ });
+
+ expect(deleteCr).toHaveBeenCalledWith(
+ expect.objectContaining({ namespace: "paperclip-acme", name: "pc-abc" }),
+ );
+ expect(receipt).toEqual({ providerLeaseId: "pc-abc", state: "destroyed" });
+ });
+
+ it("returns a destroyed receipt when the Sandbox CR was already deleted", async () => {
+ h.clients = {
+ custom: {
+ deleteNamespacedCustomObject: vi.fn().mockRejectedValue(notFound()),
+ getNamespacedCustomObject: vi.fn().mockRejectedValue(notFound()),
+ },
+ };
+
+ await expect(
+ plugin.definition.onEnvironmentReleaseLease!({
+ driverKey: "kubernetes",
+ companyId: "acme",
+ environmentId: "env-1",
+ config: CONFIG,
+ providerLeaseId: "pc-abc",
+ leaseMetadata: leaseMetadata(),
+ }),
+ ).resolves.toEqual({ providerLeaseId: "pc-abc", state: "destroyed" });
+ });
+
+ it("returns no receipt when the gone check fails", async () => {
+ h.clients = {
+ custom: {
+ deleteNamespacedCustomObject: vi.fn().mockResolvedValue({}),
+ getNamespacedCustomObject: vi.fn().mockRejectedValue(
+ Object.assign(new Error("server error"), { code: 500 }),
+ ),
+ },
+ };
+
+ await expect(
+ plugin.definition.onEnvironmentReleaseLease!({
+ driverKey: "kubernetes",
+ companyId: "acme",
+ environmentId: "env-1",
+ config: CONFIG,
+ providerLeaseId: "pc-abc",
+ leaseMetadata: leaseMetadata(),
+ }),
+ ).resolves.toBeUndefined();
+ });
+});
-7
View File
@@ -1,7 +0,0 @@
# Upstream paperclipai/paperclip commit this image is built from (REN-401).
# a027f76 is the master commit the nightly image ran when this was pinned, so
# the only change against that image is the patch next to this file.
# PAPERCLIP_VERSION is `git describe --tags --match 'v*' --long` of that
# commit, the value upstream stamps into its own images. Bump both together.
PAPERCLIP_COMMIT=a027f76a726e8a556674eded807c2de0e55f5cc0
PAPERCLIP_VERSION=v2026.916.0-219-ga027f76a7
@@ -1,976 +0,0 @@
# Paperclip Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Deploy Paperclip (server-local agent runs, option B) into the homelab cluster behind an Authentik gate, with CNPG Postgres and OpenBao-sourced secrets.
**Architecture:** One Flux `Kustomization` (`paperclip`) applies `kubernetes/apps/paperclip/`: a single-replica Deployment on a ceph-block PVC, CNPG `pclp-pg` (PG17) with barman backups to Hetzner, and a Traefik Ingress that backends only the `paperclip-gate` Authentik outpost. Namespace, baseline CNP, ClusterSecretStore allowlist and image automation live in the existing `infrastructure` tree.
**Tech Stack:** Flux v2 (kustomize-controller, image-reflector v1.2.5), CNPG + barman-cloud plugin, External Secrets → OpenBao, Cilium CNP, Traefik, Authentik blueprints.
**Spec:** `docs/superpowers/specs/2026-09-25-paperclip-design.md`
## Global Constraints
- Worktree: `/home/krenner/source/homelab-worktrees/paperclip`, branch `feat/paperclip`. Never edit the main checkout.
- Commits as claude-bot: `git -c user.name='Claude Bot' -c user.email='claude-bot@renner.uno' commit …`, message ends with `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
- Push: `git -c credential.helper='store --file=/home/krenner/.config/claude-bot/creds' push …`. PR via `tea pr create --login claude-bot`. Never self-merge.
- One Bash command per call: no `&&`, `;`, `|` chaining, no `2>/dev/null`, no `python -c`.
- Namespace `paperclip`; hostname `paperclip.renner.uno`; LAN-only (no `dns.renner.uno/public` label).
- Image `ghcr.io/paperclipai/paperclip`, tag `nightly`, digest-pinned, `digestReflectionPolicy: Always`.
- Postgres: CNPG `pclp-pg`, `ghcr.io/cloudnative-pg/postgresql:17`, 2 instances, `ceph-block`.
- PVC `paperclip-data`, `ceph-block`, 20Gi, mounted at `/paperclip`.
- OpenBao key `apps/paperclip` with fields `BETTER_AUTH_SECRET`, `PAPERCLIP_TOOL_ACTION_SIGNING_SECRET`, `PAPERCLIP_SECRETS_MASTER_KEY` (64-char hex), `CLAUDE_CODE_OAUTH_TOKEN`.
- Env: `PAPERCLIP_DEPLOYMENT_MODE=authenticated`, `PAPERCLIP_DEPLOYMENT_EXPOSURE=private`, `PAPERCLIP_PUBLIC_URL=https://paperclip.renner.uno`, `HOST=0.0.0.0`, `PORT=3100`.
- Manifest comments: one terse factual line; rationale goes in commit messages/PR.
- No `kubectl apply`/`exec`/`rollout restart` against the live cluster; Flux applies after merge. Server-side `--dry-run=server` is allowed.
## Review Focus
1. **Pod replaced mid agent run** (image bump, secret rotation via Reloader, node drain) — `Recreate` kills in-flight runs; expected: Paperclip marks them failed/recoverable on restart, not wedged. Covered in Task 9 Step 6.
2. **Shared OAuth token expired or missing** — expected: agent runs fail with a visible auth error in Paperclip, server stays healthy. Covered in Task 9 Step 7.
3. **Direct access bypassing the gate** from another namespace pod — expected: connection refused/dropped by CNP. Covered in Task 9 Step 4.
4. **Unauthenticated LAN request** to `paperclip.renner.uno` — expected: redirect to Authentik, never Paperclip's UI. Covered in Task 9 Step 3.
5. **Nightly image with a breaking DB migration** — expected: a base backup exists before the first bump lands. Covered in Task 9 Step 5.
---
### Task 1: Upstream facts that decide two manifest values
No repo changes; produces two decisions consumed by Task 6.
**Interfaces:**
- Produces: `SIGNUP_DISABLED` (`"true"` or `"false"`), `CONFIG_PARTIAL_OK` (yes/no), `ENTRYPOINT_NEEDS_ROOT` (yes/no).
- [ ] **Step 1: Sign-up vs instance claim.** Read upstream `server/src/auth/better-auth.ts`, `server/src/routes/auth.ts`, and the claim flow for `authenticated`+`private` (search `claim` under `server/src`):
Run: `gh api repos/paperclipai/paperclip/contents/server/src/routes/auth.ts -H "Accept: application/vnd.github.raw"`
Decide: if the first-admin claim creates the account through a path that ignores `disableSignUp` (e.g. invite/claim token handler calling the adapter directly), `SIGNUP_DISABLED="true"`. Otherwise `SIGNUP_DISABLED="false"` — the Authentik gate is the access control, and a follow-up PR flips it to `"true"` after the admin is claimed.
- [ ] **Step 2: Partial config.json.** Read `server/src/config.ts` and the config-file loader it calls (search `PAPERCLIP_CONFIG` / `readConfigFile`).
Run: `gh api repos/paperclipai/paperclip/contents/server/src/config.ts -H "Accept: application/vnd.github.raw"`
Decide `CONFIG_PARTIAL_OK=yes` if a file containing only `{"telemetry":{"enabled":false}}` parses (every other section optional/defaulted). If not, stop and report to the user — do not invent config fields.
- [ ] **Step 3: Entrypoint privilege.**
Run: `gh api repos/paperclipai/paperclip/contents/docker-entrypoint.sh -H "Accept: application/vnd.github.raw"`
`ENTRYPOINT_NEEDS_ROOT=yes` if it calls `usermod`/`groupmod`/`chown`/`gosu`. Record the result for Task 6.
- [ ] **Step 4: Anonymous health response.**
Run: `gh api repos/paperclipai/paperclip/contents/server/src/routes/health.ts -H "Accept: application/vnd.github.raw"`
Confirm `GET /api/health` returns HTTP 200 to an anonymous caller when healthy (probes use it). If it returns 401 anonymously, use a `tcpSocket` probe on 3100 in Task 6 instead.
---
### Task 2: Namespace, baseline CNP, ClusterSecretStore allowlist
**Files:**
- Modify: `kubernetes/infrastructure/namespaces/namespaces.yaml` (append)
- Create: `kubernetes/infrastructure/network-policies/paperclip-ns.yaml`
- Modify: `kubernetes/infrastructure/network-policies/kustomization.yaml`
- Modify: `kubernetes/infrastructure/post-crds/openbao-clustersecretstore.yaml` (sorted list)
**Interfaces:**
- Produces: namespace `paperclip`; ESO may reference `openbao` from it.
- [ ] **Step 1: Append the namespace** to the end of `namespaces.yaml`:
```yaml
---
apiVersion: v1
kind: Namespace
metadata:
name: paperclip
```
- [ ] **Step 2: Create `paperclip-ns.yaml`** (copy of `paperless-ns.yaml`'s `default-deny-ingress`, ns renamed, paperless-only CNPs dropped):
```yaml
# Traefik is not admitted: the only entry is the paperclip-gate outpost (apps/paperclip/network-policy.yaml).
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: default-deny-ingress
namespace: paperclip
spec:
endpointSelector: {}
ingress:
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": paperclip
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": database
- 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": paperclip
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": authentik
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": ingress
app.kubernetes.io/name: traefik
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": rook-ceph
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": kube-system
k8s-app: kube-dns
toPorts:
- ports:
- port: "53"
protocol: UDP
- toEntities:
- host
- remote-node
- world
- toEndpoints:
- 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 }
```
- [ ] **Step 3: Register it** — in `network-policies/kustomization.yaml` add ` - paperclip-ns.yaml` directly after ` - paperless-ns.yaml`.
- [ ] **Step 4: Allowlist the namespace** — in `openbao-clustersecretstore.yaml` insert ` - paperclip` between ` - monitoring` and ` - paperless` (list is sorted).
- [ ] **Step 5: Render check.**
Run: `kubectl kustomize kubernetes/infrastructure/network-policies`
Expected: output contains `namespace: paperclip`, exit 0.
Run: `kubectl apply --dry-run=server -f kubernetes/infrastructure/post-crds/openbao-clustersecretstore.yaml`
Expected: `clustersecretstore.external-secrets.io/openbao configured (server dry run)`.
- [ ] **Step 6: Commit** `feat(paperclip): namespace, baseline CNP and OpenBao allowlist`.
---
### Task 3: OpenBao secret `apps/paperclip`
Out-of-repo; must exist before merge or the ExternalSecret never becomes Ready.
**Interfaces:**
- Produces: OpenBao KV `apps/paperclip` with the four fields in Global Constraints.
- [ ] **Step 1:** Ask wiki-synth for the claude-bot OpenBao write procedure (note `claude-bot-openbao-write-only-identity`): login method, KV mount, exact `bao kv put`/`patch` syntax for an `apps/<name>` key. Use exactly what it returns.
- [ ] **Step 2: Write the generated fields** as claude-bot (write-only identity — never read values back). Values generated inline by the command:
`BETTER_AUTH_SECRET` = `openssl rand -hex 32`, `PAPERCLIP_TOOL_ACTION_SIGNING_SECRET` = `openssl rand -hex 32`, `PAPERCLIP_SECRETS_MASTER_KEY` = `openssl rand -hex 32` (64 hex chars = 32 bytes, accepted format).
Generate each with a separate `openssl rand -hex 32` call and pass it directly into the `bao kv put` from Step 1; do not echo or log them.
- [ ] **Step 3: Ask the user** to add the shared token themselves (never handle it): "Run `claude setup-token`, then `bao kv patch <mount>/apps/paperclip CLAUDE_CODE_OAUTH_TOKEN=<token>`." Wait for confirmation.
- [ ] **Step 4: Verify field names only** (no values): `bao kv metadata get <mount>/apps/paperclip` shows a current version ≥2. If claude-bot cannot read metadata, ask the user to confirm the four keys exist.
---
### Task 4: Image automation + initial digest
**Files:**
- Create: `kubernetes/infrastructure/image-automation/paperclip.yaml`
- Modify: `kubernetes/infrastructure/image-automation/kustomization.yaml`
**Interfaces:**
- Produces: ImagePolicy `flux-system:paperclip`; `INITIAL_DIGEST` (`sha256:…`) for Task 6.
- [ ] **Step 1: Create `paperclip.yaml`:**
```yaml
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImageRepository
metadata:
name: paperclip
namespace: flux-system
spec:
image: ghcr.io/paperclipai/paperclip
interval: 1h
---
# Upstream publishes no semver tags; track the nightly tag by digest.
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImagePolicy
metadata:
name: paperclip
namespace: flux-system
spec:
imageRepositoryRef:
name: paperclip
digestReflectionPolicy: Always
filterTags:
pattern: '^nightly$'
policy:
alphabetical:
order: asc
```
- [ ] **Step 2: Register** — add ` - paperclip.yaml` after ` - paperless-ngx.yaml` in `image-automation/kustomization.yaml`.
- [ ] **Step 3: Server dry-run.**
Run: `kubectl apply --dry-run=server -f kubernetes/infrastructure/image-automation/paperclip.yaml`
Expected: both objects `created (server dry run)`; no `unknown field "digestReflectionPolicy"` error.
- [ ] **Step 4: Resolve the current nightly digest.**
The workstation has `docker` only (no skopeo/crane/buildx, checked 2026-09-25); `docker manifest inspect` lists per-platform digests but not the index digest, so pull once:
Run: `docker pull ghcr.io/paperclipai/paperclip:nightly`
Run: `docker image inspect --format '{{index .RepoDigests 0}}' ghcr.io/paperclipai/paperclip:nightly`
Expected: `ghcr.io/paperclipai/paperclip@sha256:<64 hex>`. Record the `sha256:…` part as `INITIAL_DIGEST`.
Run: `docker image rm ghcr.io/paperclipai/paperclip:nightly`
- [ ] **Step 5: Commit** `feat(paperclip): Flux image automation tracks the nightly digest`.
---
### Task 5: Postgres (CNPG) and secrets in the app directory
**Files:**
- Create: `kubernetes/apps/paperclip/cluster.yaml`, `objectstore.yaml`, `scheduled-backup.yaml`, `hetzner-creds-externalsecret.yaml`, `externalsecret.yaml`
**Interfaces:**
- Produces: Secret `pclp-pg-app` (CNPG, key `uri`), Service `pclp-pg-rw`, Secret `paperclip-secrets`.
- [ ] **Step 1: `cluster.yaml`:**
```yaml
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: pclp-pg
namespace: paperclip
annotations:
kustomize.toolkit.fluxcd.io/prune: disabled # data guard
spec:
instances: 2
smartShutdownTimeout: 10
stopDelay: 60
# PG17: upstream's external Postgres target (docker/docker-compose.yml).
imageName: ghcr.io/cloudnative-pg/postgresql:17
storage:
storageClass: ceph-block
size: 10Gi
plugins:
- name: barman-cloud.cloudnative-pg.io
isWALArchiver: true
parameters:
barmanObjectName: pclp-pg-store
bootstrap:
initdb:
database: paperclip
owner: paperclip
postgresql:
parameters:
shared_buffers: "256MB"
max_connections: "200"
checkpoint_timeout: "15min"
max_wal_size: "2GB"
wal_compression: "zstd"
archive_timeout: "30min"
synchronous:
method: any
number: 1
dataDurability: preferred
resources:
requests:
cpu: 200m
memory: 512Mi
limits:
memory: 1Gi
```
- [ ] **Step 2: `objectstore.yaml`:**
```yaml
apiVersion: barmancloud.cnpg.io/v1
kind: ObjectStore
metadata:
name: pclp-pg-store
namespace: paperclip
annotations:
kustomize.toolkit.fluxcd.io/prune: disabled # backup target
spec:
configuration:
destinationPath: s3://renner-uno-k8s-cnpg/pclp-pg
endpointURL: https://fsn1.your-objectstorage.com
s3Credentials:
accessKeyId:
name: hetzner-s3-creds
key: ACCESS_KEY_ID
secretAccessKey:
name: hetzner-s3-creds
key: ACCESS_SECRET_KEY
region:
name: hetzner-s3-creds
key: REGION
wal:
compression: gzip
data:
compression: gzip
retentionPolicy: "30d"
```
- [ ] **Step 3: `scheduled-backup.yaml`:**
```yaml
apiVersion: postgresql.cnpg.io/v1
kind: ScheduledBackup
metadata:
name: pclp-pg-daily
namespace: paperclip
annotations:
kustomize.toolkit.fluxcd.io/prune: disabled # backup schedule
spec:
schedule: "0 45 2 * * *"
immediate: true
backupOwnerReference: self
cluster:
name: pclp-pg
method: plugin
pluginConfiguration:
name: barman-cloud.cloudnative-pg.io
```
- [ ] **Step 4: `hetzner-creds-externalsecret.yaml`:**
```yaml
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: hetzner-s3-creds
namespace: paperclip
annotations:
kustomize.toolkit.fluxcd.io/prune: disabled # barman S3 creds
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: openbao
target:
name: hetzner-s3-creds
dataFrom:
- extract:
key: infrastructure/hetzner-s3
```
- [ ] **Step 5: `externalsecret.yaml`:**
```yaml
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: paperclip-secrets
namespace: paperclip
spec:
refreshInterval: 10m
secretStoreRef:
kind: ClusterSecretStore
name: openbao
target:
name: paperclip-secrets
dataFrom:
- extract:
key: apps/paperclip
```
- [ ] **Step 6: Commit** `feat(paperclip): CNPG pclp-pg with barman backups and OpenBao secrets`. (Rendered together with Task 6's kustomization.)
---
### Task 6: Server workload, storage, ingress, network policy
**Files:**
- Create: `kubernetes/apps/paperclip/pvc.yaml`, `paperclip.yaml`, `gate-service.yaml`, `ingress.yaml`, `network-policy.yaml`, `kustomization.yaml`
**Interfaces:**
- Consumes: `pclp-pg-app` (`uri`), `paperclip-secrets`, ImagePolicy `flux-system:paperclip`, `INITIAL_DIGEST` (Task 4), `SIGNUP_DISABLED` / `ENTRYPOINT_NEEDS_ROOT` / probe choice (Task 1).
- Produces: pods labelled `app.kubernetes.io/name: paperclip` listening on 3100; Service `paperclip:3100`; ExternalName `ak-outpost-paperclip-gate:9000`.
- [ ] **Step 1: `pvc.yaml`:**
```yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: paperclip-data
namespace: paperclip
annotations:
kustomize.toolkit.fluxcd.io/prune: disabled # data guard
spec:
accessModes: [ReadWriteOnce]
storageClassName: ceph-block
resources:
requests:
storage: 20Gi
```
- [ ] **Step 2: `paperclip.yaml`** — replace `<INITIAL_DIGEST>` with the Task 4 value and `<SIGNUP_DISABLED>` with the Task 1 value. If Task 1 found `ENTRYPOINT_NEEDS_ROOT=no`, replace the container `securityContext` with `runAsNonRoot: true`, `runAsUser: 1000`, `runAsGroup: 1000`, `allowPrivilegeEscalation: false`, `capabilities: {drop: ["ALL"]}`. If Task 1 chose `tcpSocket`, replace each `httpGet` block with `tcpSocket: {port: http}`.
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: paperclip
namespace: paperclip
labels:
app.kubernetes.io/name: paperclip
annotations:
secret.reloader.stakater.com/reload: paperclip-secrets
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: paperclip
template:
metadata:
labels:
app.kubernetes.io/name: paperclip
spec:
enableServiceLinks: false
securityContext:
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
seccompProfile:
type: RuntimeDefault
initContainers:
# Telemetry is only configurable in config.json.
- name: disable-telemetry
image: ghcr.io/paperclipai/paperclip:nightly@<INITIAL_DIGEST> # {"$imagepolicy": "flux-system:paperclip"}
command:
- sh
- -c
- |
set -eu
f=/paperclip/instances/default/config.json
mkdir -p "$(dirname "$f")"
if [ -s "$f" ]; then
jq '.telemetry.enabled = false' "$f" > "$f.tmp"
else
printf '%s\n' '{"telemetry":{"enabled":false}}' > "$f.tmp"
fi
mv "$f.tmp" "$f"
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
volumeMounts:
- name: data
mountPath: /paperclip
containers:
- name: paperclip
image: ghcr.io/paperclipai/paperclip:nightly@<INITIAL_DIGEST> # {"$imagepolicy": "flux-system:paperclip"}
ports:
- name: http
containerPort: 3100
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: pclp-pg-app
key: uri
- name: HOST
value: "0.0.0.0"
- name: PORT
value: "3100"
- name: PAPERCLIP_DEPLOYMENT_MODE
value: authenticated
- name: PAPERCLIP_DEPLOYMENT_EXPOSURE
value: private
- name: PAPERCLIP_PUBLIC_URL
value: https://paperclip.renner.uno
- name: PAPERCLIP_AUTH_DISABLE_SIGN_UP
value: "<SIGNUP_DISABLED>"
envFrom:
- secretRef:
name: paperclip-secrets
volumeMounts:
- name: data
mountPath: /paperclip
startupProbe:
httpGet:
path: /api/health
port: http
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 60
livenessProbe:
httpGet:
path: /api/health
port: http
periodSeconds: 30
timeoutSeconds: 10
readinessProbe:
httpGet:
path: /api/health
port: http
periodSeconds: 10
timeoutSeconds: 5
resources:
requests:
cpu: 250m
memory: 1Gi
limits:
memory: 4Gi
securityContext:
runAsNonRoot: false
allowPrivilegeEscalation: true
capabilities:
drop: ["ALL"]
add: ["CHOWN", "DAC_OVERRIDE", "FOWNER", "SETGID", "SETUID"]
volumes:
- name: data
persistentVolumeClaim:
claimName: paperclip-data
---
apiVersion: v1
kind: Service
metadata:
name: paperclip
namespace: paperclip
spec:
selector:
app.kubernetes.io/name: paperclip
ports:
- name: http
port: 3100
targetPort: http
```
- [ ] **Step 3: `gate-service.yaml`:**
```yaml
# ExternalName mirror so the Ingress can backend the paperclip-gate outpost in the authentik namespace.
apiVersion: v1
kind: Service
metadata:
name: ak-outpost-paperclip-gate
namespace: paperclip
spec:
type: ExternalName
externalName: ak-outpost-paperclip-gate.authentik.svc.cluster.local
ports:
- port: 9000
targetPort: 9000
```
- [ ] **Step 4: `ingress.yaml`:**
```yaml
# LAN-only (no dns.renner.uno/public label); backends the gate outpost, never the Service.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: paperclip
namespace: paperclip
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
spec:
ingressClassName: traefik
tls:
- hosts:
- paperclip.renner.uno
secretName: paperclip-tls
rules:
- host: paperclip.renner.uno
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: ak-outpost-paperclip-gate
port:
number: 9000
```
- [ ] **Step 5: `network-policy.yaml`** (Traefik pod-side port is 8443, verified via `kubectl get pods -n ingress -l app.kubernetes.io/name=traefik`):
```yaml
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: paperclip-gate-ingress
namespace: paperclip
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: paperclip
ingress:
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": authentik
goauthentik.io/outpost-name: paperclip-gate
toPorts:
- ports:
- port: "3100"
protocol: TCP
---
# Covers the server and every agent it spawns (same pod).
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: paperclip-egress
namespace: paperclip
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: paperclip
egress:
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": paperclip
cnpg.io/cluster: pclp-pg
toPorts:
- ports:
- port: "5432"
protocol: TCP
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": kube-system
k8s-app: kube-dns
toPorts:
- ports:
- port: "53"
protocol: UDP
- port: "53"
protocol: TCP
# Gitea, Woodpecker, HA, Loki via their *.renner.uno names.
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": ingress
app.kubernetes.io/name: traefik
toPorts:
- ports:
- port: "8443"
protocol: TCP
- toEntities:
- kube-apiserver
toPorts:
- ports:
- port: "6443"
protocol: TCP
- toEntities:
- world
toPorts:
- ports:
- port: "443"
protocol: TCP
```
- [ ] **Step 6: `kustomization.yaml`:**
```yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- externalsecret.yaml
- hetzner-creds-externalsecret.yaml
- cluster.yaml
- objectstore.yaml
- scheduled-backup.yaml
- pvc.yaml
- paperclip.yaml
- gate-service.yaml
- ingress.yaml
- network-policy.yaml
```
- [ ] **Step 7: Render check.**
Run: `kubectl kustomize kubernetes/apps/paperclip`
Expected: exit 0; output contains `kind: Cluster`, `kind: Deployment`, `claimName: paperclip-data`, and no literal `<INITIAL_DIGEST>` or `<SIGNUP_DISABLED>`.
Run: `grep -rn "<INITIAL_DIGEST>\|<SIGNUP_DISABLED>" kubernetes/apps/paperclip`
Expected: no output, exit 1.
- [ ] **Step 8: Commit** `feat(paperclip): server Deployment, PVC, gated Ingress and CNPs`.
---
### Task 7: Authentik gate
**Files:**
- Modify: `kubernetes/apps/authentik/blueprints-configmap.yaml` (insert before the line ` # navidrome-gate: the Navidrome web UI.`)
- Create: `kubernetes/apps/authentik/paperclip-gate-egress.yaml`
- Modify: `kubernetes/apps/authentik/kustomization.yaml`
**Interfaces:**
- Produces: outpost pods labelled `goauthentik.io/outpost-name: paperclip-gate`, Service `ak-outpost-paperclip-gate.authentik:9000`.
- [ ] **Step 1: Insert blueprint entries** (same indentation as the neighbouring `wiki-gate` block):
```yaml
# paperclip-gate: Paperclip UI; the CNP admits only this outpost. Paperclip keeps its own login behind it.
- id: paperclip-users-group
model: authentik_core.group
identifiers:
name: Paperclip Users
state: present
attrs:
is_superuser: false
users:
- !Find [authentik_core.user, [username, k.renner]]
- id: paperclip-gate-provider
model: authentik_providers_proxy.proxyprovider
identifiers:
name: paperclip-gate
state: present
attrs:
authentication_flow: !Find [authentik_flows.flow, [slug, passkey-passwordless-authentication]]
authorization_flow: !Find [authentik_flows.flow, [slug, passkey-stepup-authorization]]
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
mode: proxy
internal_host: http://paperclip.paperclip.svc.cluster.local:3100
internal_host_ssl_validation: false
external_host: https://paperclip.renner.uno
cookie_domain: renner.uno
access_token_validity: hours=24
refresh_token_validity: days=30
intercept_header_auth: true
- model: authentik_core.application
identifiers:
slug: paperclip-gate
state: present
attrs:
name: Paperclip
slug: paperclip-gate
provider: !KeyOf paperclip-gate-provider
meta_launch_url: https://paperclip.renner.uno
meta_description: "Paperclip agent orchestration"
- model: authentik_policies.policybinding
identifiers:
target: !Find [authentik_core.application, [slug, paperclip-gate]]
group: !KeyOf paperclip-users-group
order: 0
state: present
attrs:
enabled: true
negate: false
- model: authentik_outposts.outpost
identifiers:
name: paperclip-gate
state: present
attrs:
type: proxy
service_connection: !KeyOf k8s-conn
providers:
- !KeyOf paperclip-gate-provider
config:
log_level: info
authentik_host: "http://authentik-server.authentik.svc.cluster.local"
authentik_host_insecure: false
authentik_host_browser: "https://auth.renner.uno/"
refresh_interval: "minutes=5"
container_image: null
object_naming_template: "ak-outpost-%(name)s"
kubernetes_namespace: authentik
kubernetes_replicas: 1
kubernetes_service_type: ClusterIP
kubernetes_disabled_components: ["ingress"]
kubernetes_image_pull_secrets: []
kubernetes_ingress_annotations: {}
kubernetes_ingress_class_name: null
kubernetes_ingress_secret_name: "authentik-outpost-tls"
kubernetes_json_patches: null
```
- [ ] **Step 2: `paperclip-gate-egress.yaml`:**
```yaml
# Lets the paperclip-gate outpost reach the Paperclip server; scoped to this outpost's pods.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: paperclip-gate-egress
namespace: authentik
spec:
endpointSelector:
matchLabels:
goauthentik.io/outpost-name: paperclip-gate
egress:
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": paperclip
app.kubernetes.io/name: paperclip
toPorts:
- ports:
- port: "3100"
protocol: TCP
```
- [ ] **Step 3: Register** — add ` - paperclip-gate-egress.yaml` after ` - paperless-gate-egress.yaml` in `apps/authentik/kustomization.yaml`.
- [ ] **Step 4: Render + dry-run.**
Run: `kubectl kustomize kubernetes/apps/authentik`
Expected: exit 0 (valid YAML incl. the blueprint ConfigMap).
Run: `kubectl apply --dry-run=server -f kubernetes/apps/authentik/paperclip-gate-egress.yaml`
Expected: `ciliumnetworkpolicy.cilium.io/paperclip-gate-egress created (server dry run)`.
- [ ] **Step 5: Commit** `feat(authentik): paperclip-gate outpost and Paperclip Users group`.
---
### Task 8: Flux Kustomization
**Files:**
- Create: `kubernetes/clusters/homelab/paperclip.yaml`
- [ ] **Step 1:**
```yaml
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: paperclip
namespace: flux-system
spec:
interval: 10m
retryInterval: 1m
timeout: 20m
sourceRef:
kind: GitRepository
name: flux-system
path: ./kubernetes/apps/paperclip
prune: true
wait: true
dependsOn:
- name: infrastructure
- name: infrastructure-crds
- name: infrastructure-ceph-cluster
- name: infrastructure-cnpg-barman
healthCheckExprs:
- apiVersion: postgresql.cnpg.io/v1
kind: Cluster
failed: "has(status.phase) && status.phase.startsWith('Failed')"
current: "has(status.phase) && (status.phase == 'Cluster in healthy state' || status.readyInstances > 0)"
```
- [ ] **Step 2: Server dry-run.**
Run: `kubectl apply --dry-run=server -f kubernetes/clusters/homelab/paperclip.yaml`
Expected: `kustomization.kustomize.toolkit.fluxcd.io/paperclip created (server dry run)`.
- [ ] **Step 3: Commit** `feat(paperclip): Flux Kustomization`.
---
### Task 9: PR, merge by user, live verification
- [ ] **Step 1: Push and open the PR** (Task 3 must be complete).
Run: `git -c credential.helper='store --file=/home/krenner/.config/claude-bot/creds' push -u origin feat/paperclip`
Run: `tea pr create --login claude-bot --base master --head feat/paperclip --title "feat(paperclip): deploy Paperclip (server-local runs, trial)" --description "<summary of spec decisions + link to spec + bootstrap steps>\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)"`
Then wait for the user to merge. Do not merge.
- [ ] **Step 2: Reconciliation.** After merge:
Run: `flux get kustomizations paperclip`
Expected: `Ready True`. If False, read `kubectl describe kustomization paperclip -n flux-system` and the failing object's events; diagnose, don't poke.
Run: `kubectl get cluster -n paperclip pclp-pg`
Expected: `Cluster in healthy state`, 2 ready instances.
Run: `kubectl get pods -n paperclip -l app.kubernetes.io/name=paperclip`
Expected: `1/1 Running`.
- [ ] **Step 3: Gate enforced (Review Focus 4).**
Run: `curl -sS -o /dev/null -w '%{http_code} %{redirect_url}\n' https://paperclip.renner.uno/`
Expected: `302` redirecting to `https://paperclip.renner.uno/outpost.goauthentik.io/…` or `auth.renner.uno`, never `200`.
- [ ] **Step 4: Gate not bypassable (Review Focus 3).**
`default` has no CNPs (verified 2026-09-25), so its egress is open and only paperclip's ingress policy can block the probe.
Run: `kubectl get ciliumnetworkpolicies -n default`
Expected: `No resources found` (else pick another CNP-free namespace).
Run: `kubectl run cnp-probe -n default --rm -i --restart=Never --image=curlimages/curl:8.10.1 -- curl -sS -m 5 http://paperclip.paperclip.svc.cluster.local:3100/api/health`
Expected: timeout (exit 28), no HTTP response.
- [ ] **Step 5: First backup (Review Focus 5).**
Run: `kubectl get backup -n paperclip`
Expected: one `pclp-pg-daily-…` with phase `completed`.
- [ ] **Step 6: Image policy.**
Run: `kubectl get imagepolicy -n flux-system paperclip -o jsonpath='{.status.latestRef}'`
Expected: `nightly` tag with a `sha256:` digest.
- [ ] **Step 7: Hand-over to the user (browser steps, theirs):**
1. Open `https://paperclip.renner.uno`, pass Authentik, claim the instance as admin.
2. Create a test agent with the `claude-local` adapter and a trivial task ("reply with OK"); it must complete with the shared token (Review Focus 2: if it fails, report the run's error text — an expired token must show as an auth error, not a hang).
3. If `SIGNUP_DISABLED` was `"false"`, open the follow-up PR flipping `PAPERCLIP_AUTH_DISABLE_SIGN_UP` to `"true"` once the admin is claimed.
4. Review Focus 1 is observed on the first nightly bump: check the interrupted-run status in the UI.
- [ ] **Step 8: Wiki.** Route the verified Paperclip facts (execution topology, env vars, image tags, digest-reflection usage) through wiki-ingest → wiki-distill once the deployment has proven out.
@@ -0,0 +1,130 @@
# homelab → konairius/homelab, master → main — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Move the Gitea repo `k.renner/homelab` into the `konairius` org and rename its default branch `master` → `main`, with no lost Flux sync, pipeline, cron, secret or review.
**Architecture:** Two separate cut-overs, each with its own window, prep PR and rollback: **Cut-over 1** moves the repo (path changes, branch stays `master`); **Cut-over 2** renames the branch (path stays). Everything that can land beforehand lands beforehand; each cut-over's own PR is merged inside its window. The pattern is the one proven on vsftpd, notes, renovate-config, authentik-samba-bridge and homeassistant on 2026-10-18.
**Tech Stack:** Gitea 1.27 (API, transfer, `repoRenameBranch`), Woodpecker v3.19.0 (move API, crons, org secrets), Flux (GitRepository `flux-system`, ImageUpdateAutomation `homelab`), secret-rotator, OpenBao.
**Spec:** this conversation's decisions, recorded in the wiki raw notes of 2026-10-18 (konairius migration series). No separate spec doc.
## Global Constraints
- All repos live in `konairius` (exception: `k.renner/turm1388` stays).
- The default branch of every repo is `main`.
- Gitea old paths redirect (301) for reads; **Gitea API writes through an old path fail** (wiki on `k.renner/notes`, 2026-10-18). Every writer must address the new path right after the transfer.
- A transferred repo keeps Woodpecker's old org link until `POST /api/repos/{id}/move?to=<owner>/<repo>` (homelab: Woodpecker repo id `1`). Org secrets follow the org link.
- Gitea's `RenameBranch` renames, in one transaction: the branch, the default branch, a protection rule named exactly `master`, and the base/head of unmerged PRs (`models/git/branch.go`). It does **not** keep a `master` ref; anything fetching `master` fails afterwards.
- Renaming the default branch needs repo admin (owner or site admin), not `claude-bot`.
- Gitea's transfer removes collaborators who are **members** of the new org (their access then comes only from their konairius team) and keeps non-members (`services/repository/transfer.go`).
- `woodpecker repo repair` copies the forge's default branch into Woodpecker (`repairRepo` → `Repo.Update` sets `Branch`).
- Checked live 2026-10-18: Woodpecker repo id `1`; cron ids `alert-monitor` 4, `ci-claude-refresh` 7, `daily-security-scan` 2, `issue-fix` 5, `update-audit` 3, `weekly-update` 1, `wiki-maintenance` 8; the `k.renner` org holds only `claude_code_oauth_token` and `review_bot_token`, every other pipeline secret is a repo secret of homelab.
- Gitea API: `PATCH /repos/{owner}/{repo}/branches/{branch}` with `{"name": "<new>"}` (`repoRenameBranch`), present on our Gitea.
- Each service reads only its own OpenBao path; no cross-service key sharing.
- Never force-push; PRs for every repo change; never `2>/dev/null`.
## Review Focus
1. **Flux fetch across the transfer.** Flux's GitRepository fetches `k.renner/homelab.git` until the URL change lands. Documented behaviour says this works: source-controller v1.9.5 uses go-git v5.19.1 (its `go.mod`), whose `RedirectPolicy` "mirror[s] Git's http.followRedirects config" with the zero value `initial` (pkg.go.dev); git documents `initial` as following the redirect on the first request and using "the redirected URL as the base for the follow-up requests" (`git-config`, `http.followRedirects`). Gitea's 301 stays on the same host and scheme, so fetch and the IUA push both reach `konairius/homelab`. Fallback only, should that fail: Task 3 has a declared break-glass (suspend the `flux-system` Kustomization so it cannot re-apply the old URL, patch the GitRepository URL to the value the PR sets, resume once the source is Ready on the merge commit), checked by `flux get source git flux-system`.
6. **Write access lost in the transfer.** Any writer that is a konairius org member loses its collaborator grant and keeps only its team permission (`flux` pushes for image automation; claude-bot, issue-fix-bot, security-bot, review-bot, argus-bot, renovate-bot write PRs/reviews). Expect: Task 3 Step 0 snapshot of collaborators, konairius teams and the branch-protection whitelists, compared after Step 1.
2. **Image automation push between merge and rename (Cut-over 2).** The IUA pushes to `master`; after the Task 5 PR it expects `main`. Expect: IUA suspended for the whole window and resumed only after Flux reports `main`.
3. **Org secrets after the Woodpecker move.** Homelab pipelines use the org secrets `claude_code_oauth_token` and `review_bot_token`; after the move they resolve in `konairius`. Expect: both exist in `konairius` before Cut-over 1 (Task 1 verification).
4. **Crons on a vanished branch.** All 7 crons run on `master`. Expect: each switched to `main` in place (`woodpecker repo cron update --branch main`) inside the Cut-over 2 window, verified by `cron ls`.
5. **Rotations that verify against the old path.** flux-git (`GITEA_REQUIRE_PUSH=true`, verify repo `k.renner/homelab`) would fail every quarter. Expect: verify repo switched in Task 2 and a manual rotator run in Task 3.
---
### Task 1: Org secrets in konairius before the move
**Files:**
- Modify: `kubernetes/infrastructure/openbao/rotation/claude-oauth.yaml` (add a second container `sync-woodpecker-konairius`, same env with `WOODPECKER_ORG=konairius`)
- Modify: `kubernetes/infrastructure/openbao/rotation/review-bot.yaml` (same: a second sync container for `konairius`, `WOODPECKER_SECRET_NAME=review_bot_token`, events `cron,manual,pull_request`)
**Interfaces:** Produces the konairius org secrets `claude_code_oauth_token` and `review_bot_token` that Task 3 relies on. The k.renner containers stay until Task 4.
- [ ] **Step 1:** Add the containers. Both CronJobs reuse their existing OpenBao role (it only reads OpenBao and the woodpecker-bot bootstrap; no policy change).
- [ ] **Step 2:** `kubectl kustomize kubernetes/infrastructure/openbao/rotation` renders.
- [ ] **Step 3:** PR, merge, `flux reconcile kustomization infrastructure`.
- [ ] **Step 4:** Run both once: `kubectl create job -n openbao --from=cronjob/claude-oauth-secret-sync claude-oauth-konairius` and `--from=cronjob/review-bot-secret-rotate review-bot-konairius`. Expected logs: `updated|created woodpecker org secret konairius/<name>`. Note: the review-bot run also executes its init containers `rotate-mint` and `rotate-mint-mcp`, i.e. mints and reaps **both** review-bot PATs off schedule. Keeping the two newest makes one extra run safe; afterwards check `kubectl get externalsecret -n review-bot review-bot-secrets` is SecretSynced before anything else rotates.
- [ ] **Step 5:** `woodpecker org secret ls --organization konairius` lists `claude_code_oauth_token`, `review_bot_token`, `registry_bot_token`; `woodpecker org secret ls --organization k.renner` still lists only `claude_code_oauth_token` and `review_bot_token` (nothing else to carry over).
### Task 2: PR "follow homelab to konairius/homelab" (prepared, merged in Task 3)
**Files (all FUNCTIONAL `k.renner/homelab` references, 2026-10-18 inventory):**
- `kubernetes/clusters/homelab/flux-system/gotk-sync.yaml:14` — GitRepository url → `https://gitea.renner.uno/konairius/homelab.git`
- `kubernetes/infrastructure/openbao/rotation/flux-git.yaml:81` — `GITEA_VERIFY_REPO` → `konairius/homelab` (and the comment at :84)
- `kubernetes/apps/review-bot/deployment.yaml:38` — `REPOS` entry → `konairius/homelab`
- `.woodpecker/alert-monitor.yml:133,241`, `ci-claude-refresh.yml:177,179,200`, `issue-fix.yml:85,166,190,199`, `pr-review.yml:46`, `security-scan.yml:150,160`, `update-audit.yml:114` — API, clone and push URLs
- Header comments with `--repo k.renner/homelab` / `pipeline deploy k.renner/homelab` in the same files and `e2e.yml:10`
- `review-bot/go.mod:1` → `module gitea.renner.uno/konairius/homelab/review-bot` (no Go file imports it) and the test fixtures in `review-bot/*_test.go` (consistency only)
- `ci/images/heating-can/Containerfile:66` — OCI source label
- [ ] **Step 1:** Apply the replacements; leave `docs/`, `PROPOSAL*` and `MIGRATION*` history as is.
- [ ] **Step 2:** `git grep -n 'k.renner/homelab' -- ':!docs'` returns only historical files (`PROPOSAL*`, `MIGRATION*`).
- [ ] **Step 3:** `go test -C review-bot ./...` passes; `kubectl kustomize kubernetes/clusters/homelab` and the touched app directories render.
- [ ] **Step 4:** Open the PR titled "(merge inside Cut-over 1 only)"; do not merge.
### Task 3: Cut-over 1 — transfer (window)
**Preconditions:** Task 1 verified; Task 2 PR green; no deploy pipeline running (`woodpecker pipeline ls k.renner/homelab --limit 5`); record `flux get source git flux-system` revision.
- [ ] **Step 0 (owner, needs admin):** snapshot `tea api --login renner.uno /repos/k.renner/homelab/collaborators`, `tea api --login renner.uno /orgs/konairius/teams` plus each team's members, and the `master` rule's push/merge/approvals whitelists (`tea api --login renner.uno /repos/k.renner/homelab/branch_protections`). Any writer that is a konairius member must get write access through its team, or be re-added as collaborator right after Step 1.
- [ ] **Step 1 (owner):** `tea api --login renner.uno -X POST /repos/k.renner/homelab/transfer -f new_owner=konairius`. Expect `curl -sI https://gitea.renner.uno/k.renner/homelab` → 301.
- [ ] **Step 2 (claude-bot):** Woodpecker move: `POST $WOODPECKER_SERVER/api/repos/1/move?to=konairius/homelab` → 204; `woodpecker repo ls` shows `konairius/homelab`.
- [ ] **Step 3 (owner):** merge the Task 2 PR.
- [ ] **Step 1a:** repeat the Step 0 reads against `konairius/homelab` and compare; re-add any lost writer as collaborator before continuing.
- [ ] **Step 4:** `flux reconcile source git flux-system`. If it fails to fetch: break-glass — `flux suspend kustomization flux-system` (else it re-applies the last good artifact with the old URL), then `kubectl -n flux-system patch gitrepository flux-system --type merge -p '{"spec":{"url":"https://gitea.renner.uno/konairius/homelab.git"}}'` (the value the PR sets), wait for the source to be Ready on the Task 2 merge commit, then `flux resume kustomization flux-system`. Expect: revision = Task 2 merge commit, Ready.
- [ ] **Step 5:** `flux reconcile image update homelab` after the next ImagePolicy change, or check the last IUA push reaches `konairius/homelab` (commit by Flux Image Automation on master).
- [ ] **Step 6:** Run flux-git rotator once (`kubectl create job -n openbao --from=cronjob/flux-git-secret-rotate flux-git-move`); log shows `pull+push verified` against `konairius/homelab`.
- [ ] **Step 6a:** Expected side effects of the Task 2 merge, not failures: `build-ci-images` rebuilds the review-bot image (go.mod, tests) and heating-can (label), and image automation rolls out the new review-bot tag; `ci-claude-refresh` runs (its own file is in its path filter) and may open a ci-claude bump PR.
- [ ] **Step 7:** Verify writers: a PR on konairius/homelab gets a review-bot review; `woodpecker pipeline ls konairius/homelab` shows the merge's push pipeline green; an agent cron (alert-monitor, every 15 min) succeeds; `security-scan`/`issue-fix` open PRs against the new path at their next run.
- [ ] **Step 8:** Check the GitHub push mirror (`konairius/Homelab` on GitHub, restore-test source) still syncs (Gitea repo Settings → Mirror, last sync after the transfer).
**Rollback:** owner transfers back to `k.renner`, Woodpecker move back to `k.renner/homelab`, revert the Task 2 PR.
### Task 4: After Cut-over 1
- [ ] Remove the `k.renner` sync containers from Task 1 (nothing in the `k.renner` Woodpecker org uses them any more; turm1388 uses repo secrets only). PR, reconcile.
- [ ] Workstation: homelab clone remote → `https://gitea.renner.uno/konairius/homelab.git`; `~/source/CLAUDE.md:13` row → `konairius/homelab`.
- [ ] Wiki deposit of the observed Cut-over 1 results.
### Task 5: PR "default branch main" (prepared, merged in Task 6)
**Files (all FUNCTIONAL `master` references):**
- `.woodpecker/build-ci-images.yml:39`, `ci-claude-refresh.yml:71`, `deploy-claude-workstation.yml:4`, `deploy-edge-mqtt-bridge.yml:4`, `deploy-infrastructure.yml:7`, `deploy-netbird-client.yml:4`, `deploy-netbird-proxy.yml:4`, `deploy-netbird-server.yml:4`, `security-scan.yml:72` — `branch: main`
- `.woodpecker/ci-claude-refresh.yml:211`, `issue-fix.yml:202`, `security-scan.yml:155` — PR `base: "main"`
- `kubernetes/clusters/homelab/flux-system/gotk-sync.yaml:11` — `ref.branch: main`
- `kubernetes/infrastructure/image-automation/update-automation.yaml:26,36` — checkout and push branch `main`
- `kubernetes/restore-test/flux-root.yaml:16`, `kubernetes/vmtest-job.yaml:90` — `main`
- `kubernetes/bootstrap.sh:107` default `main`, `:1478-1479` guard on `main`, `:102` example
- **Not** the prompts: `review-bot/prompt.md` is baked into the review-bot image, which `build-ci-images` only rebuilds on a push to the branch its `when:` names. In this PR that filter flips to `main` while the PR merges into `master`, so the prompt edit would not ship. It moves to Task 6 Step 9.
- [ ] **Step 1:** Apply; `git grep -n -w master -- ':!docs' ':!ansible'` returns only historical/runbook text (handled in Task 7), the two prompts, and unrelated words (VRRP, Open3E, Multus `"master"`).
- [ ] **Step 2:** `bash -n kubernetes/bootstrap.sh`; `kubectl kustomize kubernetes/clusters/homelab` renders.
- [ ] **Step 3:** Open the PR titled "(merge inside Cut-over 2 only)"; do not merge.
### Task 6: Cut-over 2 — rename master → main (window)
**Preconditions:** Cut-over 1 done; Task 5 PR green; no deploy pipeline running.
- [ ] **Step 1:** `flux suspend image update homelab`.
- [ ] **Step 2 (owner):** merge the Task 5 PR into `master`. Flux now points at `main`, which does not exist yet; the cluster keeps its last applied state.
- [ ] **Step 3 (owner, immediately):** `tea api --login renner.uno -X PATCH /repos/konairius/homelab/branches/master -f name=main`. Expect: default branch `main`, protection rule now named `main` (`GET /repos/konairius/homelab/branches/main` → `protected: true, required_approvals: 1`), open PRs based on `main`.
- [ ] **Step 4:** `flux reconcile source git flux-system` → Ready on `main@<merge commit>`; `flux get kustomizations` all Ready.
- [ ] **Step 5:** Crons: for each of `alert-monitor`(4), `ci-claude-refresh`(7), `daily-security-scan`(2), `issue-fix`(5), `update-audit`(3), `weekly-update`(1): `woodpecker repo cron update --repository konairius/homelab --id <id> --branch main`. Delete `wiki-maintenance`(8): no pipeline filters on it (`git grep wiki-maintenance -- .woodpecker` is empty), it starts nothing. `cron ls` shows six crons, all on `main`.
- [ ] **Step 6:** `woodpecker repo repair konairius/homelab`; `woodpecker repo show` shows branch `main`.
- [ ] **Step 7:** `flux resume image update homelab`; next IUA commit lands on `main`.
- [ ] **Step 8:** Verify: a small PR into `main` gets reviewed and its merge triggers the push pipelines; the deploy pipelines' `when: branch: main` fire.
- [ ] **Step 9:** PR into `main` updating `review-bot/prompt.md:16` and `ci/agents/pr-review/mistral-prompt.md:16` (all repos use `main`). Its merge is a push to `main` touching `review-bot/**`, so `build-ci-images` rebuilds the review-bot image; image automation rolls it out; review-bot's log shows the Mistral agents re-synced (new prompt hash). This PR doubles as the Step 8 test PR.
- [ ] **Step 10 (owner, GitHub):** the push mirror `konairius/Homelab` on GitHub now carries `main`. GitHub refuses to delete a repo's default branch, so first set its default branch to `main` (repo Settings → Branches), then delete `master` there. `kubernetes/restore-test/flux-root.yaml` already points at `main` (Task 5).
**Rollback:** `PATCH .../branches/main -f name=master`, revert the Task 5 PR, crons back to `master` (re-add `wiki-maintenance` only if wanted), resume IUA.
### Task 7: Doc and memory sweep
- [ ] One PR replacing remaining `k.renner/homelab` and `master` in runbooks and READMEs: `kubernetes/RUNBOOK-cold-rebuild.md` (incl. `flux bootstrap --branch=master` at :282), `RUNBOOK-home-assistant-image-rollback.md`, `kubernetes/infrastructure/image-automation/README.md`, `README.md`, pipeline comments, `ci/agents/pr-review/run.sh`. Leave dated plans/specs and `MIGRATION*` history unchanged.
- [ ] Update the two memory notes that assume `master` (`feedback_image_bumps_via_flux_automation.md`, vican `verify-repo-freshness-before-review.md`).
- [ ] Local clones: `git branch -m master main && git fetch origin && git branch -u origin/main main` (owner's workstation).
- [ ] Wiki deposit of Cut-over 2 results.
@@ -0,0 +1,250 @@
# kuma-sync Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace AutoKuma with an in-house Go controller that reconciles typed `KumaMonitor` resources into Uptime Kuma 2.5.6, with ownership stored as a Kuma tag.
**Architecture:** A controller-runtime manager watches `KumaMonitor` (cluster-wide) and talks to Kuma through `breml/go-uptime-kuma-client` wrapped behind an internal `kumaclient.Client` interface. Each resource maps to one Kuma monitor, found by the tag `kuma-sync=<namespace>/<name>`. Finalizers and a periodic garbage collection keep Kuma free of orphans. The controller has no local state.
**Tech Stack:** Go 1.27, controller-runtime v0.25.2, controller-gen (CRD + deepcopy), `github.com/breml/go-uptime-kuma-client` v0.6.2, envtest, testcontainers-go, kaniko (Woodpecker), Flux (Kustomization + image automation), ESO/OpenBao, Cilium.
**Spec:** `docs/superpowers/specs/2026-10-18-kuma-sync-design.md`
## Global Constraints
- **Latest stable versions everywhere** (owner requirement). As of 2026-10-18 that is:
- Go 1.27 (`go` directive and builder image)
- controller-runtime v0.25.2, with the k8s.io libraries and controller-gen/envtest versions it requires
- go-uptime-kuma-client v0.6.2
- Uptime Kuma 2.5.6 (latest stable; 3.0 is nightly only)
- the latest testcontainers-go
Each task that adds a dependency first checks its latest release (GitHub releases / `go list -m -versions`) and uses that. Before the final commit of each task, run `go list -m -u all` and record that it reports no available updates for direct dependencies. If Kuma ships a newer stable release before Task 7, bump the VPS role and the test image together.
- Module path `gitea.renner.uno/k.renner/homelab/kuma-sync`, source in `ci/images/kuma-sync/`.
- Kuma client: `github.com/breml/go-uptime-kuma-client v0.6.2`, exact version. Copy its `replace github.com/maldikhan/go.socket.io => github.com/breml/go.socket.io <same pseudo-version>` from that release's `go.mod`.
- Kuma under test: `louislam/uptime-kuma:2.5.6-slim`. That is the same image the VPS runs (`ansible/roles/uptime-kuma/defaults/main.yml`).
- CRD: group `uptime.renner.uno`, version `v1alpha1`, kind `KumaMonitor`, plural `kumamonitors`, namespaced.
- Ownership tag: Kuma tag name `kuma-sync`; value `<namespace>/<name>`. Never read, edit or delete a monitor without that tag.
- Finalizer name: `uptime.renner.uno/kuma-sync`.
- Requeue when a parent group does not exist yet: 10 s. Full resync and orphan GC: every 5 min.
- Kuma URL `https://kuma.renner.uno`; credentials from OpenBao KV v2 mount `homelab`, path `infrastructure/uptime-kuma`, keys `username`, `password` (reuse the existing secret; this controller is Kuma's only API client).
- Image `gitea.renner.uno/gitea_admin/kuma-sync`, tag `YYYY-MM-DD-HHMM`, Flux ImagePolicy picks the newest (pattern of `kubernetes/infrastructure/image-automation/authentik-gate-sync.yaml`).
- Unit tests run as a stage of the image build (house pattern for authentik-gate-sync). Integration tests (build tag `integration`) need Docker and run locally, not in CI.
- No `2>/dev/null`. Config comments are terse factual lines. Commits as claude-bot. Every change goes through a PR. No force-push.
## Review Focus
1. **Restart or rebuild duplicates monitors.** That was the AutoKuma failure mode. The adopt-by-tag path is pinned by integration test `TestRestartAdoptsByTag` (Task 3) and reconciler unit test `TestReconcileAdoptsExisting` (Task 4).
2. **Deletion while the controller is down orphans a monitor.** The GC pass is pinned by `TestGCDeletesOrphans` (Task 5) and integration `TestDeleteWhileDownIsCollected` (Task 6).
3. **A hand-made Kuma monitor gets touched.** `TestGCIgnoresUntagged` (Task 5) and integration `TestUntaggedMonitorUntouched` (Task 6).
4. **`editMonitor` is a full overwrite.** Sending a partial object silently clears fields such as `parent` or `accepted_statuscodes`. Pinned by integration `TestUpdateKeepsUnchangedFields` (Task 3).
5. **Children are created before their group.** They must requeue after 10 s, not fail for minutes. Pinned by `TestReconcileWaitsForParent` (Task 4).
---
### Task 1: Module, API types, generated CRD
**Files:**
- Create: `ci/images/kuma-sync/go.mod`, `go.sum`
- Create: `ci/images/kuma-sync/api/v1alpha1/kumamonitor_types.go`, `groupversion_info.go`, generated `zz_generated.deepcopy.go`
- Create: `ci/images/kuma-sync/config/crd/uptime.renner.uno_kumamonitors.yaml` (generated)
- Create: `ci/images/kuma-sync/Makefile` with targets `generate` (controller-gen object + crd), `test`, `test-integration`
- Test: `ci/images/kuma-sync/api/v1alpha1/validation_test.go` (envtest)
**Interfaces:**
- Produces the type `KumaMonitorSpec`:
- `Type` enum: `group|http|keyword|json-query|dns|port|push`.
- `DisplayName string` (required).
- `ParentRef *LocalRef{Name}`.
- `Interval`, `RetryInterval` (`int32` seconds, min 20, defaults 60).
- `MaxRetries` (`int32`, default 0).
- `Timeout` (`int32` seconds, min 1).
- `Paused bool`.
- `Description string`.
- `HTTP *HTTPSpec` holds `URL` (required), `Method` (default GET), `AcceptedStatusCodes []string` (default `["200-299"]`), `Headers map[string]string`, `Body string`, `MaxRedirects *int32`, `IgnoreTLS bool`, `ExpiryNotification bool`. It is used by `http`, `keyword` and `json-query`.
- `Keyword *KeywordSpec{Keyword, Invert bool}`.
- `JSONQuery *JSONQuerySpec`: `Expression string` (JSONata), `Operator` enum `==|!=|<|<=|>|>=|contains`, `Expected string`.
- `DNS *DNSSpec{Hostname, ResolveType enum A|AAAA|CNAME|MX|NS|PTR|SOA|SRV|TXT|CAA, ResolveServer default "1.1.1.1", Port default 53}`.
- `Port *PortSpec{Hostname, Port int32 1-65535}`.
- `Push *PushSpec{}`, an empty marker.
- Produces the type `KumaMonitorStatus{MonitorID int64, ObservedGeneration int64, Conditions []metav1.Condition}`, where `Ready` is the only condition type.
- CEL rules on the spec:
- `type in ['http','keyword','json-query'] == has(self.http)`
- `(type == 'keyword') == has(self.keyword)`
- `(type == 'json-query') == has(self.jsonQuery)`
- `(type == 'dns') == has(self.dns)`
- `(type == 'port') == has(self.port)`
- `(type == 'push') == has(self.push)`
- `type == 'group'` implies none of the blocks are set.
- [ ] **Step 1: Write failing envtest validation tests.** Each test applies a `KumaMonitor` and asserts the API server's answer:
- `TestValidKeyword`: type keyword with http+keyword is accepted.
- `TestKeywordWithoutBlock`: rejected; the error contains `keyword`.
- `TestGroupWithHTTP`: rejected.
- `TestJSONQueryBadOperator`: operator `=~` is rejected.
- `TestIntervalBelowMin`: interval 5 is rejected.
- `TestDefaults`: an applied http monitor reads back `interval 60` and `acceptedStatusCodes ["200-299"]`.
- [ ] **Step 2: Run** `make test`. Expected: FAIL, because the types are missing.
- [ ] **Step 3: Write the types with kubebuilder markers, then run** `make generate`.
- [ ] **Step 4: Run** `make test`. Expected: all 6 PASS.
- [ ] **Step 5: Commit** `feat(kuma-sync): KumaMonitor API types and CRD`.
### Task 2: Kuma client interface and fake
**Files:**
- Create: `ci/images/kuma-sync/internal/kumaclient/client.go` (interface + domain types)
- Create: `ci/images/kuma-sync/internal/kumaclient/fake.go` (in-memory fake used by reconciler tests)
- Test: `ci/images/kuma-sync/internal/kumaclient/fake_test.go`
**Interfaces:**
- Produces:
```go
type Monitor struct { ID int64; Type, Name, Description string; Parent *int64; Active bool
Interval, RetryInterval, MaxRetries, Timeout int32
URL, Method, Body string; Headers map[string]string; AcceptedStatusCodes []string; MaxRedirects int32; IgnoreTLS, ExpiryNotification bool
Keyword string; InvertKeyword bool; JSONPath, JSONPathOperator, ExpectedValue string
Hostname string; Port int32; DNSResolveType, DNSResolveServer string
OwnerKey string } // value of the kuma-sync tag; "" = not ours
type Client interface {
ListOwned(ctx) ([]Monitor, error) // only monitors carrying the kuma-sync tag
Create(ctx, m Monitor) (int64, error) // creates and tags with m.OwnerKey
Update(ctx, m Monitor) error // full overwrite of an owned monitor; errors if not owned
Delete(ctx, id int64) error // errors if not owned
}
```
- The fake also holds untagged monitors (`AddForeign(Monitor)`), so the "never touch untagged" rule can be tested.
- [ ] **Step 1: Write failing tests:**
- `TestFakeListOwnedHidesForeign`
- `TestFakeUpdateRejectsForeign`
- `TestFakeDeleteRejectsForeign`
- `TestFakeCreateAssignsIDAndTag`
- [ ] **Step 2: Run** `go test ./internal/kumaclient/...`. Expected: FAIL.
- [ ] **Step 3: Implement the interface and the fake.**
- [ ] **Step 4: Run** the tests. Expected: PASS.
- [ ] **Step 5: Commit** `feat(kuma-sync): Kuma client interface and fake`.
### Task 3: Real client on breml/go-uptime-kuma-client + integration tests
**Files:**
- Create: `ci/images/kuma-sync/internal/kumaclient/breml.go`
- Test: `ci/images/kuma-sync/internal/kumaclient/breml_integration_test.go` (`//go:build integration`)
**Interfaces:**
- Consumes the `Client` interface and `Monitor` from Task 2.
- Produces `NewBreml(ctx, url, username, password string) (Client, error)`. It logs in once and reuses the session (Kuma rate-limits logins). It ensures the tag `kuma-sync` exists, creating it on first use with color `#2563eb`.
Integration harness: testcontainers-go starts `louislam/uptime-kuma:2.5.6-slim` with `UPTIME_KUMA_DB_TYPE=sqlite` and waits for port 3001. Setup must then create the admin. Use the client's setup call if it has one; otherwise emit `needSetup`/`setup` after the `info` event, exactly as `ansible/roles/uptime-kuma/files/kuma-setup.js` does.
- [ ] **Step 1: Write failing integration tests:**
- `TestCreateFieldsIntact`: creates a keyword, a json-query (operator `contains`), a dns, a port and a group monitor, then reads them back through a fresh client. Every field from Task 2's `Monitor` must equal the input.
- `TestUpdateKeepsUnchangedFields`: updates only `Interval` from 60 to 120. On re-read, `Parent`, `AcceptedStatusCodes`, `Keyword` and `ExpiryNotification` are unchanged.
- `TestRestartAdoptsByTag`: creates monitors, discards the client, builds a new one. `ListOwned` returns the same IDs with the same `OwnerKey`s.
- `TestUntaggedMonitorUntouched`: a monitor created through the raw library without a tag is absent from `ListOwned`, and `Update`/`Delete` on its ID return an error.
- [ ] **Step 2: Run** `go test -tags integration ./internal/kumaclient/...`. Expected: FAIL.
- [ ] **Step 3: Implement `breml.go`.** Map `Monitor` to the library's per-type monitor structs. Tag with `AddMonitorTag(tagID, monitorID, ownerKey)`. For `Update`, read the current full object and overwrite only the fields we manage, as Review Focus 4 requires.
- [ ] **Step 4: Run** the integration tests. Expected: all 4 PASS.
- [ ] **Step 5: Commit** `feat(kuma-sync): Kuma client on go-uptime-kuma-client`.
### Task 4: Reconciler
**Files:**
- Create: `ci/images/kuma-sync/internal/controller/kumamonitor_controller.go`
- Create: `ci/images/kuma-sync/internal/controller/mapping.go` (spec → `kumaclient.Monitor`)
- Test: `ci/images/kuma-sync/internal/controller/kumamonitor_controller_test.go` (controller-runtime fake client + Kuma fake)
- Test: `ci/images/kuma-sync/internal/controller/mapping_test.go`
**Interfaces:**
- Consumes `kumaclient.Client` and the API types.
- Produces `Reconciler{Client client.Client; Kuma kumaclient.Client}` with `Reconcile(ctx, req) (ctrl.Result, error)` and `SetupWithManager(mgr)`.
- Algorithm:
1. Get the resource.
2. If it is being deleted, delete the owned monitor by OwnerKey (absent counts as success) and remove the finalizer.
3. Ensure the finalizer is present.
4. Resolve `parentRef`: the parent `KumaMonitor` must exist and have `status.monitorID`. If not, set `Ready=False` with reason `ParentPending` and requeue after 10 s.
5. Map the spec to the desired `Monitor`, with `OwnerKey = ns/name`.
6. Find the existing monitor via `ListOwned` by OwnerKey. If there is none, `Create`. If it differs on managed fields, `Update`.
7. Set the status: monitorID, observedGeneration, `Ready=True`.
8. Requeue after 5 min.
- [ ] **Step 1: Write failing tests:**
- `TestMappingAllTypes`: a table test, one row per type, showing that spec fields land on the right `Monitor` fields; `Paused` maps to `Active=false`.
- `TestReconcileCreates`
- `TestReconcileAdoptsExisting`: the fake already holds a monitor with the same OwnerKey. Expected: no second create, and status.monitorID equals the existing ID.
- `TestReconcileUpdatesDrift`: the fake monitor's interval differs. Expected: one `Update`.
- `TestReconcileNoopWhenEqual`: zero Update calls.
- `TestReconcileWaitsForParent`: a child whose group has no monitorID gets `RequeueAfter == 10s` and `Ready=False/ParentPending`.
- `TestReconcileDeleteRemovesMonitorAndFinalizer`
- `TestReconcileDeleteToleratesMissingMonitor`
- [ ] **Step 2: Run** `go test ./internal/controller/...`. Expected: FAIL.
- [ ] **Step 3: Implement the mapping and the reconciler.**
- [ ] **Step 4: Run** the tests. Expected: PASS.
- [ ] **Step 5: Commit** `feat(kuma-sync): KumaMonitor reconciler`.
### Task 5: Orphan garbage collection, manager, image
**Files:**
- Create: `ci/images/kuma-sync/internal/controller/gc.go` (a `manager.Runnable` ticking every 5 min)
- Create: `ci/images/kuma-sync/cmd/kuma-sync/main.go`
- Create: `ci/images/kuma-sync/Containerfile`. Stage 1 is `golang` (pinned digest), runs `go test ./...` without tags, then builds a static binary. Stage 2 is distroless static nonroot.
- Modify: `.woodpecker/build-ci-images.yml`. Add a `build-kuma-sync` step that mirrors `build-authentik-gate-sync`, with tag `YYYY-MM-DD-HHMM` plus `latest`.
- Test: `ci/images/kuma-sync/internal/controller/gc_test.go`
**Interfaces:**
- Produces `GC{Client client.Client; Kuma kumaclient.Client; Interval time.Duration}`. Each run deletes every owned monitor whose OwnerKey has no matching `KumaMonitor`. A list error from either side aborts the run, so nothing is deleted on partial information.
- `main.go` reads env `KUMA_URL`, `KUMA_USERNAME` and `KUMA_PASSWORD`. Missing env makes it exit non-zero with a message naming the variable. Health probes run on `:8081`, metrics on `:8080`, and there is no leader election.
- [ ] **Step 1: Write failing tests:**
- `TestGCDeletesOrphans`
- `TestGCIgnoresUntagged`: a foreign monitor in the fake survives.
- `TestGCKeepsLiveResources`
- `TestGCAbortsOnListError`: the fake k8s list fails, and zero deletes happen.
- [ ] **Step 2: Run** the tests. Expected: FAIL.
- [ ] **Step 3: Implement the GC and `main.go`.**
- [ ] **Step 4: Run** `go test ./...` (expected: PASS), then `docker build -f Containerfile .` in `ci/images/kuma-sync` (expected: the build succeeds, with the test stage passing).
- [ ] **Step 5: Commit** `feat(kuma-sync): orphan GC, manager and image build`. Open a PR. After the owner merges it, the controller confirms the first image tag exists in the registry before Task 7.
### Task 6: End-to-end integration test (automated pilot)
**Files:**
- Test: `ci/images/kuma-sync/internal/controller/e2e_integration_test.go` (`//go:build integration`)
The test runs envtest (an API server with the CRD) plus a Kuma 2.5.6 container plus the real manager, with the breml client and GC interval 2 s.
- [ ] **Step 1: Write the tests:**
- `TestE2EGroupNesting`: apply group `outer`, group `inner` (parent outer), and a dns monitor (parent inner), all at once. Within 60 s, all three are in Kuma with the right parent IDs.
- `TestE2ERestartNoDuplicates`: stop the manager, start a new one, wait 2 GC intervals. The monitor count in Kuma is unchanged.
- `TestDeleteWhileDownIsCollected`: stop the manager, remove the finalizer, delete the resource, start the manager. The monitor is gone from Kuma within 2 GC intervals.
- `TestE2EDriftRepaired`: change the interval directly in Kuma via the raw library. The next resync restores the spec value.
- [ ] **Step 2: Run** `go test -tags integration ./internal/controller/...`. Expected: PASS. Any failure is a defect, not something to retry.
- [ ] **Step 3: Commit** `test(kuma-sync): end-to-end integration suite`.
### Task 7: Kubernetes deployment
**Files:**
- Create: `kubernetes/apps/kuma-sync/{crd-kumamonitors.yaml (copied from config/crd), rbac.yaml, externalsecret.yaml, deployment.yaml, kustomization.yaml}`
- Create: `kubernetes/clusters/homelab/kuma-sync.yaml`. It is a Flux Kustomization modeled on the removed `autokuma.yaml`: dependsOn infrastructure and infrastructure-crds, with the CEL ExternalSecret health check.
- Create: `kubernetes/infrastructure/network-policies/kuma-sync-ns.yaml`, the same shape as the removed `autokuma-ns.yaml`: ingress `[{}]`, DNS with `rules.dns`, kube-apiserver, `toFQDNs kuma.renner.uno:443`. Allow kubelet probes from `host`.
- Create: `kubernetes/infrastructure/image-automation/kuma-sync.yaml` (ImageRepository + ImagePolicy).
- Modify: `namespaces.yaml` (add `kuma-sync`), `network-policies/kustomization.yaml`, and the `openbao-clustersecretstore.yaml` allowlist (sorted).
**Interfaces:**
- Deployment: image `gitea.renner.uno/gitea_admin/kuma-sync:<first tag from Task 5>` with the setter `# {"$imagepolicy": "flux-system:kuma-sync"}`. 1 replica, `Recreate`. Security context as in `kubernetes/apps/uptime-monitor/deployment.yaml`, with a read-only root filesystem.
- ClusterRole: `kumamonitors` get/list/watch/update/patch, `kumamonitors/status` get/update/patch, `kumamonitors/finalizers` update, and `events` create/patch.
- [ ] **Step 1: Write the manifests.**
- [ ] **Step 2: Verify:**
- `kubectl kustomize` on every touched directory renders.
- `kubectl apply --dry-run=server -f kubernetes/apps/kuma-sync/crd-kumamonitors.yaml` succeeds.
- [ ] **Step 3: Commit** `feat(kuma-sync): deploy controller` and open a PR. After merge, the controller checks:
- `flux get kustomization kuma-sync` reports Ready.
- The pod is Running.
- The Kuma log shows a login from the controller.
- [ ] **Step 4: Live smoke test** in a follow-up PR: one `KumaMonitor` (keyword on www.renner.uno) in namespace `kuma-sync`. Expected:
- It shows up in Kuma, tagged `kuma-sync=kuma-sync/www-renner-uno`.
- Deleting the pod changes nothing in Kuma.
- Removing it from git deletes the monitor.
## Follow-up (parent plan)
Parent plan `2026-10-18-uptime-kuma.md` Task 3 (pilot) is replaced by Tasks 6 and 7 here. Its Tasks 4 and 5 then use `KumaMonitor` instead of `KumaEntity`.
@@ -0,0 +1,168 @@
# Uptime Kuma (replace UptimeRobot) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace UptimeRobot with a self-hosted Uptime Kuma on the NetBird VPS, with every monitor, notification and the status page declared in this repo.
**Architecture:** Uptime Kuma runs as an Ansible-managed container on `netbird.renner.uno` behind that host's existing Traefik, and checks targets over the public internet (no NetBird dependency). An in-cluster controller turns `KumaEntity` custom resources into Kuma monitors over Kuma's public HTTPS endpoint. The cluster's blackbox exporter probes Kuma itself, so a dead VPS still raises an alarm. Kuma moves to its own VPS later by retargeting the Ansible play and DNS; nothing else changes.
**Tech Stack:** Uptime Kuma 2.5.6 (`louislam/uptime-kuma:2.5.6-slim`, SQLite), AutoKuma 2.0.0 (`ghcr.io/bigboot/autokuma:2.0.0`, Kubernetes source), Ansible + docker compose, Traefik (docker provider), Flux, ESO/OpenBao, Woodpecker.
**Spec:** this conversation (2026-10-18). There is no separate spec file; the decisions are recorded under *Decisions* below.
## Decisions (from the design conversation)
- Kuma lives **off-site**, on the NetBird coordinator VPS for now. It gets its own VPS once the setup is accepted. The NetBird coordinator is not in the ingress path, so sharing the host does not skew what the probes measure.
- Monitors are **declared in the repo** as custom resources and reconciled by a controller. A ready-made controller is used if it passes the pilot gate (Task 3). Otherwise we write our own, under a separate plan.
- Detailed checks (keyword, JSON query, DNS, TCP, certificate expiry) are a goal, not an afterthought. The custom-resource schema must carry them from the first monitor.
- The UptimeRobot IngressMonitorController (`kubernetes/apps/uptime-monitor/`) is removed after cutover.
## Owner answers (2026-10-18)
1. The credential source is OpenBao. The play reads it with `community.hashi_vault`.
2. Public DNS is managed in the Cloudflare UI. The owner creates `kuma.renner.uno` before Task 1 deploys and repoints `uptime.renner.uno` in Task 5.
3. The SMTP sender is `uptime@renner.uno`. Its Proton SMTP settings are stored at OpenBao `homelab/infrastructure/uptime-kuma/smtp` with keys `server`, `port`, `username` and `token`. Task 4 reads all four, and the `uptime-kuma-vps` policy gains read on `homelab/data/infrastructure/uptime-kuma/smtp`.
## Original open questions (kept for reference)
1. **Where the Kuma admin credential lives.** Kuma is single-user. Ansible needs that user to bootstrap the instance, and AutoKuma needs it to log in. Ansible has no OpenBao lookup today (`ansible/` uses Ansible Vault only), so storing it in both places makes two copies. *Recommendation:* OpenBao `infrastructure/uptime-kuma` is the only source, and the play reads it with `community.hashi_vault`, using a Woodpecker secret token that has read access to that one path. This plan assumes that recommendation. If it is rejected, Task 1 Step 2 changes and nothing else does.
2. **Public DNS.** Neither `netbird.renner.uno` nor `uptime.renner.uno` is defined in this repo (`opentofu/dns_renner_uno.tf` holds LAN records only). `uptime.renner.uno` currently points at UptimeRobot's status-page fleet. Where are these records managed? If it is the Cloudflare UI, Task 1 needs `kuma.renner.uno` created there and Task 5 needs `uptime.renner.uno` repointed there.
3. **Alert channel.** Alertmanager sends email through `smtp.protonmail.ch:587` as `cluster@renner.uno`. Kuma needs its own SMTP token, because no service shares another's key. *Recommendation:* create a Proton SMTP token for `uptime@renner.uno` (or another address you pick) and store it at OpenBao `infrastructure/uptime-kuma`, key `smtp_password`. Creating the token is a one-time owner action.
## Global Constraints
- Kuma image: `louislam/uptime-kuma:2.5.6-slim`. Pin it in role defaults, the way `netbird_version` is pinned. The controller drives Kuma's **internal Socket.io API**, which upstream says may change without notice. Kuma and AutoKuma are therefore bumped together, and only after the pilot checks from Task 3 pass against the new pair.
- `UPTIME_KUMA_DB_TYPE=sqlite`. This skips the database setup wizard (`server/setup-database.js:98`).
- Kuma data lives at `/opt/uptime-kuma/data` (container `/app/data`) on local disk. Not NFS.
- Hostnames: `kuma.renner.uno` serves the admin UI and the Socket.io API. `uptime.renner.uno` serves the public status page, through Kuma's custom-domain mapping.
- Nothing in the Kuma alert path may traverse the homelab.
- No `2>/dev/null` anywhere. No inline interpreters in Bash. Commits use the claude-bot identity, every change goes through a PR, and nothing is ever force-pushed.
## Review Focus
1. **Socket.io API drift.** AutoKuma 2.0.0 (2025-11) targets an older Kuma 2.x API than 2.5.6. Silent failures (a monitor created with fields dropped) are worse than loud ones. The Task 3 pilot asserts the field values Kuma actually stored, not just that the monitor exists.
2. **Controller restart duplicates monitors.** AutoKuma tracks ownership by tag and keeps state in `/data`. A pod restart with an empty `/data` must not create a second copy of every monitor. Task 3 Step 5 pins this.
3. **Deleting a `KumaEntity` while the controller is down.** The finalizer `entity.autokuma.bigboot.dev` must hold the object until the controller is back and removes the monitor. Task 3 Step 6 pins this.
4. **Kuma or VPS down goes unnoticed.** The blackbox probe on `https://kuma.renner.uno` plus its alert rule (Task 4) is the dead-man switch. Task 4 Step 4 proves the alert fires.
5. **Unclaimed fresh instance.** Between `docker compose up` and admin setup, the first visitor to `kuma.renner.uno` could claim admin. The bootstrap runs in the same play run, before the Traefik route is enabled (Task 1 Step 4).
---
### Task 1: Uptime Kuma on the NetBird VPS (Ansible role, play, pipeline)
**Files:**
- Create: `ansible/roles/uptime-kuma/defaults/main.yml`
- Create: `ansible/roles/uptime-kuma/tasks/main.yml`
- Create: `ansible/roles/uptime-kuma/templates/docker-compose.yml.j2`
- Create: `ansible/roles/uptime-kuma/files/kuma-setup.js`
- Create: `ansible/plays/install-uptime-kuma.yml`
- Create: `.woodpecker/deploy-uptime-kuma.yml`
**Interfaces:**
- Produces: `https://kuma.renner.uno` (Kuma UI and Socket.io), with the admin user from OpenBao `infrastructure/uptime-kuma` keys `username` and `password`. Task 3 consumes these.
- [ ] **Step 1: Role defaults.** Set `uptime_kuma_version: "2.5.6"`, `uptime_kuma_host: kuma.renner.uno`, `uptime_kuma_status_host: uptime.renner.uno`, `uptime_kuma_dir: /opt/uptime-kuma`, and `uptime_kuma_traefik_network: netbird_netbird`. The last one is the network the NetBird Traefik watches (`ansible/roles/netbird-server/templates/docker-compose.yml.j2`, docker provider).
- [ ] **Step 2: Credentials.** Look up `username` and `password` from OpenBao `infrastructure/uptime-kuma` with `community.hashi_vault`. Add the collection to the CI image only if `ci-ansible:2026-04-29` lacks it; check with `ansible-galaxy collection list` in that image. Store the Woodpecker secret `openbao_uptime_kuma_token`, and give it a policy that reads that one path.
- [ ] **Step 3: Compose template.** One service, `uptime-kuma`, using image `louislam/uptime-kuma:{{ uptime_kuma_version }}-slim`. Environment `UPTIME_KUMA_DB_TYPE=sqlite`, volume `{{ uptime_kuma_dir }}/data:/app/data`, external network `{{ uptime_kuma_traefik_network }}`. Traefik labels: `traefik.enable={{ uptime_kuma_route_enabled }}` and a router on `Host(kuma.renner.uno) || Host(uptime.renner.uno)` with `tls.certresolver=letsencrypt` and server port 3001.
- [ ] **Step 4: Bootstrap with no manual step.** `files/kuma-setup.js` uses `socket.io-client`, which is a production dependency of Kuma 2.5.6 and so present in the image. It connects to `http://localhost:3001`, emits `needSetup`, and if the answer is true emits `setup(username, password)`. If the answer is false it exits 0. It reads the credentials from env vars `KUMA_SETUP_USERNAME` and `KUMA_SETUP_PASSWORD`, never from arguments. The tasks run in this order:
1. Template compose with `uptime_kuma_route_enabled: false`, then `docker compose up -d`.
2. Wait for `GET http://127.0.0.1:3001` (published on loopback only) to return 200.
3. Copy the script in and run it with `docker compose exec -e ... uptime-kuma node /tmp/kuma-setup.js`.
4. Re-template with the route enabled and run `docker compose up -d` again.
- [ ] **Step 5: Play and pipeline.** `plays/install-uptime-kuma.yml` targets hosts `netbird_server` with roles `uptime-kuma`. `docker-install` is already applied by the NetBird play. `.woodpecker/deploy-uptime-kuma.yml` is a copy of `.woodpecker/deploy-netbird-server.yml` with its paths and play swapped, and it adds the OpenBao token secret.
- [ ] **Step 6: Verify.**
- Run `ansible-playbook --syntax-check -i ansible/inventory ansible/plays/install-uptime-kuma.yml`. Expected: no errors.
- After the pipeline runs, `curl -sI https://kuma.renner.uno/` returns `302` to `/dashboard`. A `/setup` response means the bootstrap failed.
- Run the pipeline a second time. Expected: it is green and the script logs "already set up".
- [ ] **Step 7: Commit and open a PR.** Message: `feat(uptime-kuma): Uptime Kuma on the NetBird VPS`.
### Task 2: Controller deployment (AutoKuma, Kubernetes source)
**Files:**
- Create: `kubernetes/apps/autokuma/{crd-kumaentities.yaml,externalsecret.yaml,rbac.yaml,deployment.yaml,kustomization.yaml}`
- Create: `kubernetes/clusters/homelab/autokuma.yaml`
- Create: `kubernetes/infrastructure/network-policies/autokuma-ns.yaml`
- Modify: `kubernetes/infrastructure/namespaces/namespaces.yaml`. Add namespace `autokuma`.
**Interfaces:**
- Consumes: Task 1's `https://kuma.renner.uno` and the OpenBao `infrastructure/uptime-kuma` `username`/`password`.
- Produces: the CRD `kumaentities.autokuma.bigboot.dev/v1` (namespaced, `spec.config` is a free-form object). Monitors live as `KumaEntity` objects in namespace `autokuma`. `metadata.name` is the AutoKuma ID, and `spec.config` uses the static-monitor keys (`type`, `name`, `url`, …).
- [ ] **Step 1: Vendor the CRD.** Copy it verbatim from `https://raw.githubusercontent.com/BigBoot/AutoKuma/v2.0.0/autokuma/kubernetes/crds-autokuma.yml`, with a one-line header naming the source and the tag.
- [ ] **Step 2: RBAC.** AutoKuma lists and watches `Api::<KumaEntity>::all` (`kubernetes_source.rs`, `init`), so a namespaced Role cannot satisfy it. Create a ClusterRole limited to `kumaentities` with `get, list, watch, patch, update`, plus a ClusterRoleBinding to ServiceAccount `autokuma/autokuma`. No other resources.
- [ ] **Step 3: ExternalSecret.** Use ClusterSecretStore `openbao`, key `infrastructure/uptime-kuma`, properties `username` and `password`, rendered into a Secret named `autokuma-kuma`. Copy the CEL health-check pattern from `kubernetes/clusters/homelab/uptime-monitor.yaml`.
- [ ] **Step 4: Deployment.** Image `ghcr.io/bigboot/autokuma:2.0.0`, 1 replica, strategy `Recreate`. Environment:
- `AUTOKUMA__KUMA__URL=https://kuma.renner.uno`
- `AUTOKUMA__KUMA__USERNAME` and `AUTOKUMA__KUMA__PASSWORD` from the Secret
- `AUTOKUMA__KUBERNETES__ENABLED=true`
- `AUTOKUMA__DOCKER__ENABLED=false`
- `AUTOKUMA__TAG_NAME=AutoKuma`
- `AUTOKUMA__ON_DELETE=delete`
Mount an `emptyDir` at `/data`. Add the security context from `kubernetes/apps/uptime-monitor/deployment.yaml`, and the reloader annotation for `autokuma-kuma`.
- [ ] **Step 5: NetworkPolicy.** Copy `uptime-monitor-ns.yaml`: default-deny, DNS to kube-dns, `kube-apiserver`, and `world` on TCP 443.
- [ ] **Step 6: Flux Kustomization.** Path `./kubernetes/apps/autokuma`, `prune: true`, `wait: true`, `dependsOn` `infrastructure` and `infrastructure-crds`. It ships with **no** `KumaEntity` objects.
- [ ] **Step 7: Verify.**
- `kubectl kustomize kubernetes/apps/autokuma` renders.
- After merge, `kubectl -n autokuma logs deploy/autokuma` shows a successful Kuma login and no "CRD is not queryable".
- `flux get kustomization autokuma` reports Ready.
- [ ] **Step 8: Commit and open a PR.** Message: `feat(autokuma): KumaEntity controller for Uptime Kuma`.
### Task 3: Pilot gate — does AutoKuma meet the bar?
**Files:**
- Create: `kubernetes/apps/autokuma/pilot-kumaentities.yaml`. Add it to the kustomization last.
**Interfaces:**
- Consumes: Task 2's CRD and controller.
- Produces: a go/no-go verdict, recorded in the PR description. **No-go stops this plan.** The fallback is our own controller against the same CRD shape, under a separate plan.
- [ ] **Step 1: Pilot entity.** Create `KumaEntity` `www-renner-uno` with `config: {type: keyword, name: "www.renner.uno", url: "https://www.renner.uno/", keyword: "<a string present on the page>", interval: 60, expiry_notification: true}`. Check the field names against `https://autokuma.bigboot.dev/dev/entity-types/keyword/` before writing them.
- [ ] **Step 2: Fields arrive intact.** In the Kuma UI, or with `kuma monitor list` from the AutoKuma CLI, confirm `type=keyword`, the keyword, `interval=60` and certificate-expiry notification are all as declared. Expected: all four match. Any dropped field is a **no-go**.
- [ ] **Step 3: Update.** Change `interval` to 120 in git. Expected: Kuma shows 120 within one reconcile (≤ 5 min), and the monitor ID is unchanged.
- [ ] **Step 4: Detailed types.** Add one `json-query` entity, one `dns` entity, and one `port` entity on `proxy-1.renner.uno:33080`. Expected: all three are created with their type-specific fields intact.
- [ ] **Step 4a: Groups.**
- Add a `group` entity `pilot` and set `parent_name: pilot` on every pilot check. Expected: in Kuma, all checks sit under the group, and the group goes DOWN when one child is pointed at a non-existent path. A group is DOWN when any child is DOWN (`server/monitor-types/group.js`).
- Add a group `pilot-inner` with `parent_name: pilot`, and move one check under it. Record whether nesting works. If it doesn't, Task 5 uses a flat layout with one group per service, which is not a no-go.
- [ ] **Step 4b: JSON query intact.** Read back the `json-query` entity's `json_path`, `json_path_operator` and `expected_value` from Kuma. Use a non-default operator such as `>=`, so that a dropped field shows up as the default `==`. Expected: all three match the values declared in git.
- [ ] **Step 5: Restart without duplicates.** Delete the AutoKuma pod, which empties `/data`. Expected: after it restarts, Kuma still holds exactly one monitor per entity.
- [ ] **Step 6: Deletion while the controller is down.** Scale the controller to 0, delete the `port` entity in git, wait for Flux, then scale back to 1. Expected: the object sits `Terminating` with the finalizer while the controller is down, and the Kuma monitor is removed after scale-up. An orphaned monitor is a **no-go**.
- [ ] **Step 7: Verdict.** If Steps 2–6 all pass, the verdict is **go**: commit the pilot entities and open a PR with the evidence. Otherwise it is **no-go**: revert the pilot and report which step failed.
### Task 4: Notifications, status page, dead-man switch
**Files:**
- Modify: `ansible/roles/uptime-kuma/files/kuma-setup.js`. Add the SMTP notification: host, port, user and password from OpenBao `homelab/infrastructure/uptime-kuma/smtp` keys `server`, `port`, `username` and `token`, sender `uptime@renner.uno`, "default enabled", applied to all monitors. `KumaEntity.spec.config` is plain text in git, so the secret-bearing notification is created by the bootstrap and never by a custom resource.
- Create: `kubernetes/apps/autokuma/status-page.yaml` (a `KumaEntity` status page with slug `renner`, domain `uptime.renner.uno`)
- Modify: `kubernetes/apps/monitoring-rules/probes-blackbox.yaml`. Add `https://kuma.renner.uno` to `blackbox-http`.
- Modify: the alert-rule file that covers `blackbox-http` targets. Find it with Grep for `probe_success` under `kubernetes/apps/monitoring-rules/`.
- [ ] **Step 1: Check the status-page entity.** Check AutoKuma's status-page entity docs for field names and domain support. If AutoKuma cannot set the status-page domain, have `kuma-setup.js` set it instead, keeping the value in the role defaults.
- [ ] **Step 2: Notification.** Extend `kuma-setup.js` so it creates the notification if none named `email` exists, and updates it otherwise. Run the Task 1 pipeline. Expected: in the Kuma UI, *Test* delivers a mail to the owner's address, and a second pipeline run creates no duplicate.
- [ ] **Step 3: Status page.** Apply it. Expected: `curl -s -H 'Host: uptime.renner.uno' https://kuma.renner.uno/` returns the status page HTML, not the dashboard redirect. Turn on Kuma's "Trust Proxy" setting through the same mechanism as the domain.
- [ ] **Step 4: Dead-man switch.** Add the blackbox target and make sure an alert rule covers it. Prove it fires: stop the Kuma container on the VPS through the pipeline (or have the owner do it). Expected: the alert email arrives through Alertmanager. Then start Kuma again.
- [ ] **Step 5: Response-time alerting.** Kuma 2.5.6 has no latency threshold; its states are only UP, DOWN, PENDING and MAINTENANCE. Degradation is therefore alerted from the cluster:
- `kuma-setup.js` creates a Kuma metrics API key named `prometheus` if none exists, and writes it to OpenBao `infrastructure/uptime-kuma-metrics` key `api_key`. The Ansible token gets write access to that one path. No other service's credential is reused.
- Add an ExternalSecret into `monitoring` for that key, and an `additionalScrapeConfigs` job `uptime-kuma` in `kubernetes/apps/monitoring/helmrelease-prometheus.yaml`: target `kuma.renner.uno:443`, `scheme: https`, `basic_auth` with an empty username and the key as `password_file`.
- Check that the `monitoring` namespace egress policy lets Prometheus reach `world:443`, and add the rule if it doesn't.
- Add a PrometheusRule `UptimeKumaSlowResponse` on `monitor_response_time` (label `monitor_name`): `avg_over_time(monitor_response_time[15m]) > 1500` for 15m, severity warning. Check the metric name against a live scrape before writing the rule.
- Expected: `monitor_response_time` series are visible in Prometheus for every Kuma monitor, and the rule loads without errors.
- [ ] **Step 6: Commit and open a PR.** Message: `feat(autokuma): notifications, status page, Kuma dead-man probe, latency alerting`.
### Task 5: Migrate monitors, cutover, decommission UptimeRobot
**Files:**
- Create: `kubernetes/apps/autokuma/monitors-*.yaml`. One file per area, one `KumaEntity` per check.
- Delete (in the decommission PR): `kubernetes/apps/uptime-monitor/`, `kubernetes/clusters/homelab/uptime-monitor.yaml`, `kubernetes/infrastructure/network-policies/uptime-monitor-ns.yaml`, and the `uptime-monitor` namespace entry.
- [ ] **Step 1: Inventory.** Take the 9 hand-made monitors from UptimeRobot's public feed. Its path is in the header of `kubernetes/apps/uptime-monitor/pilot-endpointmonitors.yaml`. Each monitored service becomes one `group` entity with its checks as children, nested or flat depending on Task 3 Step 4a.
- Every check sets `accepted_statuscodes` and a tight `timeout`, so "far too slow" counts as DOWN.
- Where the service has a health or API endpoint, use `json-query` with a JSONata expression that validates the parsed content (example: `$count(components[status != "ok"])` `==` `0`).
- Otherwise use `keyword` on a string that only a working page renders. Keyword matching is a case-sensitive substring, not a regex.
- `expiry_notification: true` on every HTTPS check.
- The 3 third-party monitors move as plain checks.
- [ ] **Step 2: Parallel run.** Merge the monitors and run both systems for 7 days. Expected: Kuma reports every outage UptimeRobot reports.
- [ ] **Step 3: Cutover.** Repoint `uptime.renner.uno` at the VPS (see open question 2). Expected: `curl -sI https://uptime.renner.uno/` is served by Kuma with a Let's Encrypt certificate.
- [ ] **Step 4: Decommission PR.** Remove the IMC files listed above in one PR. Monitors deleted along with the CRD are orphaned in UptimeRobot. That is acceptable, because the account is closed next.
- [ ] **Step 5: Owner action.** Close the UptimeRobot account. Delete the OpenBao key `infrastructure/uptime-robot`.
- [ ] **Step 6: Wiki.** Deposit the final architecture through `wiki-ingest`: the corrected `uptime-kuma-probes` note, the Socket.io-API pinning rule, and the dead-man switch.
@@ -1,207 +0,0 @@
# Paperclip in the homelab cluster — design
Date: 2026-09-25
Status: draft, awaiting review
## Goal
Run [Paperclip](https://github.com/paperclipai/paperclip) (MIT, agent-orchestration
server + UI) in the homelab cluster for real use: its agents do actual work against
homelab systems. This spec covers the **platform** only. Bot identities and their
credentials are out of scope (see "Deferred").
## Decisions
| Topic | Decision |
|---|---|
| Execution model | **B — server-local runs** (upstream default): agent CLIs are child processes of the server, in the server container. Trial only. |
| Upgrade path | If Paperclip is kept: **C** — `@paperclipai/plugin-kubernetes` sandbox provider + kubernetes-sigs agent-sandbox controller (`agents.x-k8s.io`), per-run Pods and Secrets. Separate spec. |
| Claude auth | **One shared Claude subscription AI connection**, created once via Paperclip's local sign-in (`claude auth login` into `ai-local-logins/<id>`), then its value replaced with a long-lived `claude setup-token` token. Paperclip stores the value as company secret "My Claude subscription" and injects it per run as `CLAUDE_CODE_OAUTH_TOKEN`; it never refreshes Anthropic credentials, so the login's ~8h access token expires (runs then fail with "ACP agent reported a terminal access failure", 0 tokens). Renew by rotating that secret with a new setup-token (~yearly). Server-env `CLAUDE_CODE_OAUTH_TOKEN` is not used: `stripAiAuthBindings` blanks it for managed runs, and upstream accepts no pasted setup-token (the device-code flow needs a Daytona sandbox). |
| Bot credentials | Out of scope. Future "AIR-Manager" Paperclip role handles permission requests. |
| Exposure | LAN-only, behind an Authentik forward-auth gate, then Paperclip's own login. |
### Known trade-off of B (accepted for the trial)
Upstream `runChildProcess()` spawns every local adapter with
`{...sanitizeInheritedPaperclipEnv(process.env), ...opts.env}`; the sanitizer strips only
`PAPERCLIP_*` keys. Every agent therefore sees `DATABASE_URL`, `BETTER_AUTH_SECRET`,
the shared filesystem (including every other agent's workspace and the
`ai-local-logins` credentials) and every agent-scoped secret it can read from disk. Under B, no bot may hold
a credential we would not tolerate any other bot using. Per-bot isolation is C's job.
## Architecture
```
LAN client ─▶ Traefik ─▶ ExternalName svc ─▶ ak-outpost-paperclip-gate (authentik ns)
│ forward-auth OK
▼
paperclip Deployment (ns paperclip, 1 replica)
├─ server :3100 (+ agent CLIs as child processes)
├─ PVC /paperclip (ceph-block)
└─▶ pclp-pg-rw (CNPG, PG17, 2 instances) ─▶ barman ▶ Hetzner S3
```
## Components
All modeled on `kubernetes/apps/paperless/` unless stated.
### Namespace and baseline policy
- `Namespace paperclip` in `kubernetes/infrastructure/namespaces/namespaces.yaml`.
- Baseline default-deny CNP `kubernetes/infrastructure/network-policies/paperclip-ns.yaml`
(copy of `paperless-ns.yaml`, renamed).
- Add `paperclip` to the `openbao` ClusterSecretStore namespace allowlist.
### Flux
- `kubernetes/clusters/homelab/paperclip.yaml`: Flux `Kustomization`,
`path: ./kubernetes/apps/paperclip`, `prune: true`, `wait: true`, same `dependsOn` and
CNPG `healthCheckExprs` as `paperless.yaml`.
- `kubernetes/apps/paperclip/kustomization.yaml`: plain resource list.
### Server Deployment (`paperclip.yaml`)
- Image `ghcr.io/paperclipai/paperclip:nightly@sha256:<digest>` with the Flux
image-automation marker for digest updates.
- `replicas: 1`, `strategy: Recreate` (single writer on an RWO PVC).
- Starts as root: upstream `docker-entrypoint.sh` remaps the `node` user, chowns
`/paperclip`, then drops to uid 1000 via `gosu`. Caps limited to CHOWN,
DAC_OVERRIDE, FOWNER, SETGID, SETUID; `fsGroup: 1000`.
- `serviceAccountName: paperclip-prometheus-read` with
`automountServiceAccountToken: true`. All agents share the pod filesystem, so all
of them can read that token — it is mounted anyway because the Role behind it
grants exactly `get` on `services/proxy` for
`kube-prometheus-stack-prometheus:9090` and
`kube-prometheus-stack-alertmanager:9093` in `monitoring`, and nothing else
(`kubernetes/apps/paperclip/prometheus-read-rbac.yaml`, REN-36 then REN-95).
It was `false` until then.
Three things about that grant are worth knowing before you use or debug it:
- **It scopes the HTTP method, not the endpoint.** The apiserver maps GET→`get`,
POST→`create`, PUT→`update` on a service-proxy request, so `get` alone refuses
`POST /-/reload` and `POST /-/quit` in the authorizer. It does *not* narrow
which GET endpoints are reachable: the whole read surface of both services is,
including `/api/v1/status/config` (scrape credentials there render as
`<secret>`) and the web UI.
- **Force GET in the caller.** Prometheus accepts both GET and POST on
`/api/v1/query`; under this Role only the GET form works, so a client library
that switches to POST for long queries will 403 for a reason that has nothing
to do with the query.
- **If reads start 403ing, check the Service name against `resourceNames`
first.** `resourceNames` is matched verbatim, and
`kubernetes/apps/monitoring/helmrelease-prometheus.yaml` floats the chart at
`>=72.0.0` — a release rename breaks this path silently,
with everything still looking correct in git. The port-qualified form is the
only one granted: `svc`, `svc:9090` and `http:svc:9090` are three different
strings to the authorizer.
- Port 3100; `Service paperclip` ClusterIP :3100.
- Probes: startup and readiness on `GET /api/health` with `Host: localhost` (the
private-hostname guard rejects the pod IP); liveness is a TCP check, since
`/api/health` runs `SELECT 1` and a DB outage must not restart the server.
- Env:
- `DATABASE_URL` assembled from CNPG `pclp-pg-app` Secret (`uri` key).
- `HOST=0.0.0.0`, `PORT=3100`
- `PAPERCLIP_DEPLOYMENT_MODE=authenticated`
- `PAPERCLIP_DEPLOYMENT_EXPOSURE=private`
- `PAPERCLIP_PUBLIC_URL=https://paperclip.renner.uno`
- `PAPERCLIP_AUTH_DISABLE_SIGN_UP=false` until the first admin is claimed: the
claim (`/bootstrap/claim`) requires an already signed-in Better Auth account.
A follow-up flips it to `true`.
- `PAPERCLIP_TELEMETRY_DISABLED=1` (read by `resolveTelemetryConfig`).
- `PAPERCLIP_API_URL=http://paperclip.paperclip.svc.cluster.local:3100` plus
`PAPERCLIP_ALLOWED_HOSTNAMES=paperclip.paperclip.svc.cluster.local`: upstream
otherwise hands agents the public URL, and the gate (`intercept_header_auth`)
rejects their bearer tokens. The Service name also works for sandbox pods under
option C; agent JWTs still authenticate each call.
- `envFrom: secretRef paperclip-secrets`
- Reloader: `secret.reloader.stakater.com/reload: paperclip-secrets`.
### Storage
- `PVC paperclip-data`, `ceph-block`, 20Gi, mounted at `/paperclip` (`PAPERCLIP_HOME`
and `HOME`): instance config, workspaces, repo checkouts, per-agent `claude-runtime`
dirs.
### Postgres
- CNPG `Cluster pclp-pg`: 2 instances, `imageName: ghcr.io/cloudnative-pg/postgresql:17`,
`ceph-block` 10Gi, `bootstrap.initdb {database: paperclip, owner: paperclip}`,
barman-cloud plugin as WAL archiver.
- `ObjectStore pclp-pg-store` → `s3://renner-uno-k8s-cnpg/pclp-pg`, creds via the
existing `hetzner-s3-creds` ExternalSecret pattern.
- `ScheduledBackup` nightly, `method: plugin`, `backupOwnerReference: self`.
- `kustomize.toolkit.fluxcd.io/prune: disabled` on Cluster, ObjectStore, ScheduledBackup.
- PG17 deviates from the cluster-wide PG16 convention because upstream's external
Postgres target is `postgres:17` (`docker/docker-compose.yml`) and its embedded default
is PG18.
### Secrets
One ExternalSecret `paperclip-secrets` ← OpenBao `apps/paperclip` (whole-object
extract):
- `BETTER_AUTH_SECRET`
- `PAPERCLIP_TOOL_ACTION_SIGNING_SECRET`
- `PAPERCLIP_SECRETS_MASTER_KEY` (64-char hex). Held in OpenBao, not on the PVC, so
encrypted agent secrets in Postgres survive PVC loss.
### Ingress and SSO
- Hostname `paperclip.renner.uno`, Traefik `Ingress`, `cert-manager.io/cluster-issuer:
letsencrypt-cluster`, **no** `dns.renner.uno/public` label (LAN-only).
- Authentik gate, per the paperless pattern:
- blueprint entries in `kubernetes/apps/authentik/blueprints-configmap.yaml`:
proxy provider `paperclip-gate` (`internal_host:
http://paperclip.paperclip.svc.cluster.local:3100`, `external_host:
https://paperclip.renner.uno`), application, group "Paperclip Users", policy binding;
- outpost `ak-outpost-paperclip-gate`;
- `kubernetes/apps/authentik/paperclip-gate-egress.yaml` (no `auth.renner.uno`
outpost Ingress: like wiki/shelly gates, the outpost serves its own callback on
`paperclip.renner.uno`);
- `ExternalName` Service in `paperclip` ns pointing at the outpost; the app Ingress
targets it on :9000.
- Paperclip's own email/password login stays on (no header/OIDC login upstream).
### Network policy (`kubernetes/apps/paperclip/network-policy.yaml`)
Covers the server **and every agent** (same pod under B).
- Ingress to the app pod: only from the `paperclip-gate` outpost (by label) on 3100.
- Egress from the app pod:
- `pclp-pg` pods :5432
- kube-dns :53
- `ingress` namespace (Traefik) :8443 (pod-side websecure port) — reaches Gitea, Woodpecker, HA, Loki by their
`*.renner.uno` names
- `kube-apiserver` entity :6443
- `world` TCP :443 (Anthropic API, GitHub, npm)
- No SSH egress; git over HTTPS only. The namespace baseline
(`paperclip-ns.yaml`) also restricts `world` to TCP 443 — Cilium unions allows
across CNPs, so an unrestricted baseline would override this list.
- Known gap (house baseline): pods in `monitoring`/`database` and other pods in
`paperclip` can reach :3100 without the gate; Paperclip's own login still applies.
### Image automation
`kubernetes/infrastructure/image-automation/paperclip.yaml`: `ImageRepository`
(`ghcr.io/paperclipai/paperclip`) + `ImagePolicy` filtering tag `^nightly$` with
`digestReflectionPolicy: Always` (image-reflector-controller v1.2.5 in cluster; field
available since v0.35.0). First use of digest reflection in this repo.
## Guardrails
- No bot receives cluster-admin or any binding in `flux-system`, `authentik`, `openbao`.
- Git work goes through PRs; bots get no merge rights on protected branches.
- Nothing is granted under B that we would not tolerate any agent misusing.
## Bootstrap (one-time)
1. Place `apps/paperclip` values in OpenBao (the three generated secrets).
2. Merge; Flux deploys with sign-up enabled.
3. Sign up through the gate and claim the instance as admin; then set
`PAPERCLIP_AUTH_DISABLE_SIGN_UP=true`.
4. In onboarding "Connect a model", choose Claude subscription and run the shown login
**as the node user** (the entrypoint makes exec sessions root, and root-owned 0600
credentials are unreadable to the server):
`kubectl exec -it -n paperclip deploy/paperclip -- gosu node sh -c 'export CLAUDE_CONFIG_DIR=<shown path> && mkdir -p "$CLAUDE_CONFIG_DIR" && claude auth login'`
Then Settings → Secrets → "My Claude subscription": replace the value with the
output of `claude setup-token`. Repeat both after loss of the database.
## Verification
- Flux Kustomization `paperclip` Ready; CNPG `pclp-pg` healthy; first ScheduledBackup
completes.
- `curl https://paperclip.renner.uno/api/health` through the gate returns healthy after
Authentik login; unauthenticated request redirects to `auth.renner.uno`.
- Direct pod access from another namespace is denied (CNP).
- A test agent (claude-local) completes a trivial task using the shared Claude connection.
- Flux ImagePolicy reports a `nightly@sha256:` digest.
## Deferred
- **Option C** (sandboxed runs) — required before any per-bot credential isolation.
- **AIR-Manager** role and all bot identities/credentials.
- Public exposure.
@@ -0,0 +1,48 @@
# kuma-sync — in-house Uptime Kuma controller (design)
## Why
AutoKuma failed the pilot gate (plan `2026-10-18-uptime-kuma.md`, Task 3). It never applied its ownership tag against Uptime Kuma 2.5.6, so ownership lived only in the pod's local `/data`, and every restart duplicated every monitor. Its CRD is also untyped (`spec.config: object`), so typos are only noticed when a monitor silently misbehaves. The owner decided to replace it with an in-house controller.
## Goals
- Uptime Kuma monitors are declared in git as **typed** Kubernetes resources, validated by the API server when they are applied.
- **Ownership is stored in Kuma itself**: a tag on each managed monitor. Restart, cluster rebuild or a lost pod state never duplicates or orphans a monitor.
- The controller converges on its own: creation order (groups before children), drift (a monitor edited in the UI is set back), and deletion (including deletion while the controller was down).
- It supports the detailed checks the owner asked for: groups with nesting, HTTP status codes, keyword (with invert), JSON query (JSONata expression + operator + expected value), DNS, TCP port, certificate-expiry notification, and timeouts per check.
## Non-goals
- Notifications and status pages are not managed as resources (Task 4 of the parent plan creates them via the Ansible bootstrap). Monitors reference notifications by name only if Task 4 needs it; not in v1.
- No support for other Kuma versions than the pinned one. Kuma and the client library are bumped together.
- No Ingress-driven auto-monitors. Every monitor is an explicit resource.
- Push monitors (Kuma does not generate push tokens via the API; needs token handling) — later version.
## Anti-goals
- Never touch a Kuma monitor that does not carry this controller's tag (hand-made monitors stay untouched).
- No local state the controller depends on. Restarting with nothing but the cluster and Kuma must give the same result.
## Decisions (owner)
- Language: Go.
- Typed CRD.
- Ownership via a Kuma tag.
## Decisions (my calls, open to review)
- **Name** `kuma-sync`, source in `ci/images/kuma-sync/` (house pattern: `netbird-service-sync`), image `gitea.renner.uno/gitea_admin/kuma-sync`, built by kaniko in `.woodpecker/build-ci-images.yml`, rolled out by Flux image automation.
- **Kuma client**: `github.com/breml/go-uptime-kuma-client` v0.6.2 (MIT, tested against Kuma 2.5.6), pinned, with its `replace` for the go.socket.io fork copied into our `go.mod`. Wrapped behind a small internal interface so the reconciler can be tested against a fake and the library can be swapped.
- **CRD** `KumaMonitor`, group `uptime.renner.uno`, version `v1alpha1`, namespaced, generated by `controller-gen` from Go types with kubebuilder markers. One kind with `spec.type` (enum) and one optional block per type; CEL rules enforce that exactly the block matching `type` is set. Groups are `KumaMonitor`s of type `group`; children reference them with `spec.parentRef.name` (same namespace).
- **Ownership tag**: Kuma tag named `kuma-sync`, value `<namespace>/<name>` of the resource. Not the UID: a rebuilt cluster creates new UIDs and must adopt the existing monitors.
- **Reconcile**: find the monitor by tag value → none: create, then tag → exists: compare the desired fields with the actual ones and edit if they differ (full-object `editMonitor`, as Kuma requires). Parent not created yet → requeue after 10 s, not minutes.
- **Deletion**: a finalizer deletes the Kuma monitor. A periodic garbage collection (every resync) deletes tagged monitors whose resource no longer exists, which also covers deletion while the controller was down.
- **Resync** every 5 minutes, to repair drift.
- **Scope**: watches `KumaMonitor` cluster-wide (apps can declare their own monitors in their namespace). ClusterRole limited to `kumamonitors`, `kumamonitors/status`, `kumamonitors/finalizers` and `events`. Credentials come from a Secret in its own namespace.
- **Network**: egress only to DNS, kube-apiserver and `kuma.renner.uno:443` (FQDN policy, same as the AutoKuma one the owner asked for).
- **Single replica, Recreate, no leader election** (house pattern from the IngressMonitorController deployment).
- **Tests**: unit tests for the reconciler against a fake Kuma; CRD validation tests with envtest; integration tests (build tag `integration`) against a real `louislam/uptime-kuma:2.5.6-slim` container via testcontainers-go. The integration suite reproduces the AutoKuma pilot: fields intact, update, groups + nesting, json-query operator, restart without duplicates, deletion while down.
## Left to the builder
Internal package layout, naming inside the code, log format, metric names.
+59 -111
View File
@@ -1,142 +1,99 @@
# CNPG disaster-recovery runbook (ak-pg / im-pg / pprl-pg)
# CNPG disaster-recovery runbook
Fixes the gap in issue #190: all three backed-up CNPG clusters bootstrap via
`bootstrap.initdb`, so a **cold rebuild silently comes up with empty databases**.
WAL + base backups are archived continuously to Hetzner Object Storage via the
barman-cloud plugin, but nothing wires those backups back into a rebuild. This
document is the tested, manual path from "empty rebuilt cluster" to "restored
database", plus the reasoning behind why it is not (yet) fully automatic.
Closes the gap in issue #190. WAL + base backups are archived continuously to
Hetzner Object Storage via the barman-cloud plugin, and every backed-up cluster
below (like `wp-pg`) keeps `bootstrap.recovery` in git: a cold rebuild restores
each from its backup with no manual step. This document covers the
precondition, watching and verifying that restore, and the reasoning behind it.
| Cluster | Namespace | ObjectStore | barman path (`destinationPath`) | app DB / owner |
|-----------|------------|-----------------|-------------------------------------------|-----------------------|
| `ak-pg` | authentik | `ak-pg-store` | `s3://renner-uno-k8s-cnpg/ak-pg` | `authentik`/`authentik` |
| `im-pg` | immich | `im-pg-store` | `s3://renner-uno-k8s-cnpg/im-pg` | `immich`/`immich` |
| `pprl-pg` | paperless | `pprl-pg-store` | `s3://renner-uno-k8s-cnpg/pprl-pg` | `paperless`/`paperless` |
| Cluster | Namespace | app DB / owner |
|-----------|----------------|---------------------------------|
| `ak-pg` | authentik | `authentik`/`authentik` |
| `im-pg` | immich | `immich`/`immich` |
| `pprl-pg` | paperless | `paperless`/`paperless` |
| `ha-pg` | home-assistant | `homeassistant`/`homeassistant` |
| `mx-pg` | matrix | `synapse`/`synapse` |
| `tr-pg` | trmnl | `terminus`/`terminus` |
| `wp-pg` | woodpecker | `woodpecker`/`woodpecker` |
The barman **`serverName`** is the last path segment of `destinationPath`, i.e.
it equals the cluster name (`ak-pg`, `im-pg`, `pprl-pg`).
Each cluster `<x>` has two ObjectStores in `kubernetes/cnpg-object-stores/`:
`<x>-store`, where it archives WAL and takes base backups, and
`<x>-store-source`, which `bootstrap.recovery` reads. Their paths come from the
`flux-system/cnpg-paths` ConfigMap, which `bootstrap.sh` writes once per
cluster incarnation: the source is the prefix in `s3://renner-uno-k8s-cnpg`
whose `<x>/base/` holds the newest base backup, the archive a new prefix
`<x>-g<timestamp>`. The barman **`serverName`** is the cluster name in both.
`gf-pg` (monitoring) has no ObjectStore and stays on `initdb`.
The current prefixes: `kubectl -n flux-system get cm cnpg-paths -o yaml`
(`CNPG_<X>_SOURCE`, `CNPG_<X>_ARCHIVE`). `bootstrap.sh` never rewrites an
existing `cnpg-paths`. Adding a CNPG cluster to a running cluster therefore
means adding its two keys to that ConfigMap by hand, with the archive prefix
as the source as well; without them `cnpg-object-stores` fails to build and
every app Kustomization behind it stalls.
---
## Why this is manual, and why swapping the stanza is safe
## Why the recovery stanza is safe on a live cluster
CNPG's `spec.bootstrap` is honoured **only when a cluster is first initialised**
(empty PGDATA, no existing PVCs). On every subsequent reconcile of a *live*
cluster the field is inert — CNPG never re-bootstraps, adopts existing PVCs
as-is, and changing `bootstrap` on a running cluster is a no-op. **Editing
`cluster.yaml` from `initdb` to `recovery` therefore cannot clobber a live
database.** The only moment a swapped stanza does anything is when PGDATA is
genuinely absent — exactly the cold-rebuild case we want to cover.
as-is, and changing `bootstrap` on a running cluster is a no-op. **The
`recovery` stanza therefore cannot clobber a live database.** It only acts when
PGDATA is genuinely absent — exactly the cold-rebuild case it covers.
So why not just make `recovery` permanent in git (self-healing rebuilds)? One
sharp edge, documented upstream and in our own history: **`bootstrap.recovery`
has no `initdb` fallback.** If the object store is empty (a brand-new cluster
that has never been backed up, or a destroyed bucket), a recovery bootstrap
**hangs forever** instead of creating an empty DB. For the three existing
clusters — which all have 30 days of backups — this "fail loud" behaviour is
arguably *better* than `initdb`'s "silently come up empty". But it changes the
green-field/first-deploy story, which is a data-safety call the repo owner
should make deliberately. That decision is tracked in issue #190; until it is
made, git keeps `initdb` and this runbook is the recovery path.
The sharp edge: **`bootstrap.recovery` has no `initdb` fallback.** If the
object store is empty (a brand-new cluster that has never been backed up, or a
destroyed bucket), a recovery bootstrap **hangs forever** instead of creating
an empty DB. That "fail loud" behaviour is the chosen trade over `initdb`'s
"silently come up empty".
> Historical note: `bootstrap.recovery` from barman **was** the original seed
> design (iteration 2). It was temporarily replaced by `initdb` + a one-shot
> `pg_dump | pg_restore` seed Job for the self-hosted-to-cluster migration.
> Those seed Jobs were deleted post-cutover (2026-05-09), which is what left
> `initdb` behind as a no-op-on-rebuild leftover.
> `initdb` behind as a no-op-on-rebuild leftover, until the clusters above were
> switched back to `recovery` (2026-10-15).
---
## Cold-rebuild recovery procedure (in-place, per cluster)
## Cold-rebuild recovery (per cluster)
Do this per cluster that needs restoring. Example uses `ak-pg`; substitute the
row from the table above for `im-pg` / `pprl-pg`.
Example uses `ak-pg`; substitute the row from the table above for the others.
### 1. Precondition — confirm a backup actually exists
Never start a recovery you can't finish. Check the object store has a base
backup and recent WAL for the cluster's `serverName` before touching anything:
backup and recent WAL for the cluster's `serverName` before touching anything.
Backups live under `<prefix>/<serverName>/base/`, one prefix per incarnation
(`ak-pg` and `ak-pg-g<timestamp>`):
```sh
# From a machine with the Hetzner S3 creds (see hetzner-creds-externalsecret):
aws --endpoint-url https://fsn1.your-objectstorage.com \
s3 ls s3://renner-uno-k8s-cnpg/ak-pg/base/
s3 ls s3://renner-uno-k8s-cnpg/ | grep ak-pg
aws --endpoint-url https://fsn1.your-objectstorage.com \
s3 ls s3://renner-uno-k8s-cnpg/<prefix>/ak-pg/base/
```
If that is empty, **stop** — recovery will hang forever (no initdb fallback).
The data is gone; this is a restore-from-nothing situation, not a rebuild.
### 2. Swap the bootstrap stanza in `cluster.yaml`
### 2. Watch it recover
Replace the `bootstrap.initdb` block with the `bootstrap.recovery` +
`externalClusters` blocks below. Leave the rest of the spec (storage, plugins,
resources, postgresql params) exactly as-is — the live `cluster.yaml` already
carries the current-correct values, which is why we edit it in place rather
than keep a drift-prone duplicate manifest.
Flux applies `cluster.yaml` as part of the app's Kustomization; its
`bootstrap.recovery` + `externalClusters` stanza (`serverName` = cluster name)
drives the restore.
**`ak-pg`:**
```yaml
bootstrap:
recovery:
source: ak-pg-store
database: authentik
owner: authentik
externalClusters:
- name: ak-pg-store
plugin:
name: barman-cloud.cloudnative-pg.io
parameters:
barmanObjectName: ak-pg-store
serverName: ak-pg
```
**`im-pg`:**
```yaml
bootstrap:
recovery:
source: im-pg-store
database: immich
owner: immich
externalClusters:
- name: im-pg-store
plugin:
name: barman-cloud.cloudnative-pg.io
parameters:
barmanObjectName: im-pg-store
serverName: im-pg
```
**`pprl-pg`:**
```yaml
bootstrap:
recovery:
source: pprl-pg-store
database: paperless
owner: paperless
externalClusters:
- name: pprl-pg-store
plugin:
name: barman-cloud.cloudnative-pg.io
parameters:
barmanObjectName: pprl-pg-store
serverName: pprl-pg
```
Notes:
- Recovery is a **physical** restore (latest base backup + WAL replay), so it
brings back *all* databases and roles, not just the app DB. The
`database`/`owner` fields only tell CNPG which app user the `-app` service
should target after promotion; they mirror the old `initdb` values.
- The existing `spec.plugins[].isWALArchiver: true` block **stays**. After the
restored primary is promoted, CNPG bumps the PostgreSQL timeline and resumes
archiving to the same `serverName` path without clobbering the old WAL.
### 3. Apply and watch it recover
On a real cold rebuild, Flux applies the edited `cluster.yaml` automatically
(commit + push to the branch Flux tracks, or `flux reconcile kustomization
authentik --with-source`). For a targeted manual recovery you can also
`kubectl apply -f cluster.yaml` directly.
should target after promotion.
- After the restored primary is promoted, `spec.plugins[].isWALArchiver: true`
archives to `<x>-store`, the new incarnation's own empty prefix; the
prefix it recovered from is left untouched. Its first base backup there is
taken right away (`immediate: true` on the ScheduledBackup).
```sh
kubectl -n authentik get cluster ak-pg -w
@@ -145,7 +102,7 @@ kubectl -n authentik get cluster ak-pg -w
kubectl -n authentik logs -l cnpg.io/cluster=ak-pg,role=primary -c postgres --tail=50
```
### 4. Verify the data
### 3. Verify the data
```sh
kubectl -n authentik exec -it ak-pg-1 -c postgres -- \
@@ -154,15 +111,6 @@ kubectl -n authentik exec -it ak-pg-1 -c postgres -- \
Confirm row counts look sane and the app boots against the restored DB.
### 5. Revert the manifest to `initdb`
**Important.** Once recovery has succeeded, put `cluster.yaml` back to its
`bootstrap.initdb` form and drop the `externalClusters` block, then commit.
Leaving `recovery` in git is harmless for the now-live cluster (bootstrap is
inert), but keeping git in its steady-state `initdb` shape avoids confusion and
keeps the green-field story intact — unless/until issue #190 decides to adopt
permanent recovery.
---
## Velero backup verification (backup-verify)
+19 -4
View File
@@ -365,10 +365,25 @@ kubernetes/bootstrap.sh wait-apps
```
Waits per-app (apps, authentik, monitoring, paperless, home-assistant, immich,
etc., each absorbing its seed Job) and then waits for all cert-manager
Certificates to issue. Stateful apps restore from their seed KSes / backups
(CNPG DBs, migrating PVCs). Long tails are expected (immich library seed rsync
~26 min). End-to-end health check: `bash kubernetes/e2e-test.sh`.
etc.) and then waits for all cert-manager Certificates to issue. The volumes
come back by themselves: each Velero Schedule has a `restore-<schedule>`
Kustomization (`clusters/homelab/restore.yaml`) that applies a Velero Restore
of the schedule's newest backup, `restore-gitea` first, and every app waits
for its own. CNPG databases recover from barman (`RECOVERY.md`). Long tails
are expected (immich's library). End-to-end health check:
`bash kubernetes/e2e-test.sh`.
A Restore runs once and is never retried. One that ends `PartiallyFailed`,
`Failed` or `FailedValidation` (other than "No backups found for schedule")
keeps its `restore-*` Kustomization not Ready, and every Kustomization that
depends on it stops applying, until the Restore is gone. The Restore of
`restore-<name>` is `velero/<name>`. Read its result, fix the cause, then
delete it; Flux applies it again against the newest backup:
```sh
velero restore describe <name> --details
kubectl -n velero delete restore <name>
```
## Step 6 — switch the Flux source back to Gitea
@@ -1,25 +0,0 @@
---
# Allow the dedicated alertmanager-gate outpost to egress into the
# monitoring namespace and reach Alertmanager (:9093). Without this, the
# authentik-namespace baseline CNP has no egress rule to monitoring, so the
# gate outpost's forwarded requests are dropped. Scoped to the gate
# outpost's pod label so no other authentik component can reach it.
# Mirrors shelly-manager-gate-egress.yaml.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: alertmanager-gate-egress
namespace: authentik
spec:
endpointSelector:
matchLabels:
goauthentik.io/outpost-name: alertmanager-gate
egress:
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": monitoring
app.kubernetes.io/name: alertmanager
toPorts:
- ports:
- port: "9093"
protocol: TCP
File diff suppressed because it is too large Load Diff
+11 -1
View File
@@ -35,10 +35,20 @@ spec:
isWALArchiver: true
parameters:
barmanObjectName: ak-pg-store
# Honoured only when PGDATA is absent: a cold rebuild restores from the
# barman backup. No initdb fallback; an empty object store hangs bootstrap.
bootstrap:
initdb:
recovery:
source: ak-pg-store
database: authentik
owner: authentik
externalClusters:
- name: ak-pg-store
plugin:
name: barman-cloud.cloudnative-pg.io
parameters:
barmanObjectName: ak-pg-store-source
serverName: ak-pg
postgresql:
parameters:
shared_buffers: "256MB"
@@ -1,24 +0,0 @@
---
# Allow the dedicated esphome-gate outpost to egress into the home-assistant
# namespace and reach esphome (:6052). Without this, the authentik-namespace
# baseline CNP has no egress rule to home-assistant, so the gate outpost's
# forwarded requests are dropped. Scoped to the gate outpost's pod label so no
# other authentik component can reach it. Mirrors shelly-manager-gate-egress.yaml.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: esphome-gate-egress
namespace: authentik
spec:
endpointSelector:
matchLabels:
goauthentik.io/outpost-name: esphome-gate
egress:
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": home-assistant
app.kubernetes.io/name: esphome
toPorts:
- ports:
- port: "6052"
protocol: TCP
+147
View File
@@ -0,0 +1,147 @@
# The authentik-gate ingress class: an Ingress of this class becomes an
# Authentik proxy gate (provider, application, outpost, group binding) plus the
# Traefik Ingress, ExternalName Service and CiliumNetworkPolicies in front of
# its backend. Traefik ignores the class (it only serves classes naming
# traefik.io/ingress-controller). Controller: ci/images/authentik-gate-sync.
apiVersion: networking.k8s.io/v1
kind: IngressClass
metadata:
name: authentik-gate
spec:
controller: renner.uno/authentik-gate
---
# API token of the authentik-gate-sync service account. Generated once; the
# blueprint (gate-sync.yaml key in blueprints-configmap.yaml) sets it as the
# token's key via AUTHENTIK_GATE_SYNC_TOKEN, the controller reads the same Secret.
apiVersion: generators.external-secrets.io/v1alpha1
kind: Password
metadata:
name: authentik-gate-sync-token
namespace: authentik
spec:
length: 64
digits: 16
symbols: 0
allowRepeat: true
noUpper: false
secretKeys:
- token
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: authentik-gate-sync-token
namespace: authentik
spec:
refreshPolicy: CreatedOnce
target:
name: authentik-gate-sync-token
dataFrom:
- sourceRef:
generatorRef:
apiVersion: generators.external-secrets.io/v1alpha1
kind: Password
name: authentik-gate-sync-token
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: authentik-gate-sync
namespace: authentik
labels:
app.kubernetes.io/name: authentik-gate-sync
---
# Cluster-wide: gated Ingresses live in any namespace, and so do the route
# Ingress, ExternalName Service and backend CNP generated next to them.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: authentik-gate-sync
rules:
- apiGroups: ["networking.k8s.io"]
resources: ["ingresses"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
# The source Ingress's load-balancer status mirrors its generated route.
- apiGroups: ["networking.k8s.io"]
resources: ["ingresses/status"]
verbs: ["get", "patch", "update"]
- apiGroups: [""]
resources: ["services"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: ["cilium.io"]
resources: ["ciliumnetworkpolicies"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: authentik-gate-sync
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: authentik-gate-sync
subjects:
- kind: ServiceAccount
name: authentik-gate-sync
namespace: authentik
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: authentik-gate-sync
namespace: authentik
labels:
app.kubernetes.io/name: authentik-gate-sync
spec:
replicas: 1
# One writer against the Authentik API at a time.
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: authentik-gate-sync
template:
metadata:
labels:
app.kubernetes.io/name: authentik-gate-sync
spec:
serviceAccountName: authentik-gate-sync
securityContext:
runAsNonRoot: true
runAsUser: 65534
runAsGroup: 65534
seccompProfile:
type: RuntimeDefault
containers:
- name: controller
image: gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-10-19-0544 # {"$imagepolicy": "flux-system:authentik-gate-sync"}
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
env:
- name: AK_URL
value: http://authentik-server.authentik.svc.cluster.local
- name: AUTHENTIK_HOST_BROWSER
value: https://auth.renner.uno/
- name: SERVICE_CONNECTION
value: Local Kubernetes Cluster
- name: DEFAULT_AUTHORIZATION_FLOW
value: default-provider-authorization-implicit-consent
- name: INTERVAL_SECONDS
value: "60"
volumeMounts:
- name: token
mountPath: /var/run/secrets/authentik-gate-sync
readOnly: true
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
memory: 64Mi
volumes:
- name: token
secret:
secretName: authentik-gate-sync-token
+17 -9
View File
@@ -47,9 +47,9 @@ spec:
# in the configured Synology Users group, so the LDAP outpost
# then emits NT-hash-bearing user entries that DSM's smbd
# accepts for NTLMv2 SMB authentication. See
# https://gitea.renner.uno/k.renner/authentik-samba-bridge .
# https://gitea.renner.uno/konairius/authentik-samba-bridge .
image:
repository: gitea.renner.uno/k.renner/authentik-samba-bridge-server
repository: gitea.renner.uno/konairius/authentik-samba-bridge-server
# Pinned to a specific build for two reasons:
# 1. K8s does not pull on every reschedule when tag=latest, so
# a floating tag means we don't actually pick up rebuilds
@@ -57,8 +57,8 @@ spec:
# "no pod invasion" rule).
# 2. Pinning means the deployed binary is traceable to a
# specific repo commit. Bump the tag whenever a new build
# is published from gitea.renner.uno/k.renner/authentik-samba-bridge .
tag: "2026.8.3-872f754"
# is published from gitea.renner.uno/konairius/authentik-samba-bridge .
tag: "2026.8.3-9dd92dd"
envFrom:
# SAMBA_BRIDGE_DOMAIN_SID / _ENABLED_GROUP / _UID_OFFSET
# consumed by the bridge module at Django app startup. Missing
@@ -100,6 +100,13 @@ spec:
secretKeyRef:
name: ldap-outpost-tls
key: tls.key
# Key of the authentik-gate-sync API token, set via `!Env` in the
# gate-sync blueprint (gate-sync.yaml).
- name: AUTHENTIK_GATE_SYNC_TOKEN
valueFrom:
secretKeyRef:
name: authentik-gate-sync-token
key: token
# 2026.8 honours X-Forwarded-Proto/-Host/-For only from these
# networks. Traefik reaches authentik-server (IPv4 single-stack
# Service) from a pod IP; same ranges Traefik itself trusts for
@@ -145,10 +152,11 @@ spec:
targetCPUUtilizationPercentage: 70
metrics:
enabled: true
# The chart renders it without checking for monitoring.coreos.com, so
# the install would wait on monitoring. Declared in
# monitoring-rules/authentik-servicemonitor.yaml instead.
serviceMonitor:
enabled: true
labels:
release: kube-prometheus-stack
enabled: false
ingress:
enabled: true
ingressClassName: traefik
@@ -188,8 +196,8 @@ spec:
redis:
enabled: false
# Mount the homelab blueprints (K8s outpost integration, passkey
# step-up authorization) — every key of the ConfigMap is applied by
# Mount the homelab blueprints (K8s outpost integration, SSO and
# paperless flows) — every key of the ConfigMap is applied by
# the worker on boot and on each apply cycle. See
# blueprints-configmap.yaml.
blueprints:
+3 -18
View File
@@ -5,30 +5,15 @@ resources:
- samba-bridge-externalsecret.yaml
- blueprints-configmap.yaml
- helmrelease.yaml
- paperless-gate-ingress.yaml
- paperless-gate-egress.yaml
- paperclip-gate-egress.yaml
- vscode-gate-egress.yaml
- shelly-manager-gate-egress.yaml
- esphome-gate-egress.yaml
- zigbee2mqtt-gate-egress.yaml
- zone-configurator-gate-egress.yaml
- alertmanager-gate-egress.yaml
- prometheus-gate-egress.yaml
- wordpress-gate-egress.yaml
- llama-gate-egress.yaml
- wiki-gate-egress.yaml
- navidrome-gate-egress.yaml
- lidarr-gate-egress.yaml
- gate-sync.yaml
- ldap-outpost-cert.yaml
- ldap-outpost-lb.yaml
- ldap-outpost-cnp.yaml
# ak-pg CNPG Postgres backend (moved here from the retired seed-authentik KS;
# each carries prune: disabled so ownership transferred without a teardown).
# authentik has no data PVCs — all state lives in ak-pg. This KS gains the
# infrastructure-cnpg-barman dep + CNPG Cluster healthCheckExprs (see
# authentik has no data PVCs — all state lives in ak-pg. This KS depends on
# cnpg-object-stores and health-checks the CNPG Cluster (see
# clusters/homelab/authentik.yaml).
- cluster.yaml
- objectstore.yaml
- scheduled-backup.yaml
- hetzner-creds-externalsecret.yaml
@@ -1,20 +0,0 @@
# Lets the lidarr-gate outpost reach Lidarr; the matching ingress rule is in
# network-policies/navidrome-ns.yaml.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: lidarr-gate-egress
namespace: authentik
spec:
endpointSelector:
matchLabels:
goauthentik.io/outpost-name: lidarr-gate
egress:
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": navidrome
app.kubernetes.io/name: lidarr
toPorts:
- ports:
- port: "8686"
protocol: TCP
@@ -1,30 +0,0 @@
---
# Lets the llama-gate outpost reach llama-server. The cluster baseline CNP
# (kubernetes/infrastructure/network-policies/authentik-ns.yaml) otherwise stops
# any authentik-namespace pod from entering the ai namespace.
#
# The outpost is the trust boundary: it forwards to llama-server only after
# validating the Authentik session, and the matching ingress rule in
# ai-ns.yaml admits nothing else — so nothing in the cluster can route around
# the gate to reach the unauthenticated API through the public hostname.
#
# Scoped to this outpost's pod label rather than the whole namespace, so the
# other gates cannot reach llama-server either.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: llama-gate-egress
namespace: authentik
spec:
endpointSelector:
matchLabels:
goauthentik.io/outpost-name: llama-gate
egress:
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": ai
app.kubernetes.io/name: llama-server
toPorts:
- ports:
- port: "8080"
protocol: TCP
@@ -1,20 +0,0 @@
# Lets the navidrome-gate outpost reach Navidrome; the matching ingress rule
# is in network-policies/navidrome-ns.yaml.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: navidrome-gate-egress
namespace: authentik
spec:
endpointSelector:
matchLabels:
goauthentik.io/outpost-name: navidrome-gate
egress:
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": navidrome
app.kubernetes.io/name: navidrome
toPorts:
- ports:
- port: "4533"
protocol: TCP
@@ -1,33 +0,0 @@
---
apiVersion: barmancloud.cnpg.io/v1
kind: ObjectStore
metadata:
name: ak-pg-store
namespace: authentik
annotations:
# Prune-disabled — a standing guard kept from the seed->authentik-KS handoff (backup target).
kustomize.toolkit.fluxcd.io/prune: disabled
spec:
configuration:
# Offsite Hetzner Object Storage (fsn1) — replaces Garage on nas-1.
# No SSE: Hetzner supports only SSE-C, unusable by barman-cloud
# (plugin #646), so at-rest encryption is unavailable; transit is TLS.
destinationPath: s3://renner-uno-k8s-cnpg/ak-pg
endpointURL: https://fsn1.your-objectstorage.com
s3Credentials:
accessKeyId:
name: hetzner-s3-creds
key: ACCESS_KEY_ID
secretAccessKey:
name: hetzner-s3-creds
key: ACCESS_SECRET_KEY
# Region is required (REGION=fsn1) — without it boto3's HeadBucket
# (barman-cloud-check-wal-archive during bootstrap.recovery) 400s.
region:
name: hetzner-s3-creds
key: REGION
wal:
compression: gzip
data:
compression: gzip
retentionPolicy: "30d"
@@ -1,19 +0,0 @@
# Lets the paperclip-gate outpost reach the Paperclip server; scoped to this outpost's pods.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: paperclip-gate-egress
namespace: authentik
spec:
endpointSelector:
matchLabels:
goauthentik.io/outpost-name: paperclip-gate
egress:
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": paperclip
app.kubernetes.io/name: paperclip
toPorts:
- ports:
- port: "3100"
protocol: TCP
@@ -1,24 +0,0 @@
---
# Allow the dedicated paperless-gate outpost to egress into the
# paperless namespace — without this, the cluster baseline CNP
# (kubernetes/infrastructure/network-policies/authentik-ns.yaml)
# blocks any authentik-namespace pod from reaching paperless. The
# outpost is the trust boundary: it forwards HTTP(s) to paperless's
# pod after validating the Authentik session cookie.
#
# Scoped to the gate outpost's pod label, not all of authentik, so
# other authentik components can't reach paperless.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: paperless-gate-egress
namespace: authentik
spec:
endpointSelector:
matchLabels:
goauthentik.io/outpost-name: paperless-gate
egress:
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": paperless
app.kubernetes.io/name: paperless
@@ -1,44 +0,0 @@
---
# Routes auth.renner.uno/outpost.goauthentik.io/* to the dedicated
# `paperless-gate` outpost. Without this rule, those paths hit the
# Helm-managed authentik-server Ingress (catch-all `/`) which forwards
# them to the embedded outpost — a separate process with its own
# session store. The OAuth callback the embedded outpost handles never
# materialises into a session the gate outpost can see, so the gate's
# auth_request keeps returning 401 and the browser loops between
# /authorize and /callback.
#
# Both ingress-nginx and Traefik merge path rules across Ingress
# objects on the same host and pick the most specific match, so this
# Ingress's `/outpost.goauthentik.io` Prefix beats the chart's `/` rule.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: paperless-gate-outpost
namespace: authentik
labels:
# Registered publicly (Cloudflare wildcard -> NetBird proxy); the UDM
# external-dns instance skips it, LAN reaches it via the hairpin NAT.
# Every resource carrying a public host needs the label — external-dns
# aggregates hosts across resources, so one unlabeled carrier would
# re-register the record on the UDM.
dns.renner.uno/public: "true"
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
spec:
ingressClassName: traefik
rules:
- host: auth.renner.uno
http:
paths:
- path: /outpost.goauthentik.io
pathType: Prefix
backend:
service:
name: ak-outpost-paperless-gate
port:
number: 9000
tls:
- secretName: paperless-gate-outpost-tls
hosts:
- auth.renner.uno
@@ -1,24 +0,0 @@
---
# Allow the dedicated prometheus-gate outpost to egress into the monitoring
# namespace and reach Prometheus (:9090). Without this, the authentik
# baseline CNP has no egress rule to monitoring, so the gate outpost's
# forwarded requests are dropped. Scoped to the gate outpost's pod label.
# Mirrors alertmanager-gate-egress.yaml.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: prometheus-gate-egress
namespace: authentik
spec:
endpointSelector:
matchLabels:
goauthentik.io/outpost-name: prometheus-gate
egress:
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": monitoring
app.kubernetes.io/name: prometheus
toPorts:
- ports:
- port: "9090"
protocol: TCP
@@ -1,26 +0,0 @@
---
# Allow the dedicated shelly-manager-gate outpost to egress into the
# home-assistant namespace and reach shelly-manager (:8080). Without this,
# the authentik-namespace baseline CNP
# (kubernetes/infrastructure/network-policies/authentik-ns.yaml) has no
# egress rule to home-assistant, so the gate outpost's forwarded requests
# are dropped. Scoped to the gate outpost's pod label so no other authentik
# component can reach it. Mirrors vscode-gate-egress.yaml.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: shelly-manager-gate-egress
namespace: authentik
spec:
endpointSelector:
matchLabels:
goauthentik.io/outpost-name: shelly-manager-gate
egress:
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": home-assistant
app.kubernetes.io/name: shelly-manager
toPorts:
- ports:
- port: "8080"
protocol: TCP
@@ -1,26 +0,0 @@
---
# Allow the dedicated vscode-gate outpost to egress into the
# home-assistant namespace and reach the code-server sidecar (:8080) on
# the HA pod. Without this, the authentik-namespace baseline CNP
# (kubernetes/infrastructure/network-policies/authentik-ns.yaml) has no
# egress rule to home-assistant, so the gate outpost's forwarded requests
# are dropped. Scoped to the gate outpost's pod label so no other
# authentik component can reach code-server. Mirrors paperless-gate-egress.yaml.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: vscode-gate-egress
namespace: authentik
spec:
endpointSelector:
matchLabels:
goauthentik.io/outpost-name: vscode-gate
egress:
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": home-assistant
app.kubernetes.io/name: home-assistant
toPorts:
- ports:
- port: "8080"
protocol: TCP

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