Compare commits

..
Author SHA1 Message Date
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
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 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 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
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
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
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
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
31 changed files with 801 additions and 40 deletions
+1 -1
View File
@@ -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.302
environment:
CLAUDE_CODE_OAUTH_TOKEN:
from_secret: claude_code_oauth_token
+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.302
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.302
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.302
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.302
commands:
- sh scripts/ci/test-yaml-citations.sh
+1 -1
View File
@@ -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.302
commands:
- sh scripts/ci/test-model-selection.sh
+1 -1
View File
@@ -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.302
environment:
CLAUDE_CODE_OAUTH_TOKEN:
from_secret: claude_code_oauth_token
+1 -1
View File
@@ -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.302
environment:
CLAUDE_CODE_OAUTH_TOKEN:
from_secret: claude_code_oauth_token
+1 -1
View File
@@ -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.302
environment:
CLAUDE_CODE_OAUTH_TOKEN:
from_secret: claude_code_oauth_token
@@ -0,0 +1,250 @@
# kuma-sync Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace AutoKuma with an in-house Go controller that reconciles typed `KumaMonitor` resources into Uptime Kuma 2.5.6, with ownership stored as a Kuma tag.
**Architecture:** A controller-runtime manager watches `KumaMonitor` (cluster-wide) and talks to Kuma through `breml/go-uptime-kuma-client` wrapped behind an internal `kumaclient.Client` interface. Each resource maps to one Kuma monitor, found by the tag `kuma-sync=<namespace>/<name>`. Finalizers and a periodic garbage collection keep Kuma free of orphans. The controller has no local state.
**Tech Stack:** Go 1.27, controller-runtime v0.25.2, controller-gen (CRD + deepcopy), `github.com/breml/go-uptime-kuma-client` v0.6.2, envtest, testcontainers-go, kaniko (Woodpecker), Flux (Kustomization + image automation), ESO/OpenBao, Cilium.
**Spec:** `docs/superpowers/specs/2026-10-18-kuma-sync-design.md`
## Global Constraints
- **Latest stable versions everywhere** (owner requirement). As of 2026-10-18 that is:
- Go 1.27 (`go` directive and builder image)
- controller-runtime v0.25.2, with the k8s.io libraries and controller-gen/envtest versions it requires
- go-uptime-kuma-client v0.6.2
- Uptime Kuma 2.5.6 (latest stable; 3.0 is nightly only)
- the latest testcontainers-go
Each task that adds a dependency first checks its latest release (GitHub releases / `go list -m -versions`) and uses that. Before the final commit of each task, run `go list -m -u all` and record that it reports no available updates for direct dependencies. If Kuma ships a newer stable release before Task 7, bump the VPS role and the test image together.
- Module path `gitea.renner.uno/k.renner/homelab/kuma-sync`, source in `ci/images/kuma-sync/`.
- Kuma client: `github.com/breml/go-uptime-kuma-client v0.6.2`, exact version. Copy its `replace github.com/maldikhan/go.socket.io => github.com/breml/go.socket.io <same pseudo-version>` from that release's `go.mod`.
- Kuma under test: `louislam/uptime-kuma:2.5.6-slim`. That is the same image the VPS runs (`ansible/roles/uptime-kuma/defaults/main.yml`).
- CRD: group `uptime.renner.uno`, version `v1alpha1`, kind `KumaMonitor`, plural `kumamonitors`, namespaced.
- Ownership tag: Kuma tag name `kuma-sync`; value `<namespace>/<name>`. Never read, edit or delete a monitor without that tag.
- Finalizer name: `uptime.renner.uno/kuma-sync`.
- Requeue when a parent group does not exist yet: 10 s. Full resync and orphan GC: every 5 min.
- Kuma URL `https://kuma.renner.uno`; credentials from OpenBao KV v2 mount `homelab`, path `infrastructure/uptime-kuma`, keys `username`, `password` (reuse the existing secret; this controller is Kuma's only API client).
- Image `gitea.renner.uno/gitea_admin/kuma-sync`, tag `YYYY-MM-DD-HHMM`, Flux ImagePolicy picks the newest (pattern of `kubernetes/infrastructure/image-automation/authentik-gate-sync.yaml`).
- Unit tests run as a stage of the image build (house pattern for authentik-gate-sync). Integration tests (build tag `integration`) need Docker and run locally, not in CI.
- No `2>/dev/null`. Config comments are terse factual lines. Commits as claude-bot. Every change goes through a PR. No force-push.
## Review Focus
1. **Restart or rebuild duplicates monitors.** That was the AutoKuma failure mode. The adopt-by-tag path is pinned by integration test `TestRestartAdoptsByTag` (Task 3) and reconciler unit test `TestReconcileAdoptsExisting` (Task 4).
2. **Deletion while the controller is down orphans a monitor.** The GC pass is pinned by `TestGCDeletesOrphans` (Task 5) and integration `TestDeleteWhileDownIsCollected` (Task 6).
3. **A hand-made Kuma monitor gets touched.** `TestGCIgnoresUntagged` (Task 5) and integration `TestUntaggedMonitorUntouched` (Task 6).
4. **`editMonitor` is a full overwrite.** Sending a partial object silently clears fields such as `parent` or `accepted_statuscodes`. Pinned by integration `TestUpdateKeepsUnchangedFields` (Task 3).
5. **Children are created before their group.** They must requeue after 10 s, not fail for minutes. Pinned by `TestReconcileWaitsForParent` (Task 4).
---
### Task 1: Module, API types, generated CRD
**Files:**
- Create: `ci/images/kuma-sync/go.mod`, `go.sum`
- Create: `ci/images/kuma-sync/api/v1alpha1/kumamonitor_types.go`, `groupversion_info.go`, generated `zz_generated.deepcopy.go`
- Create: `ci/images/kuma-sync/config/crd/uptime.renner.uno_kumamonitors.yaml` (generated)
- Create: `ci/images/kuma-sync/Makefile` with targets `generate` (controller-gen object + crd), `test`, `test-integration`
- Test: `ci/images/kuma-sync/api/v1alpha1/validation_test.go` (envtest)
**Interfaces:**
- Produces the type `KumaMonitorSpec`:
- `Type` enum: `group|http|keyword|json-query|dns|port|push`.
- `DisplayName string` (required).
- `ParentRef *LocalRef{Name}`.
- `Interval`, `RetryInterval` (`int32` seconds, min 20, defaults 60).
- `MaxRetries` (`int32`, default 0).
- `Timeout` (`int32` seconds, min 1).
- `Paused bool`.
- `Description string`.
- `HTTP *HTTPSpec` holds `URL` (required), `Method` (default GET), `AcceptedStatusCodes []string` (default `["200-299"]`), `Headers map[string]string`, `Body string`, `MaxRedirects *int32`, `IgnoreTLS bool`, `ExpiryNotification bool`. It is used by `http`, `keyword` and `json-query`.
- `Keyword *KeywordSpec{Keyword, Invert bool}`.
- `JSONQuery *JSONQuerySpec`: `Expression string` (JSONata), `Operator` enum `==|!=|<|<=|>|>=|contains`, `Expected string`.
- `DNS *DNSSpec{Hostname, ResolveType enum A|AAAA|CNAME|MX|NS|PTR|SOA|SRV|TXT|CAA, ResolveServer default "1.1.1.1", Port default 53}`.
- `Port *PortSpec{Hostname, Port int32 1-65535}`.
- `Push *PushSpec{}`, an empty marker.
- Produces the type `KumaMonitorStatus{MonitorID int64, ObservedGeneration int64, Conditions []metav1.Condition}`, where `Ready` is the only condition type.
- CEL rules on the spec:
- `type in ['http','keyword','json-query'] == has(self.http)`
- `(type == 'keyword') == has(self.keyword)`
- `(type == 'json-query') == has(self.jsonQuery)`
- `(type == 'dns') == has(self.dns)`
- `(type == 'port') == has(self.port)`
- `(type == 'push') == has(self.push)`
- `type == 'group'` implies none of the blocks are set.
- [ ] **Step 1: Write failing envtest validation tests.** Each test applies a `KumaMonitor` and asserts the API server's answer:
- `TestValidKeyword`: type keyword with http+keyword is accepted.
- `TestKeywordWithoutBlock`: rejected; the error contains `keyword`.
- `TestGroupWithHTTP`: rejected.
- `TestJSONQueryBadOperator`: operator `=~` is rejected.
- `TestIntervalBelowMin`: interval 5 is rejected.
- `TestDefaults`: an applied http monitor reads back `interval 60` and `acceptedStatusCodes ["200-299"]`.
- [ ] **Step 2: Run** `make test`. Expected: FAIL, because the types are missing.
- [ ] **Step 3: Write the types with kubebuilder markers, then run** `make generate`.
- [ ] **Step 4: Run** `make test`. Expected: all 6 PASS.
- [ ] **Step 5: Commit** `feat(kuma-sync): KumaMonitor API types and CRD`.
### Task 2: Kuma client interface and fake
**Files:**
- Create: `ci/images/kuma-sync/internal/kumaclient/client.go` (interface + domain types)
- Create: `ci/images/kuma-sync/internal/kumaclient/fake.go` (in-memory fake used by reconciler tests)
- Test: `ci/images/kuma-sync/internal/kumaclient/fake_test.go`
**Interfaces:**
- Produces:
```go
type Monitor struct { ID int64; Type, Name, Description string; Parent *int64; Active bool
Interval, RetryInterval, MaxRetries, Timeout int32
URL, Method, Body string; Headers map[string]string; AcceptedStatusCodes []string; MaxRedirects int32; IgnoreTLS, ExpiryNotification bool
Keyword string; InvertKeyword bool; JSONPath, JSONPathOperator, ExpectedValue string
Hostname string; Port int32; DNSResolveType, DNSResolveServer string
OwnerKey string } // value of the kuma-sync tag; "" = not ours
type Client interface {
ListOwned(ctx) ([]Monitor, error) // only monitors carrying the kuma-sync tag
Create(ctx, m Monitor) (int64, error) // creates and tags with m.OwnerKey
Update(ctx, m Monitor) error // full overwrite of an owned monitor; errors if not owned
Delete(ctx, id int64) error // errors if not owned
}
```
- The fake also holds untagged monitors (`AddForeign(Monitor)`), so the "never touch untagged" rule can be tested.
- [ ] **Step 1: Write failing tests:**
- `TestFakeListOwnedHidesForeign`
- `TestFakeUpdateRejectsForeign`
- `TestFakeDeleteRejectsForeign`
- `TestFakeCreateAssignsIDAndTag`
- [ ] **Step 2: Run** `go test ./internal/kumaclient/...`. Expected: FAIL.
- [ ] **Step 3: Implement the interface and the fake.**
- [ ] **Step 4: Run** the tests. Expected: PASS.
- [ ] **Step 5: Commit** `feat(kuma-sync): Kuma client interface and fake`.
### Task 3: Real client on breml/go-uptime-kuma-client + integration tests
**Files:**
- Create: `ci/images/kuma-sync/internal/kumaclient/breml.go`
- Test: `ci/images/kuma-sync/internal/kumaclient/breml_integration_test.go` (`//go:build integration`)
**Interfaces:**
- Consumes the `Client` interface and `Monitor` from Task 2.
- Produces `NewBreml(ctx, url, username, password string) (Client, error)`. It logs in once and reuses the session (Kuma rate-limits logins). It ensures the tag `kuma-sync` exists, creating it on first use with color `#2563eb`.
Integration harness: testcontainers-go starts `louislam/uptime-kuma:2.5.6-slim` with `UPTIME_KUMA_DB_TYPE=sqlite` and waits for port 3001. Setup must then create the admin. Use the client's setup call if it has one; otherwise emit `needSetup`/`setup` after the `info` event, exactly as `ansible/roles/uptime-kuma/files/kuma-setup.js` does.
- [ ] **Step 1: Write failing integration tests:**
- `TestCreateFieldsIntact`: creates a keyword, a json-query (operator `contains`), a dns, a port and a group monitor, then reads them back through a fresh client. Every field from Task 2's `Monitor` must equal the input.
- `TestUpdateKeepsUnchangedFields`: updates only `Interval` from 60 to 120. On re-read, `Parent`, `AcceptedStatusCodes`, `Keyword` and `ExpiryNotification` are unchanged.
- `TestRestartAdoptsByTag`: creates monitors, discards the client, builds a new one. `ListOwned` returns the same IDs with the same `OwnerKey`s.
- `TestUntaggedMonitorUntouched`: a monitor created through the raw library without a tag is absent from `ListOwned`, and `Update`/`Delete` on its ID return an error.
- [ ] **Step 2: Run** `go test -tags integration ./internal/kumaclient/...`. Expected: FAIL.
- [ ] **Step 3: Implement `breml.go`.** Map `Monitor` to the library's per-type monitor structs. Tag with `AddMonitorTag(tagID, monitorID, ownerKey)`. For `Update`, read the current full object and overwrite only the fields we manage, as Review Focus 4 requires.
- [ ] **Step 4: Run** the integration tests. Expected: all 4 PASS.
- [ ] **Step 5: Commit** `feat(kuma-sync): Kuma client on go-uptime-kuma-client`.
### Task 4: Reconciler
**Files:**
- Create: `ci/images/kuma-sync/internal/controller/kumamonitor_controller.go`
- Create: `ci/images/kuma-sync/internal/controller/mapping.go` (spec → `kumaclient.Monitor`)
- Test: `ci/images/kuma-sync/internal/controller/kumamonitor_controller_test.go` (controller-runtime fake client + Kuma fake)
- Test: `ci/images/kuma-sync/internal/controller/mapping_test.go`
**Interfaces:**
- Consumes `kumaclient.Client` and the API types.
- Produces `Reconciler{Client client.Client; Kuma kumaclient.Client}` with `Reconcile(ctx, req) (ctrl.Result, error)` and `SetupWithManager(mgr)`.
- Algorithm:
1. Get the resource.
2. If it is being deleted, delete the owned monitor by OwnerKey (absent counts as success) and remove the finalizer.
3. Ensure the finalizer is present.
4. Resolve `parentRef`: the parent `KumaMonitor` must exist and have `status.monitorID`. If not, set `Ready=False` with reason `ParentPending` and requeue after 10 s.
5. Map the spec to the desired `Monitor`, with `OwnerKey = ns/name`.
6. Find the existing monitor via `ListOwned` by OwnerKey. If there is none, `Create`. If it differs on managed fields, `Update`.
7. Set the status: monitorID, observedGeneration, `Ready=True`.
8. Requeue after 5 min.
- [ ] **Step 1: Write failing tests:**
- `TestMappingAllTypes`: a table test, one row per type, showing that spec fields land on the right `Monitor` fields; `Paused` maps to `Active=false`.
- `TestReconcileCreates`
- `TestReconcileAdoptsExisting`: the fake already holds a monitor with the same OwnerKey. Expected: no second create, and status.monitorID equals the existing ID.
- `TestReconcileUpdatesDrift`: the fake monitor's interval differs. Expected: one `Update`.
- `TestReconcileNoopWhenEqual`: zero Update calls.
- `TestReconcileWaitsForParent`: a child whose group has no monitorID gets `RequeueAfter == 10s` and `Ready=False/ParentPending`.
- `TestReconcileDeleteRemovesMonitorAndFinalizer`
- `TestReconcileDeleteToleratesMissingMonitor`
- [ ] **Step 2: Run** `go test ./internal/controller/...`. Expected: FAIL.
- [ ] **Step 3: Implement the mapping and the reconciler.**
- [ ] **Step 4: Run** the tests. Expected: PASS.
- [ ] **Step 5: Commit** `feat(kuma-sync): KumaMonitor reconciler`.
### Task 5: Orphan garbage collection, manager, image
**Files:**
- Create: `ci/images/kuma-sync/internal/controller/gc.go` (a `manager.Runnable` ticking every 5 min)
- Create: `ci/images/kuma-sync/cmd/kuma-sync/main.go`
- Create: `ci/images/kuma-sync/Containerfile`. Stage 1 is `golang` (pinned digest), runs `go test ./...` without tags, then builds a static binary. Stage 2 is distroless static nonroot.
- Modify: `.woodpecker/build-ci-images.yml`. Add a `build-kuma-sync` step that mirrors `build-authentik-gate-sync`, with tag `YYYY-MM-DD-HHMM` plus `latest`.
- Test: `ci/images/kuma-sync/internal/controller/gc_test.go`
**Interfaces:**
- Produces `GC{Client client.Client; Kuma kumaclient.Client; Interval time.Duration}`. Each run deletes every owned monitor whose OwnerKey has no matching `KumaMonitor`. A list error from either side aborts the run, so nothing is deleted on partial information.
- `main.go` reads env `KUMA_URL`, `KUMA_USERNAME` and `KUMA_PASSWORD`. Missing env makes it exit non-zero with a message naming the variable. Health probes run on `:8081`, metrics on `:8080`, and there is no leader election.
- [ ] **Step 1: Write failing tests:**
- `TestGCDeletesOrphans`
- `TestGCIgnoresUntagged`: a foreign monitor in the fake survives.
- `TestGCKeepsLiveResources`
- `TestGCAbortsOnListError`: the fake k8s list fails, and zero deletes happen.
- [ ] **Step 2: Run** the tests. Expected: FAIL.
- [ ] **Step 3: Implement the GC and `main.go`.**
- [ ] **Step 4: Run** `go test ./...` (expected: PASS), then `docker build -f Containerfile .` in `ci/images/kuma-sync` (expected: the build succeeds, with the test stage passing).
- [ ] **Step 5: Commit** `feat(kuma-sync): orphan GC, manager and image build`. Open a PR. After the owner merges it, the controller confirms the first image tag exists in the registry before Task 7.
### Task 6: End-to-end integration test (automated pilot)
**Files:**
- Test: `ci/images/kuma-sync/internal/controller/e2e_integration_test.go` (`//go:build integration`)
The test runs envtest (an API server with the CRD) plus a Kuma 2.5.6 container plus the real manager, with the breml client and GC interval 2 s.
- [ ] **Step 1: Write the tests:**
- `TestE2EGroupNesting`: apply group `outer`, group `inner` (parent outer), and a dns monitor (parent inner), all at once. Within 60 s, all three are in Kuma with the right parent IDs.
- `TestE2ERestartNoDuplicates`: stop the manager, start a new one, wait 2 GC intervals. The monitor count in Kuma is unchanged.
- `TestDeleteWhileDownIsCollected`: stop the manager, remove the finalizer, delete the resource, start the manager. The monitor is gone from Kuma within 2 GC intervals.
- `TestE2EDriftRepaired`: change the interval directly in Kuma via the raw library. The next resync restores the spec value.
- [ ] **Step 2: Run** `go test -tags integration ./internal/controller/...`. Expected: PASS. Any failure is a defect, not something to retry.
- [ ] **Step 3: Commit** `test(kuma-sync): end-to-end integration suite`.
### Task 7: Kubernetes deployment
**Files:**
- Create: `kubernetes/apps/kuma-sync/{crd-kumamonitors.yaml (copied from config/crd), rbac.yaml, externalsecret.yaml, deployment.yaml, kustomization.yaml}`
- Create: `kubernetes/clusters/homelab/kuma-sync.yaml`. It is a Flux Kustomization modeled on the removed `autokuma.yaml`: dependsOn infrastructure and infrastructure-crds, with the CEL ExternalSecret health check.
- Create: `kubernetes/infrastructure/network-policies/kuma-sync-ns.yaml`, the same shape as the removed `autokuma-ns.yaml`: ingress `[{}]`, DNS with `rules.dns`, kube-apiserver, `toFQDNs kuma.renner.uno:443`. Allow kubelet probes from `host`.
- Create: `kubernetes/infrastructure/image-automation/kuma-sync.yaml` (ImageRepository + ImagePolicy).
- Modify: `namespaces.yaml` (add `kuma-sync`), `network-policies/kustomization.yaml`, and the `openbao-clustersecretstore.yaml` allowlist (sorted).
**Interfaces:**
- Deployment: image `gitea.renner.uno/gitea_admin/kuma-sync:<first tag from Task 5>` with the setter `# {"$imagepolicy": "flux-system:kuma-sync"}`. 1 replica, `Recreate`. Security context as in `kubernetes/apps/uptime-monitor/deployment.yaml`, with a read-only root filesystem.
- ClusterRole: `kumamonitors` get/list/watch/update/patch, `kumamonitors/status` get/update/patch, `kumamonitors/finalizers` update, and `events` create/patch.
- [ ] **Step 1: Write the manifests.**
- [ ] **Step 2: Verify:**
- `kubectl kustomize` on every touched directory renders.
- `kubectl apply --dry-run=server -f kubernetes/apps/kuma-sync/crd-kumamonitors.yaml` succeeds.
- [ ] **Step 3: Commit** `feat(kuma-sync): deploy controller` and open a PR. After merge, the controller checks:
- `flux get kustomization kuma-sync` reports Ready.
- The pod is Running.
- The Kuma log shows a login from the controller.
- [ ] **Step 4: Live smoke test** in a follow-up PR: one `KumaMonitor` (keyword on www.renner.uno) in namespace `kuma-sync`. Expected:
- It shows up in Kuma, tagged `kuma-sync=kuma-sync/www-renner-uno`.
- Deleting the pod changes nothing in Kuma.
- Removing it from git deletes the monitor.
## Follow-up (parent plan)
Parent plan `2026-10-18-uptime-kuma.md` Task 3 (pilot) is replaced by Tasks 6 and 7 here. Its Tasks 4 and 5 then use `KumaMonitor` instead of `KumaEntity`.
@@ -0,0 +1,168 @@
# Uptime Kuma (replace UptimeRobot) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace UptimeRobot with a self-hosted Uptime Kuma on the NetBird VPS, with every monitor, notification and the status page declared in this repo.
**Architecture:** Uptime Kuma runs as an Ansible-managed container on `netbird.renner.uno` behind that host's existing Traefik, and checks targets over the public internet (no NetBird dependency). An in-cluster controller turns `KumaEntity` custom resources into Kuma monitors over Kuma's public HTTPS endpoint. The cluster's blackbox exporter probes Kuma itself, so a dead VPS still raises an alarm. Kuma moves to its own VPS later by retargeting the Ansible play and DNS; nothing else changes.
**Tech Stack:** Uptime Kuma 2.5.6 (`louislam/uptime-kuma:2.5.6-slim`, SQLite), AutoKuma 2.0.0 (`ghcr.io/bigboot/autokuma:2.0.0`, Kubernetes source), Ansible + docker compose, Traefik (docker provider), Flux, ESO/OpenBao, Woodpecker.
**Spec:** this conversation (2026-10-18). There is no separate spec file; the decisions are recorded under *Decisions* below.
## Decisions (from the design conversation)
- Kuma lives **off-site**, on the NetBird coordinator VPS for now. It gets its own VPS once the setup is accepted. The NetBird coordinator is not in the ingress path, so sharing the host does not skew what the probes measure.
- Monitors are **declared in the repo** as custom resources and reconciled by a controller. A ready-made controller is used if it passes the pilot gate (Task 3). Otherwise we write our own, under a separate plan.
- Detailed checks (keyword, JSON query, DNS, TCP, certificate expiry) are a goal, not an afterthought. The custom-resource schema must carry them from the first monitor.
- The UptimeRobot IngressMonitorController (`kubernetes/apps/uptime-monitor/`) is removed after cutover.
## Owner answers (2026-10-18)
1. The credential source is OpenBao. The play reads it with `community.hashi_vault`.
2. Public DNS is managed in the Cloudflare UI. The owner creates `kuma.renner.uno` before Task 1 deploys and repoints `uptime.renner.uno` in Task 5.
3. The SMTP sender is `uptime@renner.uno`. Its Proton SMTP settings are stored at OpenBao `homelab/infrastructure/uptime-kuma/smtp` with keys `server`, `port`, `username` and `token`. Task 4 reads all four, and the `uptime-kuma-vps` policy gains read on `homelab/data/infrastructure/uptime-kuma/smtp`.
## Original open questions (kept for reference)
1. **Where the Kuma admin credential lives.** Kuma is single-user. Ansible needs that user to bootstrap the instance, and AutoKuma needs it to log in. Ansible has no OpenBao lookup today (`ansible/` uses Ansible Vault only), so storing it in both places makes two copies. *Recommendation:* OpenBao `infrastructure/uptime-kuma` is the only source, and the play reads it with `community.hashi_vault`, using a Woodpecker secret token that has read access to that one path. This plan assumes that recommendation. If it is rejected, Task 1 Step 2 changes and nothing else does.
2. **Public DNS.** Neither `netbird.renner.uno` nor `uptime.renner.uno` is defined in this repo (`opentofu/dns_renner_uno.tf` holds LAN records only). `uptime.renner.uno` currently points at UptimeRobot's status-page fleet. Where are these records managed? If it is the Cloudflare UI, Task 1 needs `kuma.renner.uno` created there and Task 5 needs `uptime.renner.uno` repointed there.
3. **Alert channel.** Alertmanager sends email through `smtp.protonmail.ch:587` as `cluster@renner.uno`. Kuma needs its own SMTP token, because no service shares another's key. *Recommendation:* create a Proton SMTP token for `uptime@renner.uno` (or another address you pick) and store it at OpenBao `infrastructure/uptime-kuma`, key `smtp_password`. Creating the token is a one-time owner action.
## Global Constraints
- Kuma image: `louislam/uptime-kuma:2.5.6-slim`. Pin it in role defaults, the way `netbird_version` is pinned. The controller drives Kuma's **internal Socket.io API**, which upstream says may change without notice. Kuma and AutoKuma are therefore bumped together, and only after the pilot checks from Task 3 pass against the new pair.
- `UPTIME_KUMA_DB_TYPE=sqlite`. This skips the database setup wizard (`server/setup-database.js:98`).
- Kuma data lives at `/opt/uptime-kuma/data` (container `/app/data`) on local disk. Not NFS.
- Hostnames: `kuma.renner.uno` serves the admin UI and the Socket.io API. `uptime.renner.uno` serves the public status page, through Kuma's custom-domain mapping.
- Nothing in the Kuma alert path may traverse the homelab.
- No `2>/dev/null` anywhere. No inline interpreters in Bash. Commits use the claude-bot identity, every change goes through a PR, and nothing is ever force-pushed.
## Review Focus
1. **Socket.io API drift.** AutoKuma 2.0.0 (2025-11) targets an older Kuma 2.x API than 2.5.6. Silent failures (a monitor created with fields dropped) are worse than loud ones. The Task 3 pilot asserts the field values Kuma actually stored, not just that the monitor exists.
2. **Controller restart duplicates monitors.** AutoKuma tracks ownership by tag and keeps state in `/data`. A pod restart with an empty `/data` must not create a second copy of every monitor. Task 3 Step 5 pins this.
3. **Deleting a `KumaEntity` while the controller is down.** The finalizer `entity.autokuma.bigboot.dev` must hold the object until the controller is back and removes the monitor. Task 3 Step 6 pins this.
4. **Kuma or VPS down goes unnoticed.** The blackbox probe on `https://kuma.renner.uno` plus its alert rule (Task 4) is the dead-man switch. Task 4 Step 4 proves the alert fires.
5. **Unclaimed fresh instance.** Between `docker compose up` and admin setup, the first visitor to `kuma.renner.uno` could claim admin. The bootstrap runs in the same play run, before the Traefik route is enabled (Task 1 Step 4).
---
### Task 1: Uptime Kuma on the NetBird VPS (Ansible role, play, pipeline)
**Files:**
- Create: `ansible/roles/uptime-kuma/defaults/main.yml`
- Create: `ansible/roles/uptime-kuma/tasks/main.yml`
- Create: `ansible/roles/uptime-kuma/templates/docker-compose.yml.j2`
- Create: `ansible/roles/uptime-kuma/files/kuma-setup.js`
- Create: `ansible/plays/install-uptime-kuma.yml`
- Create: `.woodpecker/deploy-uptime-kuma.yml`
**Interfaces:**
- Produces: `https://kuma.renner.uno` (Kuma UI and Socket.io), with the admin user from OpenBao `infrastructure/uptime-kuma` keys `username` and `password`. Task 3 consumes these.
- [ ] **Step 1: Role defaults.** Set `uptime_kuma_version: "2.5.6"`, `uptime_kuma_host: kuma.renner.uno`, `uptime_kuma_status_host: uptime.renner.uno`, `uptime_kuma_dir: /opt/uptime-kuma`, and `uptime_kuma_traefik_network: netbird_netbird`. The last one is the network the NetBird Traefik watches (`ansible/roles/netbird-server/templates/docker-compose.yml.j2`, docker provider).
- [ ] **Step 2: Credentials.** Look up `username` and `password` from OpenBao `infrastructure/uptime-kuma` with `community.hashi_vault`. Add the collection to the CI image only if `ci-ansible:2026-04-29` lacks it; check with `ansible-galaxy collection list` in that image. Store the Woodpecker secret `openbao_uptime_kuma_token`, and give it a policy that reads that one path.
- [ ] **Step 3: Compose template.** One service, `uptime-kuma`, using image `louislam/uptime-kuma:{{ uptime_kuma_version }}-slim`. Environment `UPTIME_KUMA_DB_TYPE=sqlite`, volume `{{ uptime_kuma_dir }}/data:/app/data`, external network `{{ uptime_kuma_traefik_network }}`. Traefik labels: `traefik.enable={{ uptime_kuma_route_enabled }}` and a router on `Host(kuma.renner.uno) || Host(uptime.renner.uno)` with `tls.certresolver=letsencrypt` and server port 3001.
- [ ] **Step 4: Bootstrap with no manual step.** `files/kuma-setup.js` uses `socket.io-client`, which is a production dependency of Kuma 2.5.6 and so present in the image. It connects to `http://localhost:3001`, emits `needSetup`, and if the answer is true emits `setup(username, password)`. If the answer is false it exits 0. It reads the credentials from env vars `KUMA_SETUP_USERNAME` and `KUMA_SETUP_PASSWORD`, never from arguments. The tasks run in this order:
1. Template compose with `uptime_kuma_route_enabled: false`, then `docker compose up -d`.
2. Wait for `GET http://127.0.0.1:3001` (published on loopback only) to return 200.
3. Copy the script in and run it with `docker compose exec -e ... uptime-kuma node /tmp/kuma-setup.js`.
4. Re-template with the route enabled and run `docker compose up -d` again.
- [ ] **Step 5: Play and pipeline.** `plays/install-uptime-kuma.yml` targets hosts `netbird_server` with roles `uptime-kuma`. `docker-install` is already applied by the NetBird play. `.woodpecker/deploy-uptime-kuma.yml` is a copy of `.woodpecker/deploy-netbird-server.yml` with its paths and play swapped, and it adds the OpenBao token secret.
- [ ] **Step 6: Verify.**
- Run `ansible-playbook --syntax-check -i ansible/inventory ansible/plays/install-uptime-kuma.yml`. Expected: no errors.
- After the pipeline runs, `curl -sI https://kuma.renner.uno/` returns `302` to `/dashboard`. A `/setup` response means the bootstrap failed.
- Run the pipeline a second time. Expected: it is green and the script logs "already set up".
- [ ] **Step 7: Commit and open a PR.** Message: `feat(uptime-kuma): Uptime Kuma on the NetBird VPS`.
### Task 2: Controller deployment (AutoKuma, Kubernetes source)
**Files:**
- Create: `kubernetes/apps/autokuma/{crd-kumaentities.yaml,externalsecret.yaml,rbac.yaml,deployment.yaml,kustomization.yaml}`
- Create: `kubernetes/clusters/homelab/autokuma.yaml`
- Create: `kubernetes/infrastructure/network-policies/autokuma-ns.yaml`
- Modify: `kubernetes/infrastructure/namespaces/namespaces.yaml`. Add namespace `autokuma`.
**Interfaces:**
- Consumes: Task 1's `https://kuma.renner.uno` and the OpenBao `infrastructure/uptime-kuma` `username`/`password`.
- Produces: the CRD `kumaentities.autokuma.bigboot.dev/v1` (namespaced, `spec.config` is a free-form object). Monitors live as `KumaEntity` objects in namespace `autokuma`. `metadata.name` is the AutoKuma ID, and `spec.config` uses the static-monitor keys (`type`, `name`, `url`, …).
- [ ] **Step 1: Vendor the CRD.** Copy it verbatim from `https://raw.githubusercontent.com/BigBoot/AutoKuma/v2.0.0/autokuma/kubernetes/crds-autokuma.yml`, with a one-line header naming the source and the tag.
- [ ] **Step 2: RBAC.** AutoKuma lists and watches `Api::<KumaEntity>::all` (`kubernetes_source.rs`, `init`), so a namespaced Role cannot satisfy it. Create a ClusterRole limited to `kumaentities` with `get, list, watch, patch, update`, plus a ClusterRoleBinding to ServiceAccount `autokuma/autokuma`. No other resources.
- [ ] **Step 3: ExternalSecret.** Use ClusterSecretStore `openbao`, key `infrastructure/uptime-kuma`, properties `username` and `password`, rendered into a Secret named `autokuma-kuma`. Copy the CEL health-check pattern from `kubernetes/clusters/homelab/uptime-monitor.yaml`.
- [ ] **Step 4: Deployment.** Image `ghcr.io/bigboot/autokuma:2.0.0`, 1 replica, strategy `Recreate`. Environment:
- `AUTOKUMA__KUMA__URL=https://kuma.renner.uno`
- `AUTOKUMA__KUMA__USERNAME` and `AUTOKUMA__KUMA__PASSWORD` from the Secret
- `AUTOKUMA__KUBERNETES__ENABLED=true`
- `AUTOKUMA__DOCKER__ENABLED=false`
- `AUTOKUMA__TAG_NAME=AutoKuma`
- `AUTOKUMA__ON_DELETE=delete`
Mount an `emptyDir` at `/data`. Add the security context from `kubernetes/apps/uptime-monitor/deployment.yaml`, and the reloader annotation for `autokuma-kuma`.
- [ ] **Step 5: NetworkPolicy.** Copy `uptime-monitor-ns.yaml`: default-deny, DNS to kube-dns, `kube-apiserver`, and `world` on TCP 443.
- [ ] **Step 6: Flux Kustomization.** Path `./kubernetes/apps/autokuma`, `prune: true`, `wait: true`, `dependsOn` `infrastructure` and `infrastructure-crds`. It ships with **no** `KumaEntity` objects.
- [ ] **Step 7: Verify.**
- `kubectl kustomize kubernetes/apps/autokuma` renders.
- After merge, `kubectl -n autokuma logs deploy/autokuma` shows a successful Kuma login and no "CRD is not queryable".
- `flux get kustomization autokuma` reports Ready.
- [ ] **Step 8: Commit and open a PR.** Message: `feat(autokuma): KumaEntity controller for Uptime Kuma`.
### Task 3: Pilot gate — does AutoKuma meet the bar?
**Files:**
- Create: `kubernetes/apps/autokuma/pilot-kumaentities.yaml`. Add it to the kustomization last.
**Interfaces:**
- Consumes: Task 2's CRD and controller.
- Produces: a go/no-go verdict, recorded in the PR description. **No-go stops this plan.** The fallback is our own controller against the same CRD shape, under a separate plan.
- [ ] **Step 1: Pilot entity.** Create `KumaEntity` `www-renner-uno` with `config: {type: keyword, name: "www.renner.uno", url: "https://www.renner.uno/", keyword: "<a string present on the page>", interval: 60, expiry_notification: true}`. Check the field names against `https://autokuma.bigboot.dev/dev/entity-types/keyword/` before writing them.
- [ ] **Step 2: Fields arrive intact.** In the Kuma UI, or with `kuma monitor list` from the AutoKuma CLI, confirm `type=keyword`, the keyword, `interval=60` and certificate-expiry notification are all as declared. Expected: all four match. Any dropped field is a **no-go**.
- [ ] **Step 3: Update.** Change `interval` to 120 in git. Expected: Kuma shows 120 within one reconcile (≤ 5 min), and the monitor ID is unchanged.
- [ ] **Step 4: Detailed types.** Add one `json-query` entity, one `dns` entity, and one `port` entity on `proxy-1.renner.uno:33080`. Expected: all three are created with their type-specific fields intact.
- [ ] **Step 4a: Groups.**
- Add a `group` entity `pilot` and set `parent_name: pilot` on every pilot check. Expected: in Kuma, all checks sit under the group, and the group goes DOWN when one child is pointed at a non-existent path. A group is DOWN when any child is DOWN (`server/monitor-types/group.js`).
- Add a group `pilot-inner` with `parent_name: pilot`, and move one check under it. Record whether nesting works. If it doesn't, Task 5 uses a flat layout with one group per service, which is not a no-go.
- [ ] **Step 4b: JSON query intact.** Read back the `json-query` entity's `json_path`, `json_path_operator` and `expected_value` from Kuma. Use a non-default operator such as `>=`, so that a dropped field shows up as the default `==`. Expected: all three match the values declared in git.
- [ ] **Step 5: Restart without duplicates.** Delete the AutoKuma pod, which empties `/data`. Expected: after it restarts, Kuma still holds exactly one monitor per entity.
- [ ] **Step 6: Deletion while the controller is down.** Scale the controller to 0, delete the `port` entity in git, wait for Flux, then scale back to 1. Expected: the object sits `Terminating` with the finalizer while the controller is down, and the Kuma monitor is removed after scale-up. An orphaned monitor is a **no-go**.
- [ ] **Step 7: Verdict.** If Steps 2–6 all pass, the verdict is **go**: commit the pilot entities and open a PR with the evidence. Otherwise it is **no-go**: revert the pilot and report which step failed.
### Task 4: Notifications, status page, dead-man switch
**Files:**
- Modify: `ansible/roles/uptime-kuma/files/kuma-setup.js`. Add the SMTP notification: host, port, user and password from OpenBao `homelab/infrastructure/uptime-kuma/smtp` keys `server`, `port`, `username` and `token`, sender `uptime@renner.uno`, "default enabled", applied to all monitors. `KumaEntity.spec.config` is plain text in git, so the secret-bearing notification is created by the bootstrap and never by a custom resource.
- Create: `kubernetes/apps/autokuma/status-page.yaml` (a `KumaEntity` status page with slug `renner`, domain `uptime.renner.uno`)
- Modify: `kubernetes/apps/monitoring-rules/probes-blackbox.yaml`. Add `https://kuma.renner.uno` to `blackbox-http`.
- Modify: the alert-rule file that covers `blackbox-http` targets. Find it with Grep for `probe_success` under `kubernetes/apps/monitoring-rules/`.
- [ ] **Step 1: Check the status-page entity.** Check AutoKuma's status-page entity docs for field names and domain support. If AutoKuma cannot set the status-page domain, have `kuma-setup.js` set it instead, keeping the value in the role defaults.
- [ ] **Step 2: Notification.** Extend `kuma-setup.js` so it creates the notification if none named `email` exists, and updates it otherwise. Run the Task 1 pipeline. Expected: in the Kuma UI, *Test* delivers a mail to the owner's address, and a second pipeline run creates no duplicate.
- [ ] **Step 3: Status page.** Apply it. Expected: `curl -s -H 'Host: uptime.renner.uno' https://kuma.renner.uno/` returns the status page HTML, not the dashboard redirect. Turn on Kuma's "Trust Proxy" setting through the same mechanism as the domain.
- [ ] **Step 4: Dead-man switch.** Add the blackbox target and make sure an alert rule covers it. Prove it fires: stop the Kuma container on the VPS through the pipeline (or have the owner do it). Expected: the alert email arrives through Alertmanager. Then start Kuma again.
- [ ] **Step 5: Response-time alerting.** Kuma 2.5.6 has no latency threshold; its states are only UP, DOWN, PENDING and MAINTENANCE. Degradation is therefore alerted from the cluster:
- `kuma-setup.js` creates a Kuma metrics API key named `prometheus` if none exists, and writes it to OpenBao `infrastructure/uptime-kuma-metrics` key `api_key`. The Ansible token gets write access to that one path. No other service's credential is reused.
- Add an ExternalSecret into `monitoring` for that key, and an `additionalScrapeConfigs` job `uptime-kuma` in `kubernetes/apps/monitoring/helmrelease-prometheus.yaml`: target `kuma.renner.uno:443`, `scheme: https`, `basic_auth` with an empty username and the key as `password_file`.
- Check that the `monitoring` namespace egress policy lets Prometheus reach `world:443`, and add the rule if it doesn't.
- Add a PrometheusRule `UptimeKumaSlowResponse` on `monitor_response_time` (label `monitor_name`): `avg_over_time(monitor_response_time[15m]) > 1500` for 15m, severity warning. Check the metric name against a live scrape before writing the rule.
- Expected: `monitor_response_time` series are visible in Prometheus for every Kuma monitor, and the rule loads without errors.
- [ ] **Step 6: Commit and open a PR.** Message: `feat(autokuma): notifications, status page, Kuma dead-man probe, latency alerting`.
### Task 5: Migrate monitors, cutover, decommission UptimeRobot
**Files:**
- Create: `kubernetes/apps/autokuma/monitors-*.yaml`. One file per area, one `KumaEntity` per check.
- Delete (in the decommission PR): `kubernetes/apps/uptime-monitor/`, `kubernetes/clusters/homelab/uptime-monitor.yaml`, `kubernetes/infrastructure/network-policies/uptime-monitor-ns.yaml`, and the `uptime-monitor` namespace entry.
- [ ] **Step 1: Inventory.** Take the 9 hand-made monitors from UptimeRobot's public feed. Its path is in the header of `kubernetes/apps/uptime-monitor/pilot-endpointmonitors.yaml`. Each monitored service becomes one `group` entity with its checks as children, nested or flat depending on Task 3 Step 4a.
- Every check sets `accepted_statuscodes` and a tight `timeout`, so "far too slow" counts as DOWN.
- Where the service has a health or API endpoint, use `json-query` with a JSONata expression that validates the parsed content (example: `$count(components[status != "ok"])` `==` `0`).
- Otherwise use `keyword` on a string that only a working page renders. Keyword matching is a case-sensitive substring, not a regex.
- `expiry_notification: true` on every HTTPS check.
- The 3 third-party monitors move as plain checks.
- [ ] **Step 2: Parallel run.** Merge the monitors and run both systems for 7 days. Expected: Kuma reports every outage UptimeRobot reports.
- [ ] **Step 3: Cutover.** Repoint `uptime.renner.uno` at the VPS (see open question 2). Expected: `curl -sI https://uptime.renner.uno/` is served by Kuma with a Let's Encrypt certificate.
- [ ] **Step 4: Decommission PR.** Remove the IMC files listed above in one PR. Monitors deleted along with the CRD are orphaned in UptimeRobot. That is acceptable, because the account is closed next.
- [ ] **Step 5: Owner action.** Close the UptimeRobot account. Delete the OpenBao key `infrastructure/uptime-robot`.
- [ ] **Step 6: Wiki.** Deposit the final architecture through `wiki-ingest`: the corrected `uptime-kuma-probes` note, the Socket.io-API pinning rule, and the dead-man switch.
@@ -0,0 +1,47 @@
# 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, push, 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.
## 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.
@@ -450,7 +450,7 @@ data:
# synthesise the entry by creating a service-account user
# whose user.attributes carry the sambaDomain objectClass
# and the required schema fields. The patched LDAP outpost
# (gitea.renner.uno/k.renner/authentik-samba-bridge-ldap-outpost)
# (gitea.renner.uno/konairius/authentik-samba-bridge-ldap-outpost)
# accepts sambaDomain in its hardcoded GetUserOCs() set so the
# search isn't short-circuited. The sambaSID below MUST match
# SAMBA_BRIDGE_DOMAIN_SID in samba-bridge-secret.sops.yaml AND
@@ -711,8 +711,8 @@ data:
# AND (&(sambaDomainName=...)(objectClass=sambaDomain))
# return entries instead of short-circuiting. Built from
# the samba-bridge repo at
# gitea.renner.uno/k.renner/authentik-samba-bridge .
container_image: gitea.renner.uno/k.renner/authentik-samba-bridge-ldap-outpost:2026.8.3-872f754
# gitea.renner.uno/konairius/authentik-samba-bridge .
container_image: gitea.renner.uno/konairius/authentik-samba-bridge-ldap-outpost:2026.8.3-9dd92dd
object_naming_template: "ak-outpost-%(name)s"
kubernetes_namespace: authentik
kubernetes_replicas: 1
+4 -4
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
+22
View File
@@ -72,6 +72,28 @@ spec:
- port: "3000"
protocol: TCP
---
# Secret rotation: the ha-config-sync rotator mints ha-config-sync's PAT. Egress
# half: rotator-ha-config-sync-egress in network-policies/openbao-ns.yaml.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: gitea-allow-ha-config-sync-rotator
namespace: gitea
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: gitea
ingress:
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": openbao
app.kubernetes.io/component: secret-rotator
app.kubernetes.io/name: ha-config-sync
toPorts:
- ports:
- port: "3000"
protocol: TCP
---
# Package retention CronJob (package-retention.yaml, REN-1203). It lists pods
# to find the running image tags. The namespace baseline does not open the API
# server: `kube-apiserver` is its own entity in Cilium; port is the post-DNAT
@@ -0,0 +1,19 @@
# Gitea token of the ha-config-sync account for the config-sync sidecar: push
# `live`, open the live -> main PR. Rotated by openbao/ha-config-sync-secret-rotate.
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: home-assistant-config-sync-gitea
namespace: home-assistant
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: openbao
target:
name: home-assistant-config-sync-gitea
data:
- secretKey: token
remoteRef:
key: infrastructure/ha-config-sync
property: token
+32 -11
View File
@@ -1,8 +1,7 @@
#!/bin/sh
# Home Assistant config-sync sidecar — TWO-WAY.
#
# /config is a git checkout of gitea.renner.uno/k.renner/homeassistant on an RWO
# PVC. This loop keeps the tracked HA config paths in sync with the repo in BOTH
# /config is a git checkout of $GITEA_URL/$GITEA_REPO on an RWO PVC. This loop keeps the tracked HA config paths in sync with the repo in BOTH
# directions:
#
# push — changes made inside HA (code-server edits, HA UI automation/script
@@ -15,8 +14,11 @@
# files at runtime; two-way merge handles that fine — the drift is committed to
# `live` (PR'd) and merges cleanly, so it no longer needs excluding. Untracked
# runtime state (.storage/, *.db, secrets.yaml, tts/, deps/, ...) is still left
# alone (it isn't tracked). git auth uses the PAT embedded in the PVC's remote
# URL (also reused for the gitea API to open the PR); reload auth uses $HA_TOKEN.
# alone (it isn't tracked). Git and the gitea API authenticate as $GITEA_USER
# with the token in $GITEA_TOKEN_FILE, re-read every loop and passed to git
# through the environment, so it is never written to the PVC's .git/config.
# The remote URL is reset from $GITEA_URL/$GITEA_REPO on every start. Reload auth
# uses $HA_TOKEN.
#
# Conflicts are left to git: a merge that conflicts is ABORTED (never written
# into the live files, which would break HA) and logged for you to resolve in the
@@ -30,6 +32,12 @@ MAIN="${SYNC_BRANCH:-main}"
LIVE="${SYNC_LIVE_BRANCH:-live}"
INTERVAL="${SYNC_INTERVAL:-30}"
HA_URL="${HA_URL:-http://127.0.0.1:8123}"
GITEA_URL="${GITEA_URL:?GITEA_URL is not set}"
GITEA_REPO="${GITEA_REPO:?GITEA_REPO is not set}"
GITEA_USER="${GITEA_USER:?GITEA_USER is not set}"
GITEA_TOKEN_FILE="${GITEA_TOKEN_FILE:-/run/gitea/token}"
API="${GITEA_URL}/api/v1/repos/${GITEA_REPO}"
GITEA_TOKEN=""
# Tracked HA config paths synced in both directions. www/ is included in full
# (bespoke cards AND HACS-managed assets) — HACS runtime rewrites are captured to
@@ -85,6 +93,21 @@ reload_lovelace() {
fi
}
# Read the (rotated) token and hand it to git as an HTTP header through the
# environment (GIT_CONFIG_*), so it never lands in .git/config on the PVC.
load_auth() {
GITEA_TOKEN="$(cat "${GITEA_TOKEN_FILE}" 2>/dev/null || true)"
if [ -z "${GITEA_TOKEN}" ]; then
log "WARN no token in ${GITEA_TOKEN_FILE}; push, fetch and PRs will fail"
unset GIT_CONFIG_COUNT GIT_CONFIG_KEY_0 GIT_CONFIG_VALUE_0
return
fi
GIT_CONFIG_COUNT=1
GIT_CONFIG_KEY_0=http.extraHeader
GIT_CONFIG_VALUE_0="Authorization: Basic $(printf '%s:%s' "${GITEA_USER}" "${GITEA_TOKEN}" | base64 | tr -d '\n')"
export GIT_CONFIG_COUNT GIT_CONFIG_KEY_0 GIT_CONFIG_VALUE_0
}
# Open a PR live -> main if one isn't already open. gitea returns 422 when a PR
# for this head already exists (or there's no diff); ignore it.
ensure_pr() {
@@ -109,13 +132,9 @@ git config --global pack.windowMemory 32m 2>/dev/null || true
git config --global pack.deltaCacheSize 32m 2>/dev/null || true
git config --global core.deltaBaseCacheLimit 16m 2>/dev/null || true
# Derive gitea API base + token from the remote URL (https://user:TOKEN@host/owner/repo.git).
REMOTE_URL="$(git config --get remote.origin.url 2>/dev/null || echo '')"
GITEA_TOKEN="$(printf '%s' "${REMOTE_URL}" | sed -n 's#^https://[^:]*:\([^@]*\)@.*#\1#p')"
GITEA_HOST="$(printf '%s' "${REMOTE_URL}" | sed -n 's#^https://[^@]*@\([^/]*\)/.*#\1#p')"
REPO_PATH="$(printf '%s' "${REMOTE_URL}" | sed -n 's#^https://[^@]*@[^/]*/\(.*\)\.git$#\1#p')"
API="https://${GITEA_HOST}/api/v1/repos/${REPO_PATH}"
[ -z "${GITEA_TOKEN}" ] && log "WARN could not derive gitea token from remote URL; PRs won't be opened"
# The remote is declared by the manifest, without credentials.
git remote set-url origin "${GITEA_URL}/${GITEA_REPO}.git"
log "remote origin = ${GITEA_URL}/${GITEA_REPO}.git (user ${GITEA_USER})"
# Put HEAD on the live branch without disturbing the working tree (live == current
# HEAD on first run; persisted across restarts on the PVC).
@@ -126,6 +145,8 @@ git symbolic-ref HEAD "refs/heads/${LIVE}" 2>/dev/null || true
FIRST=1
while true; do
load_auth
# 1. PUSH: capture live edits to the tracked config paths onto `live`.
# shellcheck disable=SC2086
if [ -n "$(git status --porcelain -- ${PATHS} 2>/dev/null)" ]; then
+20 -2
View File
@@ -298,8 +298,10 @@ spec:
# force-syncs only the HA YAML config paths from origin/main of the
# gitea repo into the working tree, then calls homeassistant.reload_all.
# Leaves www/ (HACS-managed) and untracked runtime state (.storage,
# *.db, secrets.yaml) untouched. git auth uses the PAT already embedded
# in the PVC's remote URL; reload auth uses a dedicated long-lived token.
# *.db, secrets.yaml) untouched. Git and the Gitea API authenticate as
# ha-config-sync with the token file from home-assistant-config-sync-gitea
# (re-read every loop, so a rotation needs no restart); reload auth uses a
# dedicated long-lived token.
# Runs as root to match HA's root-owned /config files. See config-sync.sh.
- name: config-sync
image: alpine/git:2.54.0@sha256:ce84fd6a057b4dc5cf5009da2dd832f1c6d096c1984bf50496cf3c256402264d
@@ -316,12 +318,23 @@ spec:
secretKeyRef:
name: home-assistant-config-sync-token
key: token
- name: GITEA_URL
value: https://gitea.renner.uno
- name: GITEA_REPO
value: konairius/homeassistant
- name: GITEA_USER
value: ha-config-sync
- name: GITEA_TOKEN_FILE
value: /run/gitea/token
volumeMounts:
- name: config
mountPath: /config
- name: config-sync-script
mountPath: /scripts
readOnly: true
- name: config-sync-gitea
mountPath: /run/gitea
readOnly: true
# ~1.1 GB .git (a 1 GB backup tar in history); auto-gc is disabled in
# the script so it never repacks. fetch/checkout of small config blobs
# peak well under this; the headroom covers index-pack on first fetch.
@@ -343,6 +356,11 @@ spec:
configMap:
name: ha-config-sync-script
defaultMode: 0755
# Not subPath, so a rotated token lands in place.
- name: config-sync-gitea
secret:
secretName: home-assistant-config-sync-gitea
defaultMode: 0400
- name: proxy-config
configMap:
name: ha-proxy-config
@@ -10,6 +10,7 @@ resources:
- vscode-ingress.yaml
- prometheus-token-externalsecret.yaml
- config-sync-token-externalsecret.yaml
- config-sync-gitea-externalsecret.yaml
- mosquitto-bootstrap.yaml
# Former `health` probe credential. The broker no longer reads it (the
# probes use the cert account mosquitto-probe, REN-953); removed with its
@@ -10,6 +10,9 @@ resources:
- prometheusrule-cilium.yaml
- prometheusrule-disk-wear.yaml
- prometheusrule-hetzner-backup.yaml
# Replaces the chart's KubeJobFailed/KubeContainerWaiting (disabled in
# apps/monitoring/helmrelease-prometheus.yaml).
- prometheusrule-jobs.yaml
- prometheusrule-loki.yaml
- prometheusrule-mariadb-backup.yaml
- prometheusrule-netbird-availability.yaml
@@ -0,0 +1,61 @@
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: jobs
namespace: monitoring
labels:
# kube-prometheus-stack's Prometheus selects on this label.
release: kube-prometheus-stack
spec:
groups:
- name: jobs.rules
rules:
# Maps each Job to the series of runs it belongs to: its owning
# CronJob, or its Velero repository for kopia maintenance Jobs.
- record: job_name:kube_job_series:info
expr: |
max by (namespace, job_name, series) (
label_replace(kube_job_owner{job="kube-state-metrics", owner_kind="CronJob"}, "series", "$1", "owner_name", "(.+)")
or
label_replace(kube_job_labels{job="kube-state-metrics", label_velero_io_repo_name!=""}, "series", "$1", "label_velero_io_repo_name", "(.+)")
)
- alert: KubeJobFailed
# A failed Job resolves once a later run of the same series succeeds.
expr: |
kube_job_failed{job="kube-state-metrics", condition="true"} > 0
unless on(namespace, job_name)
(
(
kube_job_status_start_time{job="kube-state-metrics"}
* on(namespace, job_name) group_left(series)
job_name:kube_job_series:info
)
< on(namespace, series) group_left()
max by (namespace, series) (
kube_job_status_completion_time{job="kube-state-metrics"}
* on(namespace, job_name) group_left(series)
job_name:kube_job_series:info
)
)
for: 15m
labels:
severity: warning
annotations:
summary: Job failed to complete.
description: "Job {{ $labels.namespace }}/{{ $labels.job_name }} failed and no later run of the same CronJob or Velero repository has succeeded."
runbook_url: https://runbooks.prometheus-operator.dev/runbooks/kubernetes/kubejobfailed
- alert: KubeContainerWaiting
# Containers of Failed/Succeeded pods never start; KubeJobFailed covers those.
expr: |
kube_pod_container_status_waiting_reason{job="kube-state-metrics", reason!="CrashLoopBackOff"} > 0
unless on(namespace, pod)
(kube_pod_status_phase{job="kube-state-metrics", phase=~"Failed|Succeeded"} == 1)
for: 1h
labels:
severity: warning
annotations:
summary: Pod container waiting longer than 1 hour
description: 'pod/{{ $labels.pod }} in namespace {{ $labels.namespace }} on container {{ $labels.container }} has been in waiting state for longer than 1 hour. (reason: "{{ $labels.reason }}")'
runbook_url: https://runbooks.prometheus-operator.dev/runbooks/kubernetes/kubecontainerwaiting
@@ -369,6 +369,10 @@ spec:
limits:
memory: 256Mi
# Groups Velero maintenance Jobs per repository in prometheusrule-jobs.yaml.
metricLabelsAllowlist:
- jobs=[velero.io/repo-name]
# --- Flux per-object readiness (REN-102) ---------------------------------
#
# WHY THIS IS HERE AND NOT ON THE FLUX CONTROLLERS. `gotk_reconcile_condition`
@@ -703,3 +707,9 @@ spec:
enabled: false
kubeProxy:
enabled: false
# Replaced by monitoring-rules/prometheusrule-jobs.yaml.
defaultRules:
disabled:
KubeJobFailed: true
KubeContainerWaiting: true
+1 -1
View File
@@ -43,7 +43,7 @@ spec:
mountPath: /consume
containers:
- name: vsftpd
image: gitea.renner.uno/k.renner/vsftpd:build.1789630783 # {"$imagepolicy": "flux-system:vsftpd"}
image: gitea.renner.uno/konairius/vsftpd:build.1792310564 # {"$imagepolicy": "flux-system:vsftpd"}
ports:
- name: ftp-ctrl
containerPort: 21
+1 -1
View File
@@ -4,7 +4,7 @@
# This is the configuration of the RUNNER only: where Gitea is, and which
# repositories to process. What Renovate does INSIDE a repository — schedule,
# grouping, commit titles, digest pinning — lives in the shared preset repository
# `k.renner/renovate-config`, which each repository's renovate.json extends. Change
# `konairius/renovate-config`, which each repository's renovate.json extends. Change
# update policy there, not here.
#
# autodiscover + requireConfig=required + onboarding=false
+1 -1
View File
@@ -2,7 +2,7 @@
# Renovate runner (REN-216).
#
# RUNS DAILY, BUT OPENS ROUTINE PULL REQUESTS ON MONDAY ONLY. The weekly rhythm
# is enforced by the `schedule` in the shared preset (k.renner/renovate-config),
# is enforced by the `schedule` in the shared preset (konairius/renovate-config),
# not by this cron line. The daily run exists for one reason: a security update
# (`vulnerabilityAlerts`, which the preset leaves unscheduled) must not wait up
# to six days. On Tuesday to Sunday a run finds nothing in schedule, opens and
+2 -2
View File
@@ -32,10 +32,10 @@ spec:
type: RuntimeDefault
containers:
- name: review-bot
image: gitea.renner.uno/gitea_admin/review-bot:2026-10-18-0612 # {"$imagepolicy": "flux-system:review-bot"}
image: gitea.renner.uno/gitea_admin/review-bot:2026-10-18-0706 # {"$imagepolicy": "flux-system:review-bot"}
env:
- name: REPOS
value: k.renner/homelab,k.renner/homeassistant,k.renner/authentik-samba-bridge,konairius/shelly-manager
value: k.renner/homelab,konairius/homeassistant,konairius/authentik-samba-bridge,konairius/shelly-manager
- name: GITEA_URL
value: https://gitea.renner.uno
- name: AGENT_NAME
+1 -1
View File
@@ -67,7 +67,7 @@ spec:
- name: Wiki__NoteStore__Gitea__BaseUrl
value: https://gitea.renner.uno
- name: Wiki__NoteStore__Gitea__Repository
value: k.renner/notes
value: konairius/notes
- name: Wiki__NoteStore__Gitea__Branch
value: main
- name: Wiki__Embeddings__Models__bge-m3__Url
@@ -4,7 +4,7 @@ metadata:
name: vsftpd
namespace: flux-system
spec:
image: gitea.renner.uno/k.renner/vsftpd
image: gitea.renner.uno/konairius/vsftpd
interval: 30m
---
# CI publishes `build.<unix-ts>` alongside SHA and `main` tags so the
@@ -212,6 +212,28 @@ spec:
- port: "8000"
protocol: TCP
---
# Egress for the ha-config-sync rotator: gitea :3000 (mint). Complements
# gitea-allow-ha-config-sync-rotator.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: rotator-ha-config-sync-egress
namespace: openbao
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/component: secret-rotator
app.kubernetes.io/name: ha-config-sync
egress:
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": gitea
app.kubernetes.io/name: gitea
toPorts:
- ports:
- port: "3000"
protocol: TCP
---
# Egress for the claude-oauth sync rotator: woodpecker server only (woodpecker :8000).
# It never touches gitea (the OAuth value is seeded into OpenBao by hand, not
# minted). Complements woodpecker-allow-rotators (woodpecker side).
@@ -0,0 +1,28 @@
# OpenBao policy: rotator-ha-config-sync
#
# Rotates the `ha-config-sync` Gitea PAT (write:repository) used by the Home
# Assistant config-sync sidecar (rotation/ha-config-sync.yaml).
#
# NOT applied by Flux. Apply ONCE with an admin token:
#
# bao policy write rotator-ha-config-sync kubernetes/infrastructure/openbao/policies/rotator-ha-config-sync.hcl
# bao write auth/kubernetes/role/rotator-ha-config-sync \
# bound_service_account_names=ha-config-sync-secret-rotator \
# bound_service_account_namespaces=openbao \
# policies=rotator-ha-config-sync ttl=15m
#
# BOOTSTRAPS (by hand):
# bao kv put homelab/infrastructure/secret-rotators-bootstrap/ha-config-sync \
# username=ha-config-sync password=<ha-config-sync account password>
# bao kv put homelab/infrastructure/ha-config-sync token=placeholder
#
# Do NOT set token_no_default_policy=true (kv-v2 preflight needs the default policy).
# ha-config-sync's password (mint bootstrap)
path "homelab/data/infrastructure/secret-rotators-bootstrap/ha-config-sync" {
capabilities = ["read"]
}
# the rotated ha-config-sync PAT
path "homelab/data/infrastructure/ha-config-sync" {
capabilities = ["read", "patch"]
}
@@ -0,0 +1,89 @@
---
# Rotate the `ha-config-sync` Gitea PAT (write:repository). ha-config-sync is a
# write collaborator on the homeassistant repo; the config-sync sidecar
# (apps/home-assistant) pushes the `live` branch and opens the live -> main PR
# with it. ESO syncs the token into Secret home-assistant-config-sync-gitea.
#
# `rotate-mint` — secret-rotator gitea-token: mint the PAT, verify it can pull
# AND push homeassistant, store it at infrastructure/ha-config-sync#token,
# reap old tokens keeping the two newest.
#
# Policy/role rotator-ha-config-sync and the bootstraps are provisioned by hand —
# see policies/rotator-ha-config-sync.hcl. Netpol halves:
# gitea-allow-ha-config-sync-rotator, rotator-ha-config-sync-egress.
apiVersion: v1
kind: ServiceAccount
metadata:
name: ha-config-sync-secret-rotator
namespace: openbao
labels:
app.kubernetes.io/name: ha-config-sync
app.kubernetes.io/component: secret-rotator
---
apiVersion: batch/v1
kind: CronJob
metadata:
name: ha-config-sync-secret-rotate
namespace: openbao
labels:
app.kubernetes.io/name: ha-config-sync
app.kubernetes.io/component: secret-rotator
spec:
suspend: false
schedule: "30 4 1 1,4,7,10 *" # quarterly, Europe/Berlin
timeZone: Europe/Berlin
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 2
failedJobsHistoryLimit: 5
startingDeadlineSeconds: 3600
jobTemplate:
spec:
backoffLimit: 1
activeDeadlineSeconds: 300
ttlSecondsAfterFinished: 259200 # 3 d
template:
metadata:
labels:
app.kubernetes.io/name: ha-config-sync
app.kubernetes.io/component: secret-rotator
spec:
serviceAccountName: ha-config-sync-secret-rotator
restartPolicy: Never
securityContext:
runAsNonRoot: true
runAsUser: 65534
runAsGroup: 65534
seccompProfile:
type: RuntimeDefault
containers:
- name: rotate-mint
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-07-30
args: ["gitea-token"]
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
env:
- name: BAO_ADDR
value: http://openbao.openbao.svc.cluster.local:8200
- name: BAO_ROLE
value: rotator-ha-config-sync
- name: BOOTSTRAP_PATH
value: homelab/infrastructure/secret-rotators-bootstrap/ha-config-sync
- name: BAO_PATH
value: homelab/infrastructure/ha-config-sync
- name: GITEA_URL
value: http://gitea.gitea.svc.cluster.local:3000
- name: GITEA_VERIFY_REPO
value: konairius/homeassistant
- name: GITEA_SCOPES
value: write:repository
- name: GITEA_TOKEN_NAME_PREFIX
value: ha-config-sync-rotated
resources:
requests:
cpu: 25m
memory: 16Mi
limits:
memory: 48Mi
@@ -45,6 +45,7 @@ resources:
- external-dns.yaml
- flux-git.yaml
- home-assistant.yaml
- ha-config-sync.yaml
- registry-bot.yaml
- review-bot.yaml
- claude-oauth.yaml
@@ -1,5 +1,5 @@
---
# Rotate the `registry-bot` Gitea PAT (scope write:package) and sync it into the
# Rotate the `registry-bot` Gitea PAT (write:package, read:repository) and sync it into the
# konairius org's Woodpecker secret `registry_bot_token`. registry-bot is in the
# konairius team `publishers` (packages write, code read); every konairius release
# pipeline publishes images to gitea.renner.uno/konairius/* with it.
@@ -81,8 +81,9 @@ spec:
# Private konairius repo the publishers team can read.
- name: GITEA_VERIFY_REPO
value: konairius/vsftpd
# read:repository: the pull check needs it.
- name: GITEA_SCOPES
value: write:package
value: write:package,read:repository
- name: GITEA_REQUIRE_PUSH
value: "false"
- name: GITEA_TOKEN_NAME_PREFIX
+2 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"local>k.renner/renovate-config"
"local>konairius/renovate-config"
],
"enabledManagers": [
"flux",
@@ -45,7 +45,7 @@
"ghcr.io/usetrmnl/terminus",
"ghcr.io/usetrmnl/trmnl-ha-amd64",
"gitea.renner.uno/k.renner/shelly-manager",
"gitea.renner.uno/k.renner/vsftpd",
"gitea.renner.uno/konairius/vsftpd",
"gitea.renner.uno/konairius/wiki",
"gotenberg/gotenberg",
"lscr.io/linuxserver/lidarr",