Case A sampled at 1h; with the 5m lookback the series were absent for most
of each hour and the pending alert reset, so the projection never fired.
Now 5m samples (1m exceeds promtool's sample limit on the 7d rate), same
0.04272 %/day and 140-day summary.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The owner decided EPHEMERAL (/var) drives run to failure (REN-420), so
NvmeDriveWearProjectedExhaustion and NvmeDriveWearCritical now skip the
drive carrying /var. The drive is resolved live by joining node-exporter's
/var mount onto smartctl-exporter's (node, device) through a recording rule;
if the join stops matching, the alerts return rather than go quiet. Spare,
critical-warning and media-error alerts still cover EPHEMERAL drives.
Adds a promtool suite and four negative controls for the exclusion.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Five ServiceAccounts agent-{atlas,iris,hestia,argus,hermes} in paperclip
with empty service-account-token Secrets. The four readers get the same
rights they hold today via the shared pod SA: `view` cluster-wide and the
existing monitoring/paperclip-prometheus-read Role. Hermes and the shared
SA get `get` on the five named token Secrets only.
CiliumNetworkPolicy paperclip-renner/paperclip-agents-kube-apiserver lets
the five agents' pods reach the kube-apiserver entity on 6443.
Spec: REN-412. Nothing restarts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
gitea.renner.uno resolves to the Traefik LB VIP; Cilium DNATs it to a Traefik
pod before egress policy, so the plugin's toFQDNs allow never matches.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adapter environment tests sync managed config into the pod before any
workspace is realized, so the lease has no remoteCwd and the Claude test
fails with claude_managed_config_dir_failed. #13733 fixes only the run
path. #14769 (open upstream, head 10dc7371) makes the Kubernetes plugin
set remoteCwd=/workspace on acquire and keep a recorded one on resume.
The plugin build from REN-405 is unchanged, so dist/ carries the fix.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Upstream's production target ships the plugin source without dist/, so the
plugin loader reports paperclip.kubernetes-sandbox-provider as "no longer
exposes a Paperclip manifest". prepare.sh now adds upstream's cloud-plugins
commands for kubernetes to the build stage, after the server build, and fails
the build if dist/manifest.js is missing. The #13733 patch is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Pipeline 11717 build-paperclip exited 137 at the 8Gi memory limit during
the server build. Disable compressed caching so kaniko does not keep
compressed layer tarballs in memory.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
k8s sandbox runs fail because the run path never sets the lease remoteCwd
(paperclipai/paperclip#13864). The fix, paperclipai/paperclip#13733, is not
merged upstream yet. Build our own image from the master commit the nightly
runs now plus that patch, and point Flux image automation at it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Koda (Garmin Connect IQ) calls POST /auth/login during sync. Only /rest
and /share bypassed the navidrome-gate, so the gate answered 302 with an
empty body and the watch failed with -400
INVALID_HTTP_BODY_IN_NETWORK_RESPONSE ("Native login failed -400").
An IngressRoute sends /auth and /api straight to Navidrome when the
request carries no authentik_proxy_ session cookie; requests with the
cookie (the web UI) keep going through the gate, so SSO is unchanged.
X-Authentik-Username is stripped on this route as on /rest.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Owner-approved on REN-350. Only the Lidarr pod, only 8686; nothing else in
navidrome is admitted. Lidarr uses AUTH__METHOD External, so this gives
agents in the paperclip pod full Lidarr access.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review 1258: the 74 written albums are re-imported, not duplicate-skipped.
Fix the comment and report before/after mb_albumid per album.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two dry runs were checked; the spot check now matches original releases.
Music mount becomes writable for tag writes; move/copy/delete stay off.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The first dry run (write: no) matched 78 of 560 albums; some matched
reissues (JP/RU) and would have changed year to the reissue year.
Set original_date and preferred countries/media/original_year, and
drop the dry-run beets DB once so the next run re-matches. Still write: no.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Nightly beets import over navidrome-music-bulk, in place: no move, copy,
rename or delete. First phase is write: no with fetchart/embedart off;
the job log prints matched/skipped counts and a beet write -p preview.
Renovate rule for the LinuxServer -ls<n> tag.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
C.1 (CNPG ha-pg) and C.2 (Velero /config) passed; results are on REN-339.
Removing the Flux Kustomization lets prune delete namespace ha-restore-test
with the recovered cluster, the Jobs, the Velero Restore and the restored PVC.
Also drops ha-restore-test from the OpenBao ClusterSecretStore allowlist.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The operator will not roll ha-pg-restore-1 after #1063; it waits on an
in-place restart the stopped instance manager cannot answer. A new name
makes Flux prune the old Cluster (pod + ephemeral PVC) and create a fresh
one. The check Job is renamed too (Job templates are immutable).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The instance manager refuses to start PostgreSQL when the Cluster references
a plugin (externalClusters) that has no sidecar in the pod. Add a plugins
entry with isWALArchiver: false and a distinct serverName.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The seed initContainer was removed in 21a0a43f; Velero home-assistant-daily is
now the only /config restore path. home-assistant-backup-password has had no
consumer since then.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Recovers ha-pg from Barman into ha-pg-restore (no WAL archiver) and restores
the home-assistant-daily Velero backup into namespace ha-restore-test. Two
Jobs log the runbook C.1/C.2 checks. The /config probe is deny-all and never
runs Home Assistant. Nothing in namespace home-assistant changes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- gitea.renner.uno/**: pinDigests false. Our images are pinned by dated
tag; registry GC deletes their digests (no-digest-pins, homelab #1053).
- The weekly non-major group becomes: one PR each for gitea, authentik,
influxdb, openbao and multus/CNI (migrations or CNI, a revert does not
undo them); one for kubernetes/apps/home-assistant/** (HA-impact review,
02:00-05:00 window); one CI-only for .woodpecker/** and ci/images/**.
Everything else stays in "all non-major dependencies".
- openbao chart: rangeStrategy bump. The default rewrote the bounded range
">=0.28.4 <0.29.0" to "<0.30.0" (homelab #1054) and dropped the lower
bound; bump gives ">=0.29.6 <0.30.0".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The slim Renovate image installs tools (golang for go.sum, helm) at run
time into /opt/containerbase, which it ships as root:root 0775 for its
user ubuntu (uid 12021, gid 0). The job ran with gid 12021 and a
read-only root, so every install failed ("Install tool golang failed",
ical-proxy #12) and go.sum was never updated.
Run with gid 0 and a writable root file system. The user stays
non-root, capabilities stay dropped, no API token is mounted.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Owner decision on REN-107: option B, 30 days on the existing disk.
retention 90d -> 30d, retentionSize 30GB -> 40GiB, PVC unchanged (50Gi).
At ~1 GB/day the 30GiB cap bound first and the real window was a
20.5-27.2 day saw-tooth; 30d peaks near 38 GB, under the 40GiB backstop.
PrometheusRetentionNotInForce now divides the 7d store peak by the cap in
force NOW, not the cap at the time of the peak, so it clears on the day
the fix lands instead of firing for up to 7 days on the old cap's peak.
Adds tests/prometheus-retention_test.yaml (5 scenarios) and 5
`retention-*` mutations, all caught.
Restarts Prometheus once (StatefulSet args change): merge 02:00-05:00
Europe/Berlin.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`retention: 90d` and `retentionSize: 30GB` are both set on kube-prometheus-stack.
Prometheus honours whichever binds first, and here it is the size cap — so the
time setting is decoration. Measured on the live TSDB through the read-only
proxy grant, 2026-09-26:
storage.tsdb.retention.size = 30GiB -> 32,212,254,720 bytes
storage.tsdb.retention.time = 90d -> 7,776,000 s
window actually held -> 2,291,239 s (26.5 days, 29% of 90d)
Size eviction is not pending — it has been running on a ~7-day cycle for at
least as long as the TSDB keeps history. At 2026-09-19 23:00->23:30 the store
went 28.16 GB -> 21.14 GB, `prometheus_tsdb_size_retentions_total` went 0 -> 1,
and the window held collapsed 27.21 -> 20.48 days. Effective retention is a
20.5-27.2 day saw-tooth. Nothing reported any of it: none of the 182 alert
rules already loaded touch retention.
REN-107 was filed on a reading of `prometheus_tsdb_size_retentions_total == 0`,
concluding the cap was ~3 days away from binding. That counter is per-process
and resets on restart; this Prometheus has had seven distinct pod IPs in 26
days, and the reading was taken on a pod started 5 days earlier. So this rule
does not read it — it compares the window on disk against the limits the
running process reports, which are true regardless of process age.
No hardcoded 90d or 30GB: both sides come from
`prometheus_tsdb_retention_limit_seconds` / `_bytes`, so editing the HelmRelease
cannot leave this rule stale.
Two clauses. The window is <90% of configured time retention, AND the store
reached 80% of the size cap within the last 7 days. The second is a guard, not
decoration: clause 1 alone fires on a legitimately young TSDB on a fresh PVC.
`max_over_time(...[7d:1h])` spans a full saw-tooth so the alert does not resolve
and re-fire twice a week on an unchanging condition, and the expression is fully
aggregated so that lookback survives the pod restarts above.
Verified against the live TSDB, not just linted — the expression as js-yaml
parses it out of this manifest returns one series with $value 2,291,414 s
(26.52 days). Counterfactuals also run live: it goes silent for a 100GiB cap
(90d intent), for `retention: 21d`, for 30d+40GiB, and for a short window on a
store far from its cap (the fresh-PVC false positive the guard exists to stop).
Scope is the alert only. This does NOT change `retention`, `retentionSize` or
the PVC — which number is the real intent is an owner call on storage cost vs
history, and changing either restarts Prometheus and gaps every series. That
decision is on REN-107 for Zeus; the config change is a separate PR.
Blast radius: a PrometheusRule is additive and loaded by the operator's config
reloader. No workload restarts, no Prometheus restart, no scrape or ingest
change, no existing rule touched, no ingress or DNS change. Worst case is a
malformed rule, which surfaces as PrometheusRuleFailures — already alerted —
and affects only this rule's evaluation.
The alert fires immediately on merge, because the condition is true today. It
clears within one evaluation once the retention config is made honest.
Revert: revert this one commit. The rule is deleted and nothing else changes.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The blackbox-ren28-negative Probe existed only to take the REN-140 §7
before/after pair across REN-28 PR 2b (#1044). The "after" reading was
taken at merge (27b505e6, 2026-09-28 00:09-00:11Z): all four targets
probe_success 0 at probe_duration_seconds ~5 s, collateral still 1.
Delete the Probe, its kustomization entry, and the now-dangling mention
of the job in the CNPNegativeControlReachable comment. gotenberg:3000
stays covered by the permanent blackbox-negative-control job.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
npm install -g ignored Paperclip's pnpm override and pulled
claude-agent-sdk 0.3.257. Install from a package.json with the override
(0.3.280) into /opt/claude-agent-acp and link the bin into /usr/local/bin.
The build fails if the SDK version is not 0.3.280.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The k8s-go adapter test failed with 'Claude ACP server command is not
available: claude-agent-acp'. Both agent-runtime images now install
@agentclientprotocol/claude-agent-acp@0.73.0, the version Paperclip pins,
with Paperclip's patch for it applied (vendored in agent-runtime-common/).
go and gofmt are also linked into /usr/local/bin, so every tool is found
with the default PATH, not only with the image ENV. The build checks that
with env -i. Kaniko context is now ci/images so both images can COPY the
shared patch.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Paperclip Kubernetes sandbox plugin (0.1.0-alpha.1) hardcodes
agents.x-k8s.io/v1alpha1; v1.0.x serves only v1beta1, so the k8s-go
probe gets 404. v0.5.6 serves v1alpha1 (webhook conversion) and stores
v1beta1, the same storage version as v1.0.4.
- GitRepository tag and controller image -> v0.5.6 (digest pinned).
- Deployment: emptyDir for the self-signed webhook cert (read-only root).
- CiliumNetworkPolicy: allow kube-apiserver ingress (conversion webhook).
- Renovate: hold agent-sandbox below 1.0.0 until the plugin moves to v1beta1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main-926ead0 includes ical-proxy PR #7 (REN-230): the upstream response
body is capped at 2 MiB, so an oversized feed gets 413 instead of being
buffered whole into a 256Mi pod. It also includes PR #10: upstream fetch
failures are logged with the host and answered with 502 instead of 500.
Refs: REN-294
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On the #1046 merge (pipeline 11306) the build-agent-runtime step got no
pod: the full rebuild of build-ci-images.yml had filled the 24-CPU
woodpecker-ci-quota ("exceeded quota"), so the go image was never built.
Move the step to .woodpecker/build-agent-runtime.yml. It runs only when
ci/images/agent-runtime-*/** or its own file change, and can be re-run
alone with a deployment to target build-agent-runtime. Step content is
unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HelmRelease agent-sandbox is Stalled (InstallFailed): the chart's common
labels render app.kubernetes.io/version from image.tag (contains the
digest, >63 chars) and helm.sh/chart with Flux's +<rev> suffix. Remove
both labels in a kustomize postRenderer. Selector and pod labels are
unaffected.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The first push of this branch started all 13 build steps: the push trigger
had no branch filter. 13 pods at the 2-CPU default limit exceeded the
24-CPU woodpecker-ci-quota, so the PR checks and one build failed.
- push trigger: branch master only
- go and ops build in one step, one pod, at 1 CPU: a full rebuild is 23 CPU
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two pod images for the Paperclip Kubernetes sandbox (REN-284 step 2):
the stock agent-runtime-claude, pinned by digest, plus
- agent-runtime-go: Go 1.27.1, golangci-lint v2.14.0
- agent-runtime-ops: kubectl v1.36.5, flux v2.9.5, kustomize v5.8.1, helm 4.3.0
Tools are copied from their upstream images pinned by tag and digest, so
the existing Renovate dockerfile manager bumps them. Pushed to
konairius/agent-runtime-{go,ops}/agent-runtime-claude:v1 because the plugin
keeps that name and swaps only the registry prefix.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- infrastructure/agent-sandbox: Flux GitRepository (tag v1.0.4) + HelmRelease
of the upstream in-repo chart; image pinned by digest, restricted
securityContext, limits. CRDs via crds: CreateReplace.
- namespaces: agent-sandbox-system and paperclip-renner, both PSS restricted.
- network-policies: CNP for agent-sandbox-system (probes in, apiserver out).
- apps/paperclip/sandbox-rbac.yaml: Role + RoleBinding in paperclip-renner and
a create-namespaces ClusterRole, bound to paperclip-prometheus-read (the SA
the paperclip pod actually runs as; there is no SA `paperclip`).
- renovate.json: group the git tag and the image pin into one PR.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
default-deny-ingress in the paperless namespace admitted the whole
`database` and `monitoring` namespaces on every port, on
endpointSelector:{} — so on paperless's HTTP listener too, which trusts
X-Authentik-Username from anything that connects. Both admits are
removed and the three load-bearing flows are kept as per-workload
policies:
pprl-pg-allow-cnpg-operator database/cloudnative-pg -> pprl-pg :8000
pprl-pg-allow-prometheus monitoring/prometheus -> pprl-pg :9187
paperless-allow-blackbox monitoring/blackbox -> paperless :8000
No ICMP rule on them (REN-144 option (a)); the baseline ICMP and the
host/remote-node L3 admit are untouched.
Also, per plan rev 8 §5 and REN-148:
- CNPNegativeControlReachable, a second group in
prometheusrule-blackbox.yaml, scoped to job=blackbox-negative-control.
- Comment-only corrections of every citation of the removed
#database-admit / #monitoring-admit anchors (paperless
network-policy, probes-blackbox, gatus header, probes-ren28-negative),
plus the IPv4-fallback and scrapeTimeout notes on the
negative-control Probe (PR 2a review findings 1 and 2).
gatus ConfigMap data is byte-identical (sha256 unchanged); every changed
line in that file is a comment above `data:`.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CephPoolLostRedundancy (critical) joins three metrics on `pool_id` and filters
on ceph_pool_metadata{type="replicated"}. If an upgrade or relabel renames the
label, the value, or the join key, the rule returns nothing and goes dark
silently. CephPoolLostRedundancyBlind is the same join with the thresholds
dropped, wrapped in absent(): warning, for: 1h to ride out mgr failovers.
Its six scenarios are a promtool suite at monitoring-rules/tests/ceph_test.yaml,
with five negative controls in mutations.txt, run by the existing `promtool`
step (#1026). The CI half of the original #989 is dropped: #1026 covers it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
New PrometheusRule home-assistant with four `for: 5m`, severity critical
alerts. Before this the only signal for any of them was the cluster-wide
KubePodNotReady (15m, warning).
- HomeAssistantDown: HA scrape target down or absent.
- HomeAssistantRecorderDBUnhealthy: ha-pg has no primary with Postgres up,
or fewer than spec.instances (2) instances with Postgres up.
- MosquittoDown: no Ready broker pod (bootstrap Job pods excluded).
- Zigbee2MQTTDownOrCoordinatorOffline: pod not Ready, or HA's
bridge_connection_state sensor reads 0.
Each alert keeps one label set across its failure modes, so one outage
is one alert with one `for` timer. Unit suite and five negative controls
added; scripts/ci/promtool-rules.sh passes locally with promtool 3.15.0.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Owner decision on REN-240 (protection rule 5):
- README.md: disruptive work only 02:00-05:00 Europe/Berlin; PRs touching
kubernetes/apps/home-assistant/ or cluster-wide disruptive changes need
an HA-impact review (Hestia; owner until she exists).
- kubernetes/RUNBOOK-home-assistant-image-rollback.md: pin the previous
HA tag in one commit (revert bump + drop the imagepolicy marker, so
image automation does not re-bump it).
Docs only. No manifest changes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Argus and Atlas need to verify that a cluster secret exists (for example
before reviewing or merging a PR that consumes it through an
ExternalSecret) without being able to read its value. The policy grants
read/list on homelab/metadata/* only: paths, versions and timestamps, no
data, no writes. Whole tree, per owner decision.
Identity is a userpass user (house precedent: claude-bot) whose password
is one Paperclip agent secret bound to both agents. Applied once by hand
with an admin token, like the other policies in this directory.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Bump the pinned ical-proxy image from main-4783abe to main-73b5d97.
The only change is konairius/ical-proxy #9 (REN-232): the server
WriteTimeout goes from 10s to 40s (30s upstream fetch timeout + 10s
headroom), so a slow-but-legitimate upstream is no longer cut off
before /proxy can write its response.
Refs: REN-239, REN-232
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Bump the pinned ical-proxy image from main-db90a59 to main-4783abe.
The new build adds two hardening changes from konairius/ical-proxy:
- #6: refuse upstreams and redirect hops that resolve to private,
loopback, link-local or CGNAT addresses. Public redirects are still
followed (up to 5).
- #8 (REN-231): cap concurrent /proxy upstream fetches at 20; requests
over the cap get 429 + Retry-After instead of piling up goroutines.
Refs: REN-238, REN-231
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ical-proxy moved from GitHub to Gitea and its image is now built by
Woodpecker and pushed to gitea.renner.uno/konairius/ical-proxy. The
ghcr.io image gets no new builds.
main-db90a59 has the same application code and Dockerfile as the
running main-99a912b; the commits in between touch only CI and docs.
The package pulls anonymously, so no imagePullSecret is added.
Revert: git revert this commit; Flux rolls the pods back to
ghcr.io/konairius/ical-proxy:main-99a912b.
Refs: REN-201
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of #1035: image-automation/ has 33 ImageRepositories, the
disable list had 32. Without this, Renovate and Flux would both bump
ghcr.io/matter-js/python-matter-server.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- apps/renovate: CronJob (daily 04:00, routine PRs Monday only via the
shared preset's schedule), runner config, renovate-bot token ExternalSecret.
- clusters/homelab/renovate.yaml: dedicated Flux Kustomization with the
ExternalSecret health check from uptime-monitor.yaml.
- infrastructure: `renovate` namespace, its CiliumNetworkPolicy (DNS,
Traefik, world:443; no ingress, no API server), and the namespace in the
openbao ClusterSecretStore allowlist.
- renovate.json: extends local>k.renner/renovate-config. Managers flux,
kubernetes, woodpecker, dockerfile. Skips the images Flux image automation
already bumps, and Flux's own gotk-components. Digest pins and
Cilium/Rook-Ceph minors get their own PRs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#1026 (REN-161) landed a promtool suite asserting CNPGWalArchiveBacklogSurge
and `for: 15m`, and mutations targeting `for: 8m`/`for: 15m`. This PR retires
the surge rule and moves the backlog rule to `> 5` for `20m`, so master CI
would break on merge.
- Generator: drop every Surge assertion; firing cases move to t=25m.
- New case 9: the worst night in Prometheus' 27-night retention (2026-09-11,
peak 141) as raw 30s samples, 12m pending at a 1m evaluation step.
- mutations.txt: surge-for-2m/-5m removed; backlog-for-12m added (caught only
by case 9, the margin control); backlog-for-30m retargeted at `for: 20m`.
- README and the woodpecker step comment updated to match.
Verified: scripts/ci/promtool-rules.sh with promtool 3.15.0 (CI's pin) —
18 manifests parsed, 2 suites green, 10 mutations caught;
scripts/ci/test-promtool-rules.sh passes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Retune under Zeus's ruling on REN-152: the instrument is persistence, not
depth. This was meant to land on #1010 before it merged; #1010 merged
first, so this is the same change applied to master. Still
observability-only text — no expr semantics change, nothing restarts, no
storage and no traffic path is touched.
- CNPGWalArchiveBacklog `for`: 15m -> 20m.
- CNPGWalArchiveBacklogSurge (>64 for 8m) removed.
Why the depth rule goes. The failure that costs something is an archiver
that has stopped, and a stopped archiver sits wherever it stopped — it can
park at 8 segments and never approach 64, let alone 150. A threshold tuned
above the nightly envelope is blind to exactly the case the rule exists
for, and has to be raised again every time the recorder grows. It also had
no room left: measured across Prometheus' full 27-night retention rather
than the 7 nights it was built on, >64 for 8m fires once, on 2026-09-11.
Why 20m and not 15m. Same 27-night replay, 30s resolution, all 12 CNPG
instances. Only ha-pg-1 ever exceeds 5; every other instance peaks at 2.
Longest contiguous excursion above 5 is 13.0 minutes, on 2026-09-11 at a
peak of 141. Firing evaluations: 0 at 20m and also 0 at 15m, but 15m
leaves 2 minutes of margin where 20m leaves 7, and the nightly peak moved
116 -> 141 inside the measured month. The 5 minutes of added latency buy
nothing back: a real stall lasts hours, and at 32Gi the volume holds about
four nights of stalled WAL.
The description now says outright that the 02:14Z (04:14 local) burst is
normal and self-clearing, so a page means the backlog did not clear. The
drain-rate and headroom pointers (wal.maxParallel, spec.storage.size,
REN-135) move up from the deleted rule rather than being lost.
Verified with promtool 3.5.0 at evaluation_interval 30s against the rule
file as committed: the real 2026-09-11 series is silent; a stall at 8
segments fires; a backlog filling to 100 and not draining fires; a backlog
crossing a failover still fires on time, which is what `max by (namespace,
pod)` is for. Negative controls bracket the value — at `for: 12m` the real
burst fires, at `for: 25m` the stall cases stop firing.
Revert: `git revert` this commit. That restores exactly what is on master
today, >5 for 15m plus the Surge rule.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The version range >=72.0.0 let Flux upgrade the chart unattended
(91.5.2 -> 91.7.0 at 19:43 UTC on 2026-09-26, no git commit). That
restart moved Prometheus onto k8s-1 while its Ceph RBD mounts were
broken, causing a ~48 min alerting outage (REN-198).
Pinned to the version already deployed, so reconcile is a no-op.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
promtool-rules.sh (REN-161, merged after this branch was opened) discovers
suites only as tests/*_test.yaml. At its old path the ESO suite would have
been skipped without a word. Moved, rule_files pointed at ../ for the
stripped mirror, the "inert until REN-96" header dropped, and five negative
controls added to mutations.txt: for 5m, sum-not-max, by-namespace,
no == 1, any condition. All five are caught.
promtool 3.15.0 (the CI pin): 18 manifests parsed, 2 suites green,
11 mutations caught. Rule file unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment-only. The open question on this rule was whether the Ready condition
gauge flaps in ordinary operation, which could not be answered until #994 had
been collecting for a while. It has now run 464 consecutive scrapes since
00:18Z: all 52 ExternalSecrets reconciled (959 syncs), the gauge never reached
1 in any scrape, the error counter did not move, and the scrape target never
went down. Recorded in the rule file with the queries that produced it.
Also drops a reference to prometheusrule-ceph-test.yaml, which is on the
unmerged REN-96 branch and not in this repo, so it read as an in-repo
precedent a reviewer could not find.
Non-comment lines are byte-identical to 92dbc0de in both files, so the
promtool 2.53.2 check-rules and test-rules runs recorded on the PR still hold.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Comment-only. The counts the rule's reasoning rests on are now measured on the
running Prometheus rather than derived from the repo: 105 series = 52
ExternalSecrets x 2 Ready polarities + 1 Deleted tombstone, across 20
namespaces, and the ExternalSecretSyncFailing expression returns empty today.
The last point is the one a reviewer wants: the rule arrives silent.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment-only, again: `diff` of all non-comment lines against bbaf0c6a is empty.
Answers the grouping objection raised in review. Aggregating by
`exported_namespace` leaves these alerts without a `namespace` label, so they
share one Alertmanager group — but `group_interval: 5m` on the root route
notifies a group whose alert set has CHANGED, which is not `repeat_interval:
12h` re-sending an unchanged one. A second failing ExternalSecret therefore
mails within 5m rather than being swallowed. Records the receiver this rule
routes to and why it misses all four child routes.
Also records that the three inhibit rules cannot suppress it: all key on
`equal: ['namespace', 'alertname']` and nothing shares this alertname.
OpenBaoQuorumLost in particular does not inhibit it, and being critical at 2m
arrives first anyway.
Refs REN-103
Comment-only. No rule, expression, `for:` window, label or annotation changes —
`diff` of all non-comment lines against the previous revision is empty, so
promtool's SUCCESS and the committed unit test still hold as run.
Argus's post-merge measurement on #994 raised the ESO sync-error counter as the
better primary signal, citing the 2026-09-20 `ai/wiki-embed-basic-auth`
incident as a case a Ready gauge would miss. Reading that incident back, it
argues the other way: the key needed a manual force-sync, so it never
self-healed and Ready=False was continuous for hours, which `for: 15m` catches
with a wide margin. It was a stuck condition, not a flap.
The measured counter data points the same way: `externalsecret_sync_calls_error`
has one series cluster-wide, `ai/wiki-anthropic` at 8 errors over 47 calls, on
an ExternalSecret that is Ready=True throughout — 8 counter events, zero sync
failures.
Also states plainly what `for: 15m` does not cover: a sync that fails and
recovers on its own inside the window, which is a deliberate choice because the
Secret ends up fresh and there is nothing for a human to do.
Refs REN-103
Merge B of REN-103. #994 made the series exist; this is the rule built on
them. Split on purpose — the `for:` window had to be chosen against real
data, and Prometheus held 0 series for this metric until #994 landed.
Two corrections to what REN-100 specified, both found only once there was
data or source to read:
* `exported_namespace`, not `namespace`. The metric carries its own
`namespace` label and the PodMonitor attaches a target label of the same
name, so Prometheus renames the metric's to `exported_namespace` and every
live series reads `namespace="external-secrets"`. REN-100's expression
still fires, and reports every failing ExternalSecret in the cluster as
living in `external-secrets`. Found by Argus against the live data.
* `max`, not `sum`. Both give 1 today on a single controller replica, but a
Deployment rollout is briefly scraped as two pods, where `sum(...) == 1`
evaluates to 2 and silently stops matching.
The condition gauge is the primary signal and `externalsecret_sync_calls_error`
is deliberately unused, which reverses the guess recorded on #994. The counter
increments on strictly more paths than the condition does — a failed Get of the
ExternalSecret, a failed Get or label Patch of the target Secret, an
unresolvable SecretStore — none of which call markAsFailed(). It is the noisier
signal, not the quieter one. The gauge does not flap: ESO sets it to 1 only in
markAsFailed() and clears it on the next successful reconcile, writing both
polarities every pass, so there is no unset state in between.
`for: 15m`, because a failed sync degrades freshness and not availability —
consumers keep the last Secret ESO wrote. Long enough to ride out a rolling
restart of the 3-replica OpenBao StatefulSet, and the real outage has its own
critical alert at 2m, so nothing is hidden by waiting.
Also adds a dead-man's-switch on `absent(externalsecret_status_condition)`.
Deleting the PodMonitor or reverting the `monitoring` ingress grant would
otherwise disarm the alert silently, which is the failure mode REN-103 exists
to end; TargetDown covers a target that is failing, not one that is absent.
Verified with promtool 2.53.2: `check rules` passes, and the committed
`-test.yaml` asserts the 15m window, the rendered namespace in the annotation,
the two-pod rollout case, that the `Deleted` tombstone never alerts, and that
the dead-man's-switch fires when the series vanish. Not yet evaluated against
the live Prometheus — this runner has no cluster access.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review #1114: 18Gi gave ~3.5 days of archiver outage, below the ~4.0 days
the shared 32Gi gives today. 21Gi gives ~4.1. The storage comment now says
WAL headroom moved to walStorage and 32Gi stays because PVCs cannot shrink.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Owner-approved option B on REN-176. Adds a separate ceph-block WAL volume
per instance and sets primaryUpdateMethod: switchover so the pod roll this
triggers promotes the rolled replica instead of restarting the primary in
place.
Permanent: the CNPG 1.30.1 webhook rejects removing walStorage, so a git
revert of this commit fails admission. Merge only in an owner-named window.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both pilot EndpointMonitors were created with an empty alert_contacts
list, so they notified nobody. Set uptimeRobotConfig.alertContacts to the
two contacts every hand-made monitor in the account already uses
(7880965 e-mail, 8026823 type 13, threshold 0, recurrence 0), in the
order the API returns them so IMC's string comparison stays stable.
Per the owner's ruling on REN-138 no test alert is fired; delivery is
owner-verified. Revert this commit to return to empty contacts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
backupPVC in node-agent-config is keyed by source storage class.
ceph-filesystem-bulk had no entry, so its data-mover took the full
CephFS subvolume clone path and sat Pending on "unbound immediate
PersistentVolumeClaims" (4 min audiobookshelf, 22 min immich on
2026-09-26, paging KubePodNotReady). Growth with data would hit the
data-mover prepare timeout and fail the backup.
- node-agent-config: ceph-filesystem-bulk -> readOnly: true with
storageClass ceph-filesystem-bulk-snapshot-readonly
- new StorageClass ceph-filesystem-bulk-snapshot-readonly:
backingSnapshot "true" (shallow RO volume, no clone), reclaimPolicy
Delete
- nodeAgent.podLabels revision label so node-agent restarts and loads
the config (node-agent only, not the server)
Refs REN-177.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agents outside the pod (Claude Code on the workstation, via
`paperclipai agent local-cli` / agent API keys) only make outbound REST
calls to /api/* with Authorization: Bearer. The Authentik gate on
paperclip.renner.uno rejects those (intercept_header_auth), so they get a
separate machine door, following the wiki-index pattern:
- paperclip-api.renner.uno (no dns.renner.uno/public label: LAN-only)
routes /api to the paperclip Service, bypassing the gate. Paperclip's
bearer agent/board keys are the authentication.
- /api/auth on that host is refused (Traefik ipAllowList matching no
address -> 403), so Better Auth password sign-in/sign-up stays behind
the gate; upstream serves all password login under /api/auth.
- The host is added to PAPERCLIP_ALLOWED_HOSTNAMES (private-hostname
guard would 403 it) and the CNP admits Traefik on 3100.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comments that pointed at other YAML as `file.yaml:N` went stale on any
edit above the cited line; three drift fixes had been merged by hand
(REN-181). The cited block now carries `# anchor: <name>` and the
comment cites `path.yaml#<name>`.
- Sweep: all 32 line-number citations in YAML comments converted.
Several were already stale at head and now point at the right block
(see PR). Vendored files (Flux gotk-components, IMC CRD) get no
anchor; those two citations name the object/schema path instead.
- Guard: scripts/ci/yaml-citations.sh in ci-manifests-test.yml; fails
on a line-number citation or a path#name whose file/anchor is gone.
Self-test in ci-scripts-test.yml.
- CONTRIBUTING.md: the convention in three lines.
Comment-only under kubernetes/: every file parses to identical objects
before and after, so Flux applies nothing and no ConfigMap data changes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Nothing in this repo checked an alert expression. ci-manifests-test.yml proved
every PrometheusRule assembled into valid YAML and stopped there — no check that
an expression parses, that a label join matches anything, or that a `for` window
behaves the way its comment claims.
On REN-129 the CNPG surge rule was written `for: 2m` and reviewed at that value.
Against Iris's seven measured nights it fires on six of them: two owner emails a
night, 04:16 and 04:22 local, for a WAL backlog that clears itself in under ten
minutes. The arithmetic that caught it was hand-written from scratch twice, in
two runs, and thrown away both times because there was nowhere in the repo to
commit it. It is now a negative control.
Adds:
* scripts/ci/promtool-rules.sh — discovers every PrometheusRule (17 today),
strips `spec:` into a temporary mirrored tree, runs `promtool check rules` on
each, then `promtool test rules` over the committed suites. Discovery floors
on both, as in the sibling steps.
* a `promtool` step in ci-manifests-test.yml, `depends_on: []` like the other
four, on prom/prometheus:v3.15.0. The 3.x pin is load-bearing: the cluster
runs kube-prometheus-stack >= 72.0.0, and alert `for` evaluation is
version-sensitive, so a 2.x promtool would check a different evaluator than
production. The script asserts the major version it is handed.
* kubernetes/apps/monitoring-rules/tests/ — the REN-129 suite (14 cases over
the seven measured nights, an adversarial envelope built from the extremes of
the measured fill/drain ranges, two stall shapes, a threshold boundary, a
failover, a multi-instance case and the epoch-zero guard), its generator, and
six negative controls in mutations.txt.
* a mutation phase. Each control breaks one value the rules depend on and the
suites must fail; a control whose sed matches nothing is a failure, not a
skip.
* scripts/ci/test-promtool-rules.sh, run from ci-scripts-test.yml — 17 cases
asserting the script refuses to report success when it has checked nothing.
The mutation phase is not belt-and-braces, it is the point. Two failure modes
bit while writing this and neither changed promtool's exit code: a `rule_files`
entry that did not resolve, where promtool WARNs, loads zero rules, and passes
every `exp_alerts: []` assertion against an empty rule set; and traces generated
against the wrong clock window, where all seven nights were flat zero. Both were
green. A suite that passes proves the rules match the assertions, not that the
assertions reach the rules.
Production: nothing applied to the cluster. No manifest changed, so the
monitoring-rules kustomization builds identically — its `resources:` list is
explicit, and the new tests/ directory is not in it and has no kustomization.yaml
of its own, so Flux does not see it. Worst case is a red step on PRs touching
kubernetes/**; enable_status_check is false on master, so it cannot block a merge
on its own.
Verified with real promtool 3.15.0 against the real tree: 17 manifests parsed
(56 rules), the suite green, all 6 mutations caught. Run again under BusyBox
1.35.0 sh/awk/sed/find/xargs, the toolchain prom/prometheus actually ships.
Guard tests 17/17 under both bash and BusyBox sh. shellcheck 0.11.0 clean at
every severity. woodpecker-cli 3.18.0 lint clean on all 17 pipeline files.
Not run in Woodpecker itself — this PR is its first real pipeline execution.
Revert: revert this commit. It adds files and one step; nothing else reads them.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ceph's mon_osd_nearfull_ratio is 0.85 and nothing reported the approach to
it, so the first signal would have been Ceph flagging nearfull — already the
incident. With failureDomain host, size 3 and three OSDs there is no
rebalance to hide behind: all three cross together.
Two warning rules, both observability only:
- CephOSDNearfullApproaching: fullest OSD sustained >= 75% for 30m. 0.75 is
0.85 minus the largest measured bulk burst (+10.24 pp in 25 min, 09-26
05:18-05:43Z), so it marks the point where one more such burst reaches
nearfull. 30m is the shortest wait that outlives a self-reclaiming burst
(that burst held >= 75% for 19.0 min) and short enough to fire before 0.85
at the fastest observed sustained climb (+11.44 pp/h).
- CephReplicapoolHeadroomLow: replicapool max_avail < 160 GiB for 30m.
160 = 89 GiB held above the 0.85 line + 38 GiB ha-pg has not yet consumed
of its approved 32Gi + 35 GiB observed replicapool churn.
Replayed against the 14d of real series to 2026-09-26 08:00Z: both fire zero
times. Worst 30m-sustained OSD floor 71.632%; lowest 30m-sustained
replicapool ceiling 208.97 GiB. Both expressions evaluated against live
Prometheus at the 09-26 05:40Z peak do select (3 OSDs at 81.87%,
replicapool at 117.41 GiB), so the 30m `for` is what keeps that excursion
quiet, not a join that never matches.
Thresholds are unmerged pending the owner's yes on REN-163.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Criterion 3 passed: the removal test (#1018, merged 101c11f4) deleted monitor
804092841 and the controller logged `Monitor Removed` at 03:57:58Z. This commit
is the deliberate revert of that test, which Zeus sequenced to run only after
the cap reading below. The restored object is byte-identical at the object level
to the version that provably created 804092841.
It also corrects a wrong claim this file asserted. The header stated that a
monitor-count cap was EXCLUDED as the cause of the `www.renner.uno` 403, on the
reasoning that a cap would arrive as HTTP 200 + `stat: fail`. The removal test
refuted that:
03:26:27-03:56:27 account 10/10, seven consecutive `Status Code: 403`
03:57:58 804092841 deleted, account 9
04:01:27 `Monitor Added: www.renner.uno-uptime-monitor`, id
804092931, account back to 10
One freed slot is the only variable. UptimeRobot returns HTTP 403 when the
account monitor limit is reached, so "account is full" is the first hypothesis
for an IMC 403, not a discarded one. Capacity is now settled on fact for
REN-158; REN-8's 18-monitor page against a cap of 10 stays blocked on it.
Expect this restored create to be refused with 403 until the tier is raised —
`www` took the freed slot. Left in git per the capacity ruling so the retry loop
creates it unaided once capacity exists. `audiobooks.renner.uno` stays covered
by its 60s Gatus check throughout, so there is no coverage gap.
Also recorded: the real delete log string is `Removing monitor … from provider`
/ `Monitor Removed`, not the `Deleting Monitor` this file predicted; and the
status page is in all-monitors mode, proven in both directions (9->10 on create,
10->9 on prune) with no API key, which settles REN-9's monitor-list mode.
Verified: js-yaml loadAll gives 2 EndpointMonitors in `uptime-monitor`; the only
functional changed line is the restored object, every other changed line is a
comment; restored spec compared equal to `f20fe8bf^`. Not applied — Flux applies
from master and I cannot merge my own work.
Revert: revert this commit. Flux prunes the object and, because its monitor does
not exist, nothing in UptimeRobot changes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#992's header comment says RecordDuration "IS called, by all six controllers",
established by grepping the tagged upstream source trees. Now that the scrape is
merged the claim is measurable, and five of six emit the family: 170 series from
kustomize (33), source (43), image-reflector (68), helm (25) and image-automation
(1), and zero from notification-controller.
Five is permanent, not today-only. notification-controller's three reconcilers
are registered but never walked -- controller_runtime_reconcile_total for
controller=alert/provider/receiver reads 0 over 56 h of uptime with 2 Alerts and
1 Provider live in the apiserver -- so the family is absent from its /metrics
entirely and creating a Flux Alert or Provider does not produce the sixth series.
Comment-only. Every one of the 17 changed lines is a pure comment line and the
comment-stripped file is byte-identical to master (same sha256), so the object
Flux applies is unchanged and no pod rolls.
Refs REN-127, REN-102, REN-97
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
probes-blackbox.yaml:209 cited apps/gatus/configmap.yaml:176-185 as the
place gatus documents the paperless blind spot. That was true at cb9e73bd,
where the sentence was written. A later +29-line insert into configmap.yaml
pushed the block down; at master those lines are now Web Service 9
(music.renner.uno), which carries no prose at all.
The same 10-line Paperless blind-spot comment now runs 205-214.
Comment-only. No spec, no pod template, no new resource.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Reviewed and merged by argus-bot (REN-145). Approved at head 088db00e.
Independently verified: the eight GVKs against the single served CRD version in Flux v2.9.5; every CRD field path the config reads; object census = 149 and the reconcile intervals behind the 2h/1h thresholds; the rendered chart keeps --resources and omits --custom-resource-state-only; the whole rendered ClusterRole uses only list+watch with no wildcard; the real kube-state-metrics v2.20.0 binary accepts the rendered config while a malformed control is rejected at startup; the kustomize CI gate green on the simulated merge result, which CI itself did not test.
Own promtool fixtures: 9 alert tests plus a 13.5-day recording-rule simulation, all passing. Warm-up guard empty at 1201 samples and populated at 1297; the pruning case yields 0 under the shipped comparator against 1.1376 under the withdrawn ratio; tag counting survives a kube-state-metrics pod roll.
The missing staleness alert is an accepted, measured reduction; follow-ups tracked in REN-143.
Revert: git revert -m 1 <this merge commit>. Both Kustomizations are prune: true, so nothing is orphaned.
Reviewed by argus-bot (four-eyes: authored by atlas-bot, merged by argus-bot).
Verified against the PR head 17c1dece:
- helmrelease.yaml diff is comments only; parsed YAML of HEAD^ and HEAD is byte-identical (1683 bytes each). No values change, so no helm upgrade and no cilium agent restart.
- ServiceMonitor selector {managed-by: homelab, name: cilium-agent} matches only the new Service; endpoint port `metrics` exists on it; targetLabels k8s-app=cilium present.
- Service selector k8s-app: cilium, headless, distinct name from the chart's cilium-agent Service - no double-scrape or ownership collision if a chart flag is flipped later.
- Revert is one step: the monitoring-rules Flux Kustomization has prune: true, so git revert removes the Service.
- ci-manifests-test and ci-scripts-test green; advisory review-bot: no blocking issues.
REN-156
Approved by argus-bot at head c1b9b259 after re-review (head moved from dd6720f2).
Ceph capacity gate cleared at 03:49Z: replicapool max_avail 168.8 GiB usable, per-OSD 76.1% -> 78.8% worst case against nearfull 0.85, HEALTH_OK.
Four eyes: authored by atlas-bot, merged by argus-bot.
PVCs do not shrink - manifest reverts with git revert <merge-sha> -m 1, volumes do not.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Approved at 7aff5250 (review 1086). CNPGWalArchiveBacklog for: 30m -> 15m; new CNPGWalArchiveBacklogSurge > 64 for 8m; both exprs wrapped in max by (namespace, pod) so a failover does not restart the for timer.
Verified with promtool 3.5.0: 12-case test rules suite over Iris's seven measured nights plus an adversarial envelope case, all passing, with five negative controls each failing. Revert tested: git revert restores the file byte-for-byte.
Closes REN-129.
Deletes the `audiobooks.renner.uno` EndpointMonitor so that the other half of
REN-7 can be proven: an EndpointMonitor creates an UptimeRobot monitor, and
deleting it removes that monitor again. "A monitor that cannot be removed is a
leak." The create side is already shown — monitor 804092841, created
2026-09-26 03:26:27, controller log `Monitor Added`.
What to observe after this merges, keyless and from either side:
* `https://uptime.renner.uno/api/getMonitorList/UgMXmP1iRV` goes from
`psp.totalMonitors: 10` to `9`, and id 804092841 is absent.
* controller log: `Deleting Monitor: audiobooks.renner.uno-uptime-monitor`.
Precondition, checked before writing this: v2.2.13 carries no finalizer, so a
delete while the controller is down is never reconciled and orphans the monitor
permanently. Pod ingressmonitorcontroller-5d577cc58-pgqwg was Ready=True with
0 restarts. Whoever merges this must re-check that the pod is Ready first.
Also records, in the file, why the second pilot object still has no monitor:
UptimeRobot answers its create with HTTP 403 and IMC retries every 300s
forever. 403 is the non-200 branch of `Add()`, so it is not the provider's
validation path — that returns 200 with `stat: fail` — which rules out the
duplicate-URL, monitor-cap and bad-parameter guesses. Tracked on REN-155; the
object is left in place as the reproducer.
Blast radius: removes one EndpointMonitor from `uptime-monitor` and one
UptimeRobot HTTP check every 300s. No Ingress, workload, ingress controller,
DNS record or RBAC rule is touched, and `www.renner.uno` is unchanged.
Revert: revert this commit. Flux re-applies the object and the controller
re-creates the monitor under the same friendly name — a new id, since the name
and not the id is what binds object to monitor. That round trip is also the
idempotent-reconcile check and is worth doing deliberately.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The sizing comment blamed the 10-minute archiving stall on Velero repository
maintenance. REN-136 disproves that over seven nights:
- sustained archiver drain is ~14 segments/min, flat whether Kopia is
running or not, so Kopia does not slow the drain;
- Kopia's nearest batch drifts across 02:03-02:24Z while the burst stays
pinned to 02:13Z, and a cause that drifts 20 minutes cannot produce an
effect fixed to the minute.
The real driver is a nightly producer burst: 56-119 segments queued at
02:13-02:15 UTC on all seven nights, against a steady-state queue of 0.
Comment now states that, and adds REN-136's stalled-night framing (6.36 GiB
free survives one stalled night, ~18.6 GiB carries about four).
Comment text only. `spec.storage.size: 32Gi` is unchanged and still the sole
semantic change on this branch — verified by parsing origin/master and HEAD
with js-yaml: with spec.storage.size masked the two parse to identical
objects.
Raised by Argus in review of #1014 as a non-blocking finding.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Reviewed at head 3b96b3ab, test-merged clean into master dd2256ea (tree af52adab).
Verified: one file +73/-0; 0 YAML parse failures repo-wide; jobName blackbox-negative-control unique; all four ports re-derived from the paperless Service/Cluster objects; tcp_connect module declared; paperless-ns.yaml still admits monitoring namespace-wide so the controls read 1 today.
Pages nobody: all 16 PrometheusRules enumerated, the only non-job-pinned probe_success rule (HetznerBackupEndpointDown) is instance-pinned to the Hetzner URL; the chart's up-based TargetDown cannot fire because blackbox answers 200 with probe_success 0 and the 5s module timeout stays inside the 10s scrapeTimeout; all 5 blackbox-overview.json panels are pinned to job=blackbox-http; no Grafana-managed alert references probe_success.
Monitoring-only, no traffic-path change. Revert with: git revert -m 1 <this-sha> on a branch, through a PR.
REN-28 §6.4 gate NOT yet taken - reviewer has no Prometheus credential either. PR 2b must not open until the probe_success == 1 reading exists.
The cilium chart renders templates/cilium-agent/service.yaml only when
prometheus.enabled AND (prometheus.serviceMonitor.enabled OR
prometheus.metricsService). infrastructure/cilium/helmrelease.yaml sets the
first and neither of the second, so no cilium-agent Service existed. The
standalone ServiceMonitor in apps/monitoring-rules selected a Service that was
never there: scrape pool serviceMonitor/kube-system/cilium-agent/0 sat at 0
active / 46 dropped targets and cilium_* had zero series.
Consequence: CiliumBPFMapPressureHigh and CiliumBPFMapFull read
cilium_bpf_map_pressure, so both evaluated an empty vector and could never
fire — the exact failure the helmrelease comment says the metrics were turned
on to prevent. cilium_policy_verdict_total and cilium_drop_count_total were
equally absent, which is why REN-140 needed a purpose-built prober to observe
a network-policy deny.
Declares a sibling metrics Service next to the ServiceMonitor instead of
setting prometheus.metricsService in the HelmRelease. The flag is the
chart-native fix and renders exactly this one object, but it is a values
change: helm-controller would upgrade the CNI release and three-way-merge
against live objects, and the configmap.reloader annotation turns any
resulting cilium-config change into a rolling restart of every agent. A
Service applied by the apps Kustomization cannot do that.
Naming follows authentik-outpost-servicemonitors.yaml: distinct name plus
app.kubernetes.io/managed-by: homelab, with the ServiceMonitor selector pinned
to it, so turning either chart flag on later neither collides on ownership nor
double-scrapes the same endpoints.
The helmrelease.yaml change is comment only — verified by rendering the chart
against the values block before and after: byte-identical, so Flux performs no
helm upgrade.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reviewed and merged by argus-bot (REN-149). Author atlas-bot; four-eyes split intact.
Verified live: no alerting rule can match job="blackbox-ren28-negative" (24 probe_/up/scrape_ rules enumerated; tcp_connect module timeout 5s < scrapeTimeout 10s keeps up==1); pod IPs 10.244.4.166/10.244.2.220 current; probeSelector strict so the release label is load-bearing and is set; monitoring has no egress enforcement.
Temporary. Delete probes-ren28-negative.yaml and its kustomization line once the REN-28 "after" reading is recorded.
Revert: git revert -m 1 <this sha>
REN-28 item 3a. Narrows the navidrome baseline CNP's whole-monitoring-namespace, every-port ingress admit to the blackbox-exporter pod on :4533 and :8686, and repoints two comments elsewhere in the tree that cited the replaced rule.
Reviewed by argus-bot at head 1c1a637c in a private detached worktree. ci-manifests-test and ci-scripts-test green at that head. Blocking review 1065 (a false ICMP justification) resolved by deletion in 1c1a637c.
Revert: git revert -m 1 <this sha>, through a PR.
ha-pg-1 queues WAL for archiving every night: flat 0 until 02:12Z, peak
02:14-02:15Z, drained by 02:22Z, peaks of 56-119 segments on the seven
nights measured 2026-09-19 to 2026-09-26. Point-in-time recovery for
home-assistant is degraded for the ~10 minutes each burst lasts. Nothing
fires, because CNPGWalArchiveBacklog's `for` was 30m.
The cause is a write burst, not IO contention. Drain was a flat 13.0-14.9
segments/min on all seven nights, including the night whose Kopia
repository-maintenance batch started only after the burst had drained;
that batch drifts across 02:03-02:24Z while the peak stays pinned to
02:15Z. Fill is what varies, 18.7-39.7 segments/min. 02:12Z is 04:12
local, when Home Assistant's recorder purge runs. An earlier revision of
this commit named Velero/Kopia as the cause; the 7-night data retracts
that, and the annotations no longer send anyone to the velero namespace.
Three changes, all alert-rule text. Nothing restarts, no storage and no
traffic path is touched.
- CNPGWalArchiveBacklog `for`: 30m -> 15m. This does not fire on the
nightly burst at either value, and should not: every burst in the week
drained itself, and the longest contiguous excursion above 5 was 10
minutes. 15m is for the archiver that has actually stopped, which is
the failure that fills the PVC and stops Postgres.
- New CNPGWalArchiveBacklogSurge, >64 segments (1 GiB) for 8m, warning.
Covers the shape the 15m rule cannot see: a working archiver outrun by
production. 8m because the measured envelope spends at most 5
consecutive minutes above 64 on any night, so 8m clears the worst
observed night by 3 minutes while still catching 1 GiB that is not
draining -- from 119, a healthy archiver is back under 64 inside 5
minutes.
- Both exprs wrapped in `max by (namespace, pod)`. `role` flips on
failover, a changed label is a new series, and a new series restarts
the `for` timer. On the raw series a backlog that persists through a
failover at minute 11 is silent at 16m and 20m and only pages by 25m.
Verified with promtool 3.6.0 against the measured traces, not applied to
the cluster: `check rules` passes, and unit tests cover the ordinary
night, the worst night, the quiet night, a stalled archiver, and a role
flip mid-backlog. Negative controls confirm the tests have teeth -- at
`for: 2m` the surge rule fires at 02:17Z with $value 99 on the ordinary
night, and without `max by` the role-flip case fails.
Revert: `git revert <this commit>` restores both rules exactly. No state
outside Prometheus's rule evaluation is affected either way.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds one Probe, `blackbox-negative-control-paperless`, with its own jobName
`blackbox-negative-control` and four tcp_connect targets in the `paperless`
namespace. The targets exist to be REFUSED: each must read probe_success == 1
at this commit and 0 once REN-28 PR 2b narrows the whole-namespace `monitoring`
admit in paperless-ns.yaml. That flip is the negative test, and it then stays
on as a standing regression alarm.
This is monitoring-only. No traffic-path change, no restart, no new grant, no
policy edit. blackbox-exporter already runs in `monitoring`, whose baseline CNP
is ingress-only, and default-deny.yaml is scoped `namespace: apps`, so the
paperless ingress policy is the only variable that can move these controls.
Deliberately silent: both blackbox alerts are scoped job="blackbox-http", so a
distinct jobName is outside them and four permanently-failing targets page
nobody after 2b. The inverted alert CNPNegativeControlReachable ships in PR 2b,
not here — between 2a and 2b these controls are supposed to read 1.
Appended at end of file so existing line-number citations (including
paperless-ns.yaml's reference to probes-blackbox.yaml:250) do not shift.
Refs REN-28 §5.6, §6.4. Revert: drop this one Probe document.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The `fromEntities: cluster` + `icmps` rule added earlier in this branch was
justified by a false premise. Its comment claimed the new `toPorts` was "the
first layer 4 ingress rule in this namespace" — at f177685d the ingress already
ended in an `icmps:` rule, which is itself a layer 4 rule. So the allow was
neither required by this change nor new with it.
It also did not match its own header ("ICMP from the host and the other nodes"
vs `fromEntities: cluster`), and for pods it was redundant: IPv4 type 3 and
IPv6 types 1 and 2 are already in the namespace-wide ICMP rule below it.
ha-pg-allow-cnpg-operator does not transfer as precedent: it is pod-scoped and
carries no `host`/`remote-node` allow, so `cluster` is the only thing admitting
the host there. Four peer baselines with exactly this shape — monitoring,
home-assistant, ai, external-secrets — carry no entity-scoped ICMP allow, and
monitoring is where blackbox-exporter and Prometheus themselves run.
The branch is now exactly the approved REN-28 §4.1 scope: the monitoring
namespace admit is narrowed to the blackbox-exporter pod on 4533/8686, and
nothing else in navidrome-ns.yaml changes.
Revert: `git revert` this commit restores the allow; reverting the whole PR
restores the namespace-wide monitoring admit.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Two of the four REN-28 §7 negative tests need a prober inside `monitoring`
that is not the workload under test. `pods/exec` in `monitoring` was declined
as a wider grant than the change it verifies, so this uses the prober the
cluster already runs: blackbox-exporter, driven by a Probe object, read back
through the existing read-only REN-36 Prometheus grant.
Adds one Probe, jobName `blackbox-ren28-negative`, module tcp_connect, four
targets that are reachable from `monitoring` TODAY and that the REN-28
paperless PR must close:
10.244.4.166:9187 / 10.244.2.220:9187 pprl-pg metrics port -> §7 test 4
pprl-pg-r.paperless.svc:5432 same, stable name
gotenberg.paperless.svc:3000 another paperless-ns workload -> §7 test 2
This is the "reachable before" half. It can only be taken while the current
policy is still in force, which is why it goes in ahead of the paperless PR.
No alert can match it: every rule in prometheusrule-blackbox.yaml is scoped to
job="blackbox-http", and TargetDown cannot fire because a Probe's scrape target
is blackbox-exporter itself, which answers 200 with probe_success 0 on a failed
probe.
No workload is touched, no restart, no policy change, no secret. Revert is
deleting the file and its kustomization line.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One semantic line: spec.storage.size 20Gi -> 32Gi. The sizing comment above it
is rewritten in the same commit because it justified 20Gi on table growth
("leaves room for autovacuum bloat and index churn"), which is no longer the
reason for the number.
The reason is WAL headroom during an archiving stall. 20Gi left 6.63 GiB free;
when WAL archiving stalled for 10 minutes under Velero repository maintenance
(REN-129) the un-archived backlog hit 1.84 GiB, 28% of that free space. Postgres
cannot recycle a WAL segment until the archiver ships it, so a longer stall
walks the volume toward full and a full PGDATA stops the recorder. 32Gi takes
free space to ~18.6 GiB and the same backlog to 10% of headroom.
Owner approved option A on REN-135 (apply now, do not gate on the capacity
read). Options B (archiver maxParallel test) and C (throttle Velero) were
declined, so ha-pg-objectstore.yaml and Velero are untouched.
Verified by parsing the file before and after: with spec.storage.size masked,
the two documents are byte-identical as parsed objects. instances: 2,
storageClass: ceph-block, all 8 postgresql.parameters and resources are
unchanged.
NOT verified here: `ceph df` for the replicapool pool. This runtime has no
kubectl and no Ceph read path — see the PR body.
Revert: `git revert <merge-sha> -m 1` restores the manifest, but the PVC
expansion itself is NOT reversible; PVCs do not shrink.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Per-object Flux readiness cannot come from the controllers: gotk_reconcile_
condition and gotk_suspend_status are declared by fluxcd/pkg/runtime and
populated by nothing at the pinned tags, so the REN-97 scrape yields 2,273
gotk_* series and none of them are about readiness. This turns on
kube-state-metrics custom-resource-state over eight Flux kinds instead, and
builds the standing not-Ready alerts on it.
The not-Ready half is complete. The Ready-but-frozen ImagePolicy half ships
as recording rules only: no threshold is derivable from the history that
exists today, and the measurements are in the rule file rather than left
implicit.
- kube-state-metrics customResourceState + rbac.extraRules (list, watch only)
- prometheusrule-flux.yaml: FluxImageAutomationNotReady (2h),
FluxResourceNotReady (1h), FluxImagePolicyMetricsMissing (absent, 30m),
and four recording rules for the staleness signal
- image-automation README: the "What watches this" section said both cases
were uncovered
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reviewed and merged by argus-bot (four-eyes: authored by atlas-bot).
Review 1063 on REN-137. Claims 1-3 re-derived from IMC v2.2.13 source and the
vendored CRD; host choice checked against the live status-page feed (9 baseline
monitors, neither target present). Revert: revert this merge commit; prune=true
plus enableMonitorDeletion=true returns the account to the 9-monitor baseline.
Recorded finding (non-blocking): UpTimeMonitorService.Add() returns nothing and
logs every failure, so a rejected newMonitor call is invisible in Kubernetes and
in Flux. A negative status-page result must not be read as "explicit-list mode"
for REN-9 without a controller-log read.
Implements the owner's decision on REN-52 (2026-09-25, option B), tracked by REN-111: one ClusterRoleBinding, built-in `view` ClusterRole, subject the paperclip/paperclip-prometheus-read ServiceAccount from REN-36.
Reviewed at head 230756c3 in a private worktree. Verified: exactly one object, no Secrets, no write verb, no new ServiceAccount; subject resolves against prometheus-read-rbac.yaml:48-52; name paperclip-cluster-view is unique in the repo. Flux CRD coverage confirmed in git — flux-view-flux-system carries aggregate-to-view: "true" (gotk-components.yaml:240) over all six toolkit groups, so the REN-52 pre-approved extra ClusterRole is correctly not added.
Revertibility confirmed rather than assumed: clusters/homelab/paperclip.yaml:14 is prune: true over ./kubernetes/apps/paperclip and the object has no prune: disabled annotation.
Revert: `git revert <this-merge-sha> -m 1`. Flux deletes the binding within the Kustomization's 10m interval and the token falls back to its REN-36 scope (get services/proxy on two named Services). No workload is touched either way.
Not verified here, owed on REN-111: the post-merge SelfSubjectAccessReview from inside the pod, proving the grant took effect and that view-aggregation actually covers the Flux CRDs in the live cluster.
Reviewed and merged by argus-bot (REN-134). CI green on 97b7f79b: ci-manifests-test, ci-scripts-test, pr-review.
One semantic line: OpenBaoUnavailable for: 1m -> 5m. Verified against the repo that 1m was the tightest probe window in the cluster (next tightest 5m), that probe and rule evaluation both run at 30s, and that severity=critical routes to the email receiver rather than a pager. Revert: git revert 97b7f79b.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
navidrome and lidarr both take identity from X-Authentik-Username
unconditionally (navidrome/deployment.yaml), so any pod that can open a TCP
connection to their listeners can claim any username. The baseline CNP
admitted the WHOLE monitoring namespace on EVERY port, which put every
current and future monitoring workload inside that boundary. Only two flows
actually cross it, and both are blackbox probes.
Exposure delta, monitoring -> navidrome namespace:
before: every pod in `monitoring`, all 4 workloads in `navidrome`, every port
after: blackbox-exporter only, :4533 (navidrome) and :8686 (lidarr)
Both selectors and both ports are measured on the live cluster, not inferred:
* `app.kubernetes.io/name: prometheus-blackbox-exporter` resolves to pod
blackbox-exporter-prometheus-blackbox-exporter-57844f6f8d-6gqz8 in
`monitoring` (Prometheus service-discovery pod labels, read through the
REN-36 read-only proxy grant). Chart prometheus-blackbox-exporter-11.19.1.
* `probe_success` is 1 for both
http://navidrome.navidrome.svc.cluster.local:80/ping and
http://lidarr.navidrome.svc.cluster.local:80/ping. Those Services select
`app.kubernetes.io/name: navidrome` / `lidarr` and would have no endpoints
if the pods did not carry the labels, so the probe path is proven end to
end, prober pod to app pod.
* Service :80 -> containerPort 4533 (deployment.yaml) and 8686
(lidarr.yaml). Cilium matches the POD port after service translation, so
`port: "80"` would read clean in review and break both probes.
ICMP rule added, and it is NOT cosmetic. The toPorts above is the first
layer 4 INGRESS rule in this namespace -- navidrome-ns.yaml is the only CNP
selecting these pods, and its ingress was L3-only until now. Cilium drops
ICMP to an endpoint selected by any layer 4 policy unless the traffic is
CT-related, and the existing ICMP allow matches pods only
(io.kubernetes.pod.namespace Exists), which `host` and `remote-node` do not
carry. The kubelet sources the HTTP probes from the host netns, so its PMTUD
path is host-sourced: without the new rule, Destination Unreachable / Packet
Too Big toward these pods are dropped silently and the symptom is a stalled
transfer, not a policy denial. Same rule, same three types, same reason as
ha-pg-allow-cnpg-operator in home-assistant-ns.yaml. This is an ADDITION to
the approved plan text, flagged here because it is mine, not the plan's.
Two citations elsewhere pointed at the rule being replaced and would have
become false -- the defect REN-28 is about, in reverse. Both now cite what a
rule admits rather than a line number, so they cannot go stale again:
* navidrome/deployment.yaml -- the union list of what really reaches the pod
* monitoring-rules/probes-blackbox.yaml -- the justification for why the
REN-21 probes needed no policy change
NOT touched: `host`/`remote-node` (deleting it CrashLoops both apps -- the
kubelet probes come from there), the in-namespace allow, the Traefik and both
gate-outpost allows, the existing ICMP rules, every egress rule, and
ND_EXTAUTH_TRUSTEDSOURCES (item 2 of REN-28, declined and documented in-tree).
No workload restart: a CNP change is picked up by Cilium in seconds.
Revert: `git revert -m 1 <merge-sha>` on master, through a PR like any other
change. Pure CNP + comment change -- no data migration, no restart, no
rollout; Flux reconciles and Cilium re-applies in seconds. Emergency path if
Flux is impaired:
kubectl apply -f <(git show <merge-sha>^:kubernetes/infrastructure/network-policies/navidrome-ns.yaml)
Detection that a revert is needed, inside one 30s scrape interval:
`probe_success == 0` on either navidrome target (alerting already exists in
prometheusrule-blackbox.yaml), or either pod leaving Ready.
REN-28 item 3a, approved by the owner. Paperless (item 3b) is a separate PR
and does not open until >=24h of green probes here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The first two of the survey's 14 page rows, and the first objects in this
repo that cause an UptimeRobot write. Both hosts are ungated, both serve a
real application response rather than a gate, and neither has a monitor in
the account today — so the pilot neither duplicates an existing monitor nor
puts anything on the public page that the owner has not already approved.
Uses `spec.url`, not `spec.urlFrom.ingressRef` as REN-7 was written: an
EndpointMonitor can only resolve an ingressRef in its OWN namespace
(pkg/kube/util/url.go:71), `IngressURLSource` has no namespace field, and
every app Ingress lives elsewhere. Reaching them would mean RBAC and a
widened WATCH_NAMESPACE in each production app namespace. Full reasoning in
the file header.
Deliberately omits `uptimeRobotConfig.statusPages`, so that whether these two
monitors appear on uptime.renner.uno on their own answers REN-9's open
question about the page's monitor-list mode.
Also records two source-verified properties that change how this must be
operated, both in comments: a Ready Kustomization cannot prove the monitor
was created (EndpointMonitorStatus is an empty struct, so kstatus returns
Current unconditionally), and v2.2.13 uses no finalizer, so deleting an
EndpointMonitor while the controller is down orphans its monitor.
Verified: kustomize build over all 68 kustomizations, 68 ok / 0 fail; both
rendered EndpointMonitors validated with kubeconform -strict against the
schema extracted from the vendored CRD, with a negative control confirming
the check rejects a bad enum. Lint/render only — NOT applied; Flux applies
from master and these objects do not exist in the cluster yet.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
OpenBaoUnavailable went pending at 01:07 UTC on a single 5s HTTPS probe
timeout while OpenBao was healthy, one evaluation short of paging a
critical. At 1m it was the tightest probe-based window in the cluster by
5x, and isolated single-sample probe blips are ambient on the
blackbox-http fleet at roughly one per two hours (three on three
unrelated targets in six hours).
5m matches WoodpeckerDown in the same group. Nothing real is lost: the
rule's own description says existing Secrets keep working and the damage
surfaces at the next ExternalSecret refresh, and OpenBaoQuorumLost
(for: 2m) still covers the fast-moving failure.
expr and severity are unchanged; this only moves the for: window and
replaces the now-wrong "two consecutive failed probes" comment.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment-only. The narrowing in 16c0c0a7 is unchanged; this records what the
two expressions do with the shape neither of them mentions — an ExternalSecret
with no `Ready` condition.
Review of REN-106 asked for the third state to be made explicit, on the
strength of a metrics census showing `ai/wiki-index-basic-auth` with only a
`Deleted=False` condition. That series is real; the object behind it is not.
ESO v2.11.0 builds the `Deleted` condition on a throwaway object in the
`IsNotFound` branch of Reconcile, purely to retire the object's metric series,
and never persists it — so the series outlives the object until the ESO pod
restarts. That ExternalSecret was retired from git in 419cbed3, its target
Secret is gone from `ai`, and its sync counter is frozen while its six live
siblings advance. On a live object ESO writes conditions of type `Ready` only.
What that leaves is three shapes, all decided: no conditions yet (in-progress,
bounded by `timeout: 5m`), `Ready=True` (current), `Ready=False` (failed for
the two non-retrying reasons, in-progress otherwise). Flux evaluates
inProgress -> failed -> current and falls back to in-progress, so an explicit
`inProgress` expression would be a no-op on all three; the reasoning is written
down instead of encoded twice.
Also noted: `Ready=True` covers reasons `SecretDeleted` and `SecretMissing`,
where ESO reports healthy with no Secret behind it. Unreachable under this
object's default Retain/Owner policies, and a trap for whoever changes them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reviewed as argus-bot (review 1056) at head 2821629e. Comment-only, reproduced independently in a private worktree: `git diff -U0` against merge-base 64ee49ab has no added or removed line that is not a whole-line `#` comment, and with comment lines stripped both files are md5-identical to master (crd f753baa9c9684002ad01d309e98755ba, rbac 092ddad9c003209d4bd27721c04048b1). Non-comment bytes identical means the parsed YAML is identical, so the applied objects cannot change: no apply, no RBAC change, no restart of the ingressmonitorcontroller pod.
Provenance re-derived from upstream, not taken from the PR body: 2deeba18 is the v2.2.13 tag commit (2026-05-06), aaffd8a8 is a 2-parent master merge commit (2026-09-25), the two chart CRDs are byte-identical, and the vendored body matches upstream at the tag (md5 f753baa9c9684002ad01d309e98755ba over `apiVersion:` to EOF) — so the new header is a check a human can actually run. All seven cmd/main.go + ingress-wrapper.go + url.go line citations in rbac.yaml verified at the tag.
Render check is CI's ci-manifests-test (green at 2821629e, with ci-scripts-test and pr-review; combined state success) — no kustomize in my sandbox either, so I am not claiming a local render.
Revert: `git revert <this-merge-sha> -m 1`. Nothing in the cluster to unwind.
Closes the two follow-ups recorded on the #995 merge (REN-6).
Reviewed and merged by argus-bot (REN-124). Comments only: js-yaml loadAll base vs head parsed-identical for all three files; gatus data["config.yaml"] byte- and hash-identical at 6572 bytes, so the gatus Deployment does not roll. All 7 repointed ranges resolved against the target files at 317ddbe9. Revert: git revert 317ddbe9.
Both found by Argus in the #995 review and handed to REN-6 as follow-ups.
Comments only — every added and removed line starts with `#`, and both files
are md5-identical to master once comment lines are stripped.
rbac.yaml misstated the failure mode of the omitted pods/services grant. It
claimed a denial "costs a log line, not a reconcile". The function's own error
branches do log and continue, but an RBAC denial never reaches them: the
reconciler holds `mgr.GetClient()` (cmd/main.go:168), so the first Service Get
starts an informer and blocks in cache sync on a `context.TODO()` that nothing
cancels. A forbidden list/watch means the Get never returns and the single
reconcile worker (default `--max-concurrent-reconciles=1`, cmd/main.go:84)
stops entirely, while `/readyz` — `healthz.Ping` — keeps the pod Ready. The
omission is still right (the ingressRef path cannot reach this namespace), but
the grant has to precede an Ingress landing here, not follow the breakage.
crd-endpointmonitors.yaml cited aaffd8a866aa09a77030979d938e766895bde5d4 as the
copy point. That is a master merge commit from 2026-09-25, four months after
the v2.2.13 tag the image is pinned to. Now cites the tag commit
2deeba188fbd2860d0b3a7c3590a954a1a709924 plus the md5 of the vendored body, so
the file can be checked against the version actually deployed. The content was
never wrong: that chart CRD is byte-identical between the tag and the commit
the old header named.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PR #997 inserted 9 comment lines into `paperless-ns.yaml` and 21 into
`navidrome/deployment.yaml`. The citations that #997 itself wrote were
renumbered; the citations elsewhere in the repo pointing *into* those two
files were not. REN-116 (merged as a8c48b54) then added one more line to
`paperless-ns.yaml`, shifting everything below it by one again.
Seven pointers repointed, all verified against this commit's tree:
probes-blackbox.yaml:156 navidrome/deployment.yaml 61-74 -> 80-93
probes-blackbox.yaml:218 paperless-ns.yaml 24-26 -> 34-36
probes-blackbox.yaml:221 paperless/network-policy 8-12 -> 22-26
gatus/configmap.yaml:47 paperless-ns.yaml 24-26 -> 34-36
paperless/network-policy.yaml:15 paperless-ns.yaml 30-32 -> 31-33
paperless/network-policy.yaml:16 paperless-ns.yaml 33-35 -> 34-36
paperless/network-policy.yaml:17 paperless-ns.yaml 36-38 -> 37-39
REN-119 specified 34-36 as `33-35` and listed only the first four rows.
Both were correct at `bebf9aad` but are stale after REN-116's one-line
insert, which is above every allow rule in the file. The last three rows
are the same defect from the same commit; leaving them would put the
file back in review for an identical fix.
`paperless-ns.yaml:9-11` (paperless namespace) is above the insert and is
unchanged — verified, not assumed. `navidrome/lidarr.yaml:114-127`,
`lidarr.yaml:29` and `probes-blackbox.yaml:250` were re-checked and are
still correct.
Comments only, and nothing is applied to the cluster. Verified per file:
`js-yaml` loadAll before and after yields identical structures (3 docs,
1 doc, 2 docs), and stripping comment/blank lines from both versions
gives identical text. `gatus/configmap.yaml:47` is in the header above
`data:` (line 62), outside the `config.yaml: |` block scalar, so
ConfigMap `data["config.yaml"]` stays byte-identical at 6572 bytes and
`configmap.reloader.stakater.com/reload: gatus-config` does not roll the
gatus Deployment. All seven edits substitute a range in place; line
counts are unchanged (312/283/97 before and after), so nothing cascades.
Revert: `git revert <this commit>`. Comments only, so a revert restores
the stale numbers and changes no cluster state.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The proxy-grant paragraph named `prometheus-read-rbac.yaml` and
`helmrelease-prometheus.yaml` by bare filename. Both are now repo-relative
paths, matching how the rest of this page cites manifests, so a 403 at 03:00
does not start with a grep.
Docs-only. No manifest changed. `docs/` is outside every Flux Kustomization
`spec.path` (all of them are under `./kubernetes/`), so Flux renders nothing
from this commit.
Follow-up to Argus's non-blocking note 2 on REN-113/REN-47.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reviewed-by: argus-bot <svc-argus@renner.uno>
Comments-only. Citation re-verified at head a8c48b54: line 250 of
kubernetes/apps/monitoring-rules/probes-blackbox.yaml is the quoted probe URL,
inside the blackbox-http Probe in namespace monitoring. Both versions of
paperless-ns.yaml parse to identical JSON across all three CiliumNetworkPolicy
documents, so re-applying is a no-op against the running policy.
Revert: git revert -m 1 <this commit>
Reviewed and merged by Argus (REN-105), review 1038 at c40c337e.
Delete-path bound re-derived from v2.2.13: GetByName exact FriendlyName equality against the default template {{.Name}}-{{.Namespace}}; GetAllByName and uptimerobot GetAll have no reachable callers. Ships zero EndpointMonitors, so the first reconcile makes no UptimeRobot call and the 9 existing monitors are untouched.
CI green at c40c337e (ci-manifests-test, ci-scripts-test, pr-review). Nothing applied to a cluster pre-merge; verification was lint/render only.
Open follow-ups for Atlas on REN-6, both comment-only: rbac.yaml misstates the failure mode for the omitted pods/services grant (a denial blocks the cached client in cache sync and wedges the single reconcile worker while the pod stays Ready, rather than costing a log line), and the CRD vendor header cites a post-tag master commit instead of the v2.2.13 tag.
Revert: git revert -m 1 of this merge commit.
`paperless-ns.yaml` cited the blackbox probe target as
`monitoring-rules/probes-blackbox.yaml:250`. From
`kubernetes/infrastructure/network-policies/`, that fragment reads as a
sibling `infrastructure/monitoring-rules/` directory, which does not
exist — `monitoring-rules/` lives only under `kubernetes/apps/`.
Use the full repo-root path, matching the style already used six lines
above for `kubernetes/apps/paperless/network-policy.yaml`. Line 250 and
the probe target itself were correct and are re-verified at this commit:
line 250 is
`- http://paperless.paperless.svc.cluster.local:80/accounts/login/`.
Comments only. Parsing the file before and after with js-yaml yields
identical structures for all three CiliumNetworkPolicy documents, and
stripping comment lines from both versions gives an identical diff. No
`spec` change, no new resource, nothing applied to the cluster.
Follow-up from the REN-110 review of PR #997 (merged as 0995e8d5).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous wording claimed home-assistant-ns.yaml was a second
port-restricted `monitoring` grant and that 17 others were namespace-wide.
Both numbers were wrong. Counted per ingress list item over
kubernetes/infrastructure/network-policies at master:
20 `monitoring` grants across 19 files
(influxdb-ns.yaml carries two — one whole-namespace, one Grafana-pod)
exactly one of them — this rule — carries a `toPorts`
home-assistant-ns.yaml looks port-scoped only because the `toPorts` that
follows its `monitoring` rule is indented under the next list item, the
`evcc` rule. Its `monitoring` grant is namespace-wide.
Comment-only. The parsed CiliumNetworkPolicy is byte-for-byte identical in
structure to master's.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Docs-only. One file, docs/superpowers/specs/2026-09-25-paperclip-design.md, +20/-2.
Closes the four documentation items on the REN-47 review bar (1/14, 5, 7, 15) for the
option-B apiserver-proxy grant, and drops the stale Prometheus-only resourceName text
that REN-95 left behind.
BLAST RADIUS: none. Every Flux Kustomization spec.path in this repo is under
./kubernetes/; docs/ is rendered by nothing. Flux applies nothing from this merge and
no workload restarts. No owner-facing surface changes.
REVERT: git revert %(SHA)s -m 1
One commit, one file, no cluster state to unwind. Nothing to re-apply, no token to
rotate, no pod to restart.
Reviewed by argus-bot (review 1031) against manifests, not against the PR body:
Role is one rule, verbs [get], resources [services/proxy], both resourceNames match
prometheus-read-rbac.yaml on master; chart float >=72.0.0 confirmed in
helmrelease-prometheus.yaml. Authored by atlas-bot, merged by argus-bot.
Implements the owner's decision recorded on REN-52 (2026-09-25, option B),
which was never shipped: one ClusterRoleBinding, roleRef the built-in `view`
ClusterRole, subject the ServiceAccount REN-36 created. No new
ServiceAccount, no new ClusterRole, no write verb, no Secrets.
Kept in its own file rather than appended to prometheus-read-rbac.yaml so it
reverts alone, and because that file's header argues for staying narrow —
which this grant is not. Both of that file's now-false claims are corrected
in place: the token is no longer worth only what its Role grants, and the
`paperclip-prometheus-read` name no longer describes it (REN-52 ruled the
name stays, since `serviceAccountName` is in the pod template and renaming it
would restart the Deployment and evict running agents).
No extra Flux CRD ClusterRole: `flux-view-flux-system` already carries
`rbac.authorization.k8s.io/aggregate-to-view: "true"` with get/list/watch on
`*` across all six Flux API groups, so `view` picks them up by aggregation.
REN-52 pre-approved adding one only if that were absent.
Additive RBAC on an existing ServiceAccount: no Pod restart, no remount, no
traffic path changed. RBAC is evaluated per request, so it takes effect on
the already-mounted token.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stakater IngressMonitorController in its own namespace, with zero
EndpointMonitor resources, so it creates nothing on arrival.
- Vendored EndpointMonitor CRD from chart 2.2.13, image pinned to v2.2.13
and deliberately kept out of Flux image automation.
- Namespaced Role only (no ClusterRole): WATCH_NAMESPACE scopes the
manager's cache to uptime-monitor. Verbs derived from what v2.2.13
actually calls; every omission is justified inline in rbac.yaml.
- The ExternalSecret now renders IMC's config.yaml, because IMC reads its
whole configuration from one Secret key rather than an env var. Config
stays in git; only the key is injected.
- Per-namespace CiliumNetworkPolicy, copied from the netbird-service-sync
neighbour: kubelet probes in, DNS + kube-apiserver + world:443 out.
- The Flux Kustomization keeps the healthCheckExprs gate and the
`wait: true` correction that #993 already merged, byte for byte. What
this commit adds to that file is only what is about REN-6: the
EndpointMonitor-vs-annotation mechanism in the header, and why
Deployment health is not a proxy for a current key now that the
controller shares the Kustomization with the ExternalSecret.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Argus's review bar for the option-B proxy grant (REN-47) asks for three notes on
the hand-over page. None of them was ever written down, and the page had also
gone stale: it still named only the Prometheus resourceName after REN-95 added
Alertmanager.
Added, next to `automountServiceAccountToken: true` where the grant is already
described:
- Both granted resourceNames, not just Prometheus (bar item 1/14).
- The Role scopes the HTTP *method*, not the endpoint — `get` refuses
`POST /-/reload` in the authorizer, but every GET endpoint of both services is
reachable, including `/api/v1/status/config` (bar item 5). Verified live this
run: the six `credentials` fields in that config render as `<secret>`.
- Callers must force GET; Prometheus accepts POST on `/api/v1/query` and that
form 403s under this Role (bar item 7).
- The drift defence: a 403 means check the Service name against `resourceNames`
first, because the chart floats at `>=72.0.0` and a rename breaks this path
silently (bar item 15).
BLAST RADIUS: none. One markdown file under docs/, not rendered by any
Kustomization, so Flux applies nothing and no workload restarts.
REVERT: revert this commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds a PodMonitor for the six Flux controllers in flux-system. Scrape only —
the not-Ready alert rules that were in the first revision of this branch are
removed, because they cannot work at the versions we run.
Why the rules are gone rather than merely deferred: `gotk_reconcile_condition`
and `gotk_suspend_status` are still declared by fluxcd/pkg/runtime v0.110.3
(runtime/metrics/recorder.go), but at the tags pinned in gotk-components.yaml
(source/kustomize v1.9.5, helm v1.6.4, notification v1.9.4, image-reflector and
image-automation v1.2.5) no controller calls RecordReadiness, RecordCondition
or RecordSuspend — zero call sites across all six tagged source trees, only
RecordDuration. An unpopulated GaugeVec exports no series, so those rules would
have evaluated against nothing for ever and the `absent()` dead-man's-switch
would have mailed admin@renner.uno every 12h from 30m after merge. Flux moved
per-object readiness to kube-state-metrics custom-resource-state
(`gotk_resource_info{ready=...}`), which is REN-102 — a prerequisite for the
alert, not a follow-up to it. Caught in review by argus-bot on #992.
What the scrape still buys on its own: `gotk_reconcile_duration_seconds` per
kind/name/namespace, controller-runtime's reconcile/error/workqueue series,
`up{job="monitoring/flux-controllers"}` under the chart's default TargetDown
rule, and the target REN-102 needs in place anyway.
PodMonitor and not ServiceMonitor is forced: four of the six controllers have
no Service, and the two that do publish the artifact/webhook port, not :8080.
No network policy change is needed and that was checked, not assumed —
gotk-components.yaml ships `allow-scraping` admitting every namespace to :8080
and flux-system has no CiliumNetworkPolicy. Nothing in the Flux install is
touched; Prometheus adds a read-only target.
Verified offline only: `kustomize build kubernetes/apps/monitoring-rules` clean
and the PodMonitor renders; all six Deployments carry both selector labels and
the `http-prom` container port. Not applied to the cluster — no kubeconfig in
this account's grants.
Refs REN-97, REN-102.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The healthCheckExprs block merged in #993 treated any `Ready=False` on the
ExternalSecret as Failed. The `flux-failures` Alert forwards every Kustomization
error event to Alertmanager and Flux health checks have no `for:` window, so a
single unlucky poll mailed the owner about something that was about to fix
itself.
REN-106 asked for `reason == 'SecretSyncedError'`, matching wordpress.yaml.
Checked against the ESO the cluster runs (v2.11.0) that is the wrong reason:
`SecretSyncedError` is the reason ESO sets when it returns an error and keeps
retrying — an OpenBao blip, a token renewal and a not-yet-populated key all
produce it. `SecretOwnedByOther` and `SecretImmutable` are the two reasons ESO
returns nil on, i.e. where it has stopped retrying.
So `failed` now names those two, and `SecretSyncedError` falls through to
in-progress and fails the Kustomization at the existing `timeout: 5m`, which
becomes the missing `for:` window. Detection of a genuinely stuck sync moves
from immediate to ~5m; a transient blip no longer reaches the owner at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment-only. No manifest field changes, so `kustomize build` output is
byte-identical before and after.
Two gaps in what REN-103's merged scrape says about itself, both raised in
review of PR #994/#996:
* The metrics endpoint is plain HTTP with no authentication. That is chart
2.11.0's default (`metrics.listen.secure.enabled` and
`metrics.listen.auth.enabled` both false, values.yaml:365-369) and this
repo overrides neither, so what actually bounds the exposure is the
CiliumNetworkPolicy, not the endpoint. A reader adding `tlsConfig` to the
PodMonitor would have broken the scrape; say so in the file.
* The network policy comment implied the grant reaches the controller. It
does not: the CNP's `endpointSelector` is {}, so monitoring→:8080 is
admitted for all three ESO deployments, which all serve metrics on that
port. Only the controller is scraped, because the PodMonitor's selector is
exact on the unsuffixed name. Reachable-but-unvisited is the accurate
description and the one a future reader needs.
Also corrected two counts in the same comment. The port-scoping is not unique
in this directory — home-assistant-ns.yaml does it too, so 17 of the other 18
`monitoring` grants are namespace-wide, not all 18. And there is no single
"health port": the controller's is 8082, the webhook's and cert-controller's
are 8081 (values.yaml:501-504, 564-570, 882-885).
Revert: `git revert` this commit. It restores the previous comment text and
touches no applied field.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comments only. No `spec` change, no pod template change, no rendered
manifest change.
Three comments told the reader the ingress side of paperless and navidrome
is locked to the Authentik gate outposts. It is not. `default-deny-ingress`
for both namespaces uses `endpointSelector: {}`, so it selects the app pods
too, and Cilium unions the allow rules of every CNP selecting an endpoint.
The effective ingress set is therefore the outpost PLUS whole namespaces:
`database` and `monitoring` for paperless, and `monitoring` plus the
lidarr-gate outpost for navidrome. Because both apps trust
X-Authentik-Username unconditionally, any pod in those namespaces can claim
any username.
- kubernetes/apps/paperless/network-policy.yaml: replace "Lock the ingress
side to the gate outpost only" with what is actually enforced, citing the
rules in paperless-ns.yaml.
- kubernetes/infrastructure/network-policies/paperless-ns.yaml: note that
the database and monitoring allows below do broaden the trust boundary in
exactly the way the traefik note above warns about, and why they are kept
(CNPG, and the blackbox probe of the paperless login page).
- kubernetes/apps/navidrome/deployment.yaml: ND_EXTAUTH_TRUSTEDSOURCES is
the whole pod CIDR and is not a narrowing; record why it stays that way
(the outpost pod IP is not stable) and list what the CNP really admits.
Verification: `kubectl kustomize` (v1.31.0 / kustomize v5.4.2) over all 68
kustomization roots in the repo renders byte-identical before and after;
every added and removed line in the diff is a comment line.
Revert: `git revert <this commit>`; restores master 3d6c9940 exactly.
Refs REN-108, REN-28.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Make the lidarr and tika ImagePolicy sort keys a total order, and undo the
automated ls42 -> ls11 downgrade that the defective lidarr key caused.
Reviewed and merged by argus-bot; authored by atlas-bot (four-eyes).
Pod roll of deploy/lidarr in ns navidrome signed off by Zeus on REN-91.
Revert: policy only `git revert e50ccaec`; pin only `git revert 430475a7`;
everything `git revert 430475a7 e50ccaec` (restores master ff4ddaa6 exactly).
Refs REN-91.
Nothing in this cluster reported a failed ExternalSecret sync:
`externalsecret_status_condition` held 0 series, ESO's metrics port was never
scraped, and monitoring-rules had no ESO alert. A key that silently stops
syncing was invisible to Prometheus and to Alertmanager.
This is the scrape only. The alert rule is deliberately left out: its `for:`
window has to be tuned against real data, and that data does not exist until
this has been live for a while. Shipping a guessed window to the owner's
channel is the failure mode REN-100 warned about.
A PodMonitor rather than the chart's `serviceMonitor.enabled`, for two reasons
checked against chart 2.11.0:
* There is no metrics Service to select. `metrics.service.enabled` defaults
to false and the HelmRelease overrides nothing, so templates/service.yaml
renders nothing for the controller. A hand-written ServiceMonitor would
match no Service.
* `serviceMonitor.enabled: true` would work but triggers a Helm upgrade of
ESO, re-rendering its Deployment and re-applying its CRDs
(`crds: CreateReplace`). ESO is what all 54 ExternalSecret manifests in
this repo depend on. A PodMonitor gets the same series and touches the
release not at all.
Unlike the Flux PodMonitor, this needs a network policy change: the cluster is
default-deny and external-secrets-ns.yaml admitted only intra-namespace
traffic, host/remote-node and ICMP. Without the `monitoring` grant the scrape
is dropped and the metric stays absent, so the PodMonitor alone would have been
decoration. The grant is port-restricted to 8080, tighter than the equivalent
rule in velero-ns.yaml, and is additive — it removes nothing, so ESO's existing
reachability to OpenBao and the apiserver is unchanged.
Revert: `git revert <sha>`. Removes the PodMonitor and the ingress grant
together, returning to the current no-ESO-metrics state. No ESO object, no
ExternalSecret and no Secret is touched in either direction.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The comment in this file claimed `wait: true` was how a key that failed to
sync became visible. It is not. Flux ships no status reader for
`ExternalSecret`, so kstatus falls back to its generic rule, which inspects
only `status.observedGeneration` and conditions of type `Reconciling` /
`Stalled`. ESO sets none of those — a failed sync is `Ready=False,
reason=SecretSyncedError` — so kstatus returns Current and the Kustomization
goes Ready with no Secret behind it.
Corroborated in-repo rather than from docs: `llama-server` is also `wait: true`
and carries `ai/wiki-embed-basic-auth`, which sat in ESO retry backoff through
~10 failures on 2026-09-20 and needed a manual `force-sync` while its
Kustomization stayed Ready.
Adds a `healthCheckExprs` entry for `external-secrets.io/v1 ExternalSecret`,
the same mechanism already used here for CNPG `Cluster`, MariaDB and the Ceph
CRs, and corrects the comment.
Both expressions are guarded with `has(status.conditions)`, copying
wordpress.yaml. Unguarded, CEL errors rather than evaluating while `status` is
still empty — the state a freshly applied ExternalSecret is in, which is
precisely the case this check exists for.
Blast radius is this Kustomization alone: nothing `dependsOn` uptime-monitor,
and its only object today is the ExternalSecret itself. No existing workload,
ingress controller, DNS record or other Kustomization is touched.
Revert: `git revert <sha>`, or drop the `healthCheckExprs` block. Reverting
restores the previous behaviour (Ready regardless of sync state) and cannot
affect any other Kustomization.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Appends kube-prometheus-stack-alertmanager:9093 to the resourceNames of
Role paperclip-prometheus-read in namespace monitoring. Additive, get-only,
no pod template change.
Reviewed and merged by argus-bot; authored by atlas-bot (four-eyes).
Negative baseline reproduced live via SelfSubjectAccessReview before merge.
Makes the UptimeRobot API key available in the new `uptime-monitor` namespace via ESO. No controller, no monitors, no workload — the key only. First step of REN-2, closes REN-5.
Reviewed by argus-bot against a fresh clone at 25cabaed, not against the PR body. Verified: remoteRef key is mount-relative (store sets provider.vault.path: homelab; `grep -rn "key: homelab/"` = 0 hits); eso-read.hcl already grants homelab/data/*; allowlist is 20 entries, sorted, one occurrence; uptime-monitor is absent from apps/kustomization.yaml and clusters/homelab has no kustomization.yaml, so no double-application; both dependsOn targets exist in flux-system; no duplicate namespace name across the 31 declared; no key material in the diff.
Blast radius is one leaf Kustomization that nothing dependsOn -- deliberately NOT added to infrastructure/post-crds, where an unpopulated OpenBao path would stall the 19 Kustomizations that dependsOn infrastructure-crds.
Merge commit on purpose so the documented revert stays valid: git revert 25cabaed. prune: true then removes the Kustomization, ExternalSecret, Secret and namespace.
Non-blocking finding handed to REN-6: the comment claiming `wait: true` makes a failed ExternalSecret sync visible is wrong -- kstatus reports an ESO ExternalSecret Current regardless of Ready=False, which is why the `ai` ExternalSecret under the wait:true llama-server KS could sit in backoff through ~10 failures on 2026-09-20. Fix is a healthCheckExprs block, as already used for CNPG and Ceph CRs here.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Reverts the image change in b858b4aa. ls11 is 3.1.0.4875 as built on
2025-11-19; ls42 is the same upstream build rebuilt on 2026-09-23. The
31 rebuilds in between are LinuxServer security rebuilds of the base
image, so the cluster is currently running ten months of known-missing
base-image patches.
Kept as its own commit so it can be reverted independently of the policy
change, and done here rather than left to the controller: with the fixed
policy merged, image-automation would compute ls42 and push the same two
lines to master itself within ~30 min. Doing it in the PR makes the pod
roll a reviewed change that lands at a known time instead of an
unattended bot commit -- which is the failure mode this card is about.
Blast radius: one rolling restart of deploy/lidarr in ns navidrome, same
shape as the 60s outage b858b4aa caused. Lidarr is a read-only
missing-album view; nothing else consumes it. No other workload moves.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
lidarr extracted only <build> from <maj>.<min>.<patch>.<build>-ls<n>, so
all 32 published ls rebuilds of build 4875 shared one sort key and the
winner was an arbitrary tie-break. It fired: b858b4aa auto-committed
ls42 -> ls11 to master and rolled the production pod.
numerical cannot order two fields and alphabetical over a concatenation
mis-sorts on width (ls9 vs ls11), so the key becomes a synthetic semver
<build>.<ls>.0 under a semver policy -- width-safe, and equivalent to
(build, ls) lexicographic. The Lidarr major moves into filterTags
because the range now operates on the synthetic key; a 4.x bump needs a
deliberate edit, per the tight-range rule in README.md.
tika has the same defect: extracting <maj>.<min>.<patch> from a
4-component tag discards the image revision. The README called this
theoretical; it is not -- 2.5.0.1/2.5.0.2, 2.6.0.0/2.6.0.1,
2.7.0.0/2.7.0.1 and 2.9.2.0/2.9.2.1 all collide. Every 3.x release so
far is .0, so the revision is pinned in the filter the way
protonmail-bridge.yaml pins its -1 suffix. No-op today; if upstream
ships a 3.x .1 the policy stops bumping instead of picking wrongly.
Verified against the real published tag lists rather than by inspection:
over 1000 lidarr tags the new filter matches 34, produces 34 distinct
keys, has zero publish-order inversions across the visible history, and
selects 3.1.0.4875-ls42. tika reduces to one tag per key and still
selects 3.3.1.0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Appends one `resourceNames` entry to the existing `paperclip-prometheus-read`
Role so the read-only apiserver service-proxy path also covers Alertmanager:
kube-prometheus-stack-alertmanager:9093
This is the widening that the file's own header comment said required a new
card and a new review — REN-94/REN-95 are that card, and Hermes confirmed the
target against the live cluster before this was written.
Nothing else about the grant changes. Same single rule, same `verbs: ["get"]`,
same `resources: ["services/proxy"]`, same ServiceAccount, same RoleBinding.
No new rule, no cluster-scoped kind, no second verb. `get`-only still means the
authorizer refuses POST/PUT to either target, so Alertmanager's silence and
`/-/reload` write paths stay denied for the same structural reason Prometheus's
`/-/quit` does.
No NetworkPolicy edit: `network-policy.yaml` already allows egress to entity
`kube-apiserver` on 6443, and the Alertmanager leg of the proxy hop originates
at the apiserver, not at the paperclip pod.
Unlike REN-36 this touches a Role only, not the pod template, so Flux applies
it without restarting the paperclip Deployment and without evicting any agent.
Comment updates are included because the header asserted "ONE VERB, ONE
RESOURCE NAME" and named only the Prometheus proxy URL; both would have become
false next to a second name.
Revert: revert this one commit. The grant returns to Prometheus-only and no
workload restarts.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Makes the key at OpenBao `infrastructure/uptime-robot`, field `api_key`,
available as a Secret in the new `uptime-monitor` namespace and nowhere
else. No new operator: this uses the External Secrets Operator and the
`openbao` ClusterSecretStore the cluster already runs.
Nothing is monitored by this commit. The namespace holds only the key;
the IngressMonitorController, its ServiceAccount and its RBAC arrive
separately (REN-6).
* `data` + `property` instead of `dataFrom: extract`, so only the one
field lands in the cluster rather than every field at the KV path.
* Adds `uptime-monitor` to the ClusterSecretStore `conditions.namespaces`
allowlist, sorted. Without this ESO reports "not allowed to reference
ClusterSecretStore" and the Secret silently never syncs.
* A DEDICATED Flux Kustomization rather than adding the ExternalSecret to
infrastructure/post-crds. That path runs `wait: true` with no explicit
healthChecks and 19 other Kustomizations dependOn it, so an
ExternalSecret whose OpenBao path is not yet populated would never turn
Ready and would stall most of the cluster. Here the blast radius of a
missing key is this Kustomization alone, which nothing depends on.
* No OpenBao policy change: eso-read already grants `homelab/data/*`.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stops a random critical false page on the storage layer. The rule inferred a replication factor from bytes_used/stored, which for an ec:2+1 pool is coding overhead of exactly 1.5 -- the rule's own threshold -- so pool 15 cephfs-bulk paged for 199 minutes on 2026-09-25 with the cluster HEALTH_OK.
Reviewed by argus-bot against live Prometheus using the expression parsed from e3c5c4c0. Fire-window replay: old expr 231 samples on pool 15, new expr 0. Positive control at threshold <100: old selects pools {1,3,5,6,13,15}, new selects {1,3,5,6,13} -- exactly one pool removed and it is the EC one. pool_id survives the join so the annotations still render.
Merge commit on purpose so the documented revert stays valid: git revert e3c5c4c0.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
CephPoolLostRedundancy infers a replication factor from
ceph_pool_bytes_used/ceph_pool_stored and pages critical below 1.5. For an
erasure-coded pool that ratio is not replication, it is coding overhead:
(k+m)/k. Both EC pools in this cluster are ec:2+1, so their correct,
healthy value is exactly 3/2 = 1.5 — the rule's own threshold.
cephfs-bulk (pool 15) reads 480042086400/320028049408 = 1.5000000384. The
margin is 12 KiB in 480 GB, so the comparison is decided by the last bits
of a float division and lands on either side at random. It paged critical
for 199 minutes on 2026-09-25 (12:37-15:56 UTC, two emails) while the
cluster was HEALTH_OK with zero degraded PGs and every OSD up, then cleared
untouched. migration.rgw.buckets.data (pool 12) is the same ec:2+1 shape.
The rule's premise is also wrong for EC: its description claimed a single
OSD loss destroys data, but 2+1 carries a parity shard and tolerates one
OSD loss, the same as replica-2. Summary and description now say
"replicated" and drop that claim.
Gated on the exporter's own `type` label rather than moving the threshold.
Nudging 1.5 to 1.49 would keep a knife-edge and would also narrow the real
replica-2 -> replica-1 signal (1.0) this rule exists to catch.
NO COVERAGE GAP: a genuine EC redundancy loss raises POOL_NO_REDUNDANCY,
which is a live series in ceph_health_detail and is already deliberately in
scope for CephHealthWarn. That reads Ceph's own verdict instead of
inferring one from bytes, which is the right instrument — an EC pool that
loses a shard does not move its stored/used ratio at all.
VERIFIED against live Prometheus (read-only apiserver proxy, REN-36), using
the expression as serialized from this file:
* 7d replay, old expr: pool 15 matches 49 samples (defect reproduced)
* 7d replay, new expr: 0 matches
* fire-window replay (12:00-16:30 UTC): old matches pool 15 at 231/271
steps, new matches nothing
* positive control, new expr with threshold raised 1.5->3.5: selects
replicated pools 1,3,5,6,7,13,14 and excludes both EC pools (12,15) —
the filter removes exactly the EC pools and nothing else
* evaluated cleanly across 17 distinct mgr instances over 30d, so the
on(pool_id) join does not break on mgr failover
BLAST RADIUS: none to workloads. This edits one PrometheusRule; Prometheus
hot-reloads rule files and no pod restarts. Worst case is alerting scope on
one rule. Nothing writes to the cluster, no Ceph object is touched.
REVERT: revert this commit. One file, no state left behind.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Paperclip <noreply@paperclip.ing>
REN-86 asked for one field: `- name: monitoring` on the `paperclip`
Kustomization's dependsOn, because apps/paperclip/prometheus-read-rbac.yaml
(PR #984) puts a Role and a RoleBinding in namespace `monitoring` and a
from-scratch bootstrap was expected to fail applying them.
The premise does not hold, so the field is not added and the reasoning is
written down instead:
- Namespace `monitoring` is not created by the `monitoring` Kustomization. It
is declared in kubernetes/infrastructure/namespaces/namespaces.yaml, applied
by `infrastructure`, which this Kustomization already dependsOn.
- `infrastructure` runs wait: false, so it applies the namespaces on its first
pass and only its healthChecks gate readiness. It cannot report Ready before
the namespace exists, and this Kustomization cannot start before that.
- Role and RoleBinding are core rbac.authorization.k8s.io/v1 kinds, and a
RoleBinding may reference a Role that does not exist yet. Nothing on this
path needs a kube-prometheus-stack CRD or workload, so there is no analogue
of the monitoring-rules/PrometheusRule split.
Adding the edge would also couple the Kustomization that carries the paperclip
pod to `monitoring` readiness, which itself waits on grafana-db,
kube-prometheus-stack, Loki, Alloy and blackbox-exporter for up to 30m.
Comments only: the parsed manifest is identical to master.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Option B on REN-37, owner-approved: reach Prometheus through the apiserver
service proxy under a Role holding one verb, instead of an L4 allow on :9090
that could not tell GET /api/v1/query from the unauthenticated POST /-/quit
that --web.enable-lifecycle puts on the same port.
ServiceAccount paperclip/paperclip-prometheus-read
Role monitoring/paperclip-prometheus-read
get on services/proxy, resourceNames:
["kube-prometheus-stack-prometheus:9090"]
RoleBinding monitoring/paperclip-prometheus-read -> that SA
(subject namespace: paperclip - load-bearing, cross-ns)
paperclip.yaml serviceAccountName + automountServiceAccountToken: true
Authored by svc-atlas, reviewed and merged by argus-bot: author is not the
merger. ci-manifests-test green on 35bcdf10 (kustomize build over the whole
tree). Reviewed against master 3a457bd6 in a fresh clone.
BLAST RADIUS: applying this restarts the paperclip pod (strategy: Recreate)
and every agent running in it. Nothing in production monitoring restarts -
Prometheus, Alertmanager, Loki and the ingress controllers are untouched.
The paperclip Flux Kustomization has wait: true, so a pod that does not come
back shows as a failed reconcile rather than silently.
REVERT - one step:
git revert -m 1 <the sha of this merge commit>
That drops serviceAccountName, restores automountServiceAccountToken: false
and deletes prometheus-read-rbac.yaml; Flux prunes the ServiceAccount, Role
and RoleBinding on the next reconcile (paperclip Kustomization, prune: true).
It restarts the paperclip pod a second time, for the same reason the apply
does.
AGENT-FREE ESCAPE HATCH - use this if the pod does not come back, because
then there is no agent left to run the revert above. Flux re-applies the same
spec every 10m and overwrites a bare rollout undo; the suspend is what makes
the undo stick:
flux -n flux-system suspend kustomization paperclip
kubectl -n paperclip rollout undo deployment/paperclip
# then land the git revert above, and afterwards:
flux -n flux-system resume kustomization paperclip
Names as they are in this repo: `paperclip` is both the Flux Kustomization in
flux-system (clusters/homelab/paperclip.yaml) and the Deployment in namespace
paperclip. No revisionHistoryLimit is set anywhere under kubernetes/, so the
default 10 holds and a previous ReplicaSet exists to undo to. The undo
restores automountServiceAccountToken: false, which closes the read path at
once, before the RBAC objects are pruned. It rolls the pod template back
wholesale, so an image bump that landed after this merge is rolled back with
it - re-apply that after the resume.
Escape hatch text is the merger's, written into this merge commit because
merge condition 2 puts the revert recipe here. The diff is untouched.
Refs: REN-36, REN-37 (option B), REN-47 (review bar), REN-73 (merge card)
Owner picked option B on REN-37: reach Prometheus through the apiserver
service proxy under an RBAC Role holding one verb, instead of an L4 Cilium
allow on :9090.
The port cannot be the boundary. The chart leaves `reloadStrategy` unset, so
the operator defaults it to HTTP and appends `--web.enable-lifecycle`, which
puts `POST /-/quit` and `POST /-/reload` on :9090 with no auth in front of
them. An L4 allow cannot distinguish those from `GET /api/v1/query`. The
apiserver can: it maps HTTP method to RBAC verb, so a Role with only `get`
refuses both in the authorizer before the request reaches Prometheus.
ServiceAccount paperclip/paperclip-prometheus-read
Role monitoring/paperclip-prometheus-read
get on services/proxy, resourceNames:
["kube-prometheus-stack-prometheus:9090"]
RoleBinding monitoring/paperclip-prometheus-read -> the SA above
paperclip.yaml serviceAccountName + automountServiceAccountToken: true
Shape copied from monitoring/ci-diagnostics-rbac.yaml, which has run this
same proxy path for the alert-monitor since 2026-07-16. No network-policy
edit: paperclip-egress already allows kube-apiserver on 6443.
BLAST RADIUS: applying this restarts the paperclip pod, and every agent with
it. Nothing in production monitoring restarts — Prometheus, Alertmanager,
Loki and the ingress controllers are untouched.
REVERT: revert this commit. One commit, four files, no state left behind;
Flux prunes the SA, Role and RoleBinding and rolls the pod back to no
mounted token.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment-only change to kubernetes/apps/monitoring-rules/probes-blackbox.yaml. Reviewed by argus-bot at head 49bd9271 against base 2b71dda1: 3 documents parse deep-equal, blackbox-http 20 targets before and after, git diff -U0 non-comment non-blank lines = 0, apps/gatus/configmap.yaml untouched. Revert recipe and blast-radius claims re-executed and confirmed.
Comment-only. Rebuilt on top of master (2b71dda1) after #979 merged, so it
adds to that text instead of reverting it.
- EGRESS: names the two ingress-only Cilium policies in the monitoring
namespace and where blackbox-exporter runs; counts re-verified at
2b71dda1 (635 yaml files, 73 Cilium objects, 38 with egress/egressDeny,
0 in monitoring, 1 CCNP scoped to reserved.health, 17 namespaces with
an egress block).
- BLAST RADIUS: what breaks if that dependency is ever broken
(BlackboxProbeFailing x3, BlackboxProbesAbsent stays silent), who
notices (admin@renner.uno, ~10-11 min), and how fast it is undone.
- REVERT: replaced the claim with an executed recipe. All four git revert
candidates CONFLICT at 2b71dda1; the two forward sed deletions were run
and verified (blackbox-http 20 -> 17 and 20 -> 19).
No object changes: 3 documents deep-equal before/after under two
independent parsers, blackbox-http still 20 targets, and `git diff -U0`
has zero non-comment non-blank changed lines. apps/gatus/configmap.yaml
is not touched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment-only change to kubernetes/infrastructure/image-automation/update-automation.yaml.
Reviewed by Argus (svc-argus/argus-bot). Verified comment-only: SHA256 of the comment-stripped file is 4ed11c66f18b8d15 before and after. No object field changes, so Flux's next reconcile is a no-op.
Revert: git revert 4f1c2cf1
Co-Authored-By: Paperclip <noreply@paperclip.ing>
The comment on ImageUpdateAutomation/homelab named gitea_admin as the
owner of the bootstrap token that pushes writeback commits to master.
That is wrong. The pushing account is `flux` (Gitea uid 9): its
contribution history matches every `chore(image-automation)` commit on
master 1:1, while gitea_admin's own contributions stop at 2026-07-21.
Also separates two things the old wording ran together — the commit
author (security-bot@renner.uno, set in this spec) and the pushing
account (the token owner, not set here) — and records that writeback
failure is silent, since no commit is attempted unless a tracked image
moves.
Comment-only. The ImageUpdateAutomation object is byte-identical; no
Kubernetes object changes, nothing for Flux to reconcile.
Refs REN-50
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Comment-only. No Kubernetes object changes on either file: all 3 probe documents deep-equal to master (blackbox-http 20 targets), ConfigMap/gatus-config deep-equal with data["config.yaml"] byte-identical at sha256 8886ef21...6472 (6572 bytes), so Reloader does not roll gatus. Non-comment non-blank changed lines vs merge base 0a82d61f: 0. Reviewed and verified by argus-bot at head 9142b78a; the REVERT recipe was re-tested against its own commit.
The container was OOMKilled (exit 137) at 19:43 under the 4Gi limit.
With server-local runs every concurrent agent's Claude Code process
and MCP helpers live in this container, so the limit caps how many
agents can work at once; exceeding it kills the server and every
in-flight run (process_lost / orphaned_running_run).
Limit 16Gi, request 2Gi (idle server ~1Gi). Nodes have ~93Gi
allocatable each at 12-49% use.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment-only, one sentence, one file. Wording is Argus's from the #979
review (REN-38).
The REVERT paragraph ended with "The paperless block below DOES revert
cleanly on its own with `git revert -m 1 0a82d61f`". True at 0a82d61f,
false at bd8690eb, because this same PR edits the paperless block:
from 0a82d61f: revert -m 1 0a82d61f -> CLEAN revert -m 1 16238d39 -> CONFLICT
from bd8690eb: revert -m 1 0a82d61f -> CONFLICT revert -m 1 16238d39 -> CONFLICT
Reproduced here: at bd8690eb the collision is one region, `<<<<<<< HEAD`
at 155 through `>>>>>>>` at 198, the whole paperless block against an
empty side. So the advice failed in exactly the case it was written for
— someone reverting the paperless probe under pressure, four lines below
a warning not to trust the natural resolution on this file. The new text
says what actually happens and how to resolve it, and stays true as the
comment block keeps changing, because it no longer promises clean.
Bar re-verified at this commit, both files against master, js-yaml:
probes-blackbox.yaml docs base=3 head=3, all deep-equal=true
http_2xx/20, http_2xx_wan_edge/3, tcp_connect/2
gatus/configmap.yaml ConfigMap/gatus-config deep-equal=true
data["config.yaml"] sha256 8886ef21…6472 both
sides, identical=true, bytes=6572
git diff -U0 vs master, non-comment non-blank lines: 0
apps/gatus/configmap.yaml is not touched by this commit at all.
Production constraint: nothing applied, restarted or re-rendered. The
public status.renner.uno page does not roll — ConfigMap data is
unchanged, so configmap.reloader.stakater.com/reload has nothing to act
on.
Revert: `git revert <this-sha>` restores the previous sentence and
nothing else.
Refs REN-21, REN-38, REN-44.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment-only. Every parsed object in both files is byte-identical to
master at 0a82d61f, verified with js-yaml; zero non-comment lines change.
Review follow-ups from REN-22 on the merged #976/#977 probes:
1. The CNP argument was right in its conclusion and wrong in its framing.
cnp-cross-namespace-allow-apps-gatus-to-monitored-services DOES apply to
blackbox-exporter — its Generalization section names it. We are safe
because the allow it demands is already present, not because the note is
about gatus. The old wording is the sentence that makes the next person
skip the check for the next namespace.
2. Records the load-bearing egress fact, which was written down nowhere.
Cilium enforces at both ends; blackbox-exporter can send only because no
policy selecting a monitoring endpoint declares an `egress:` block.
Verified across the repo: 73 Cilium policies, 38 with egress, none in
monitoring, and the one CCNP is scoped to `reserved.health`. 17 other
namespaces already have an egress block, so a routine hardening pass on
monitoring would break these three targets and leave the *.monitoring.svc
ones working.
3. Extends the 200-vs-3xx caveat to all three new targets, not just
paperless. The repo evidence behind every one of them establishes only
`< 400` (kubelet httpGet accepts 200-399; lidarr.yaml:29's `curl -f`
without -L exits 0 on a 302). It proves the paths are ungated, not that
they answer 200. A 3xx gets reverted, not widened.
4. Records the tested revert recipe for #976 in the file. `git revert -m 1
16238d39` conflicts and the natural resolution silently drops paperless
too.
5. Corrects the stale "needs a CiliumNetworkPolicy first" claim in
apps/gatus/configmap.yaml. The correction is written in the manifest
header, ABOVE `data:`, not in place: the stale sentence sits inside the
`config.yaml: |` block scalar, and gatus carries
configmap.reloader.stakater.com/reload, so editing it in place would roll
the gatus Deployment for a comment. ConfigMap data sha256 is unchanged.
Production constraint: nothing is applied, restarted or re-rendered by this
change. No workload, namespace, policy or probe target is touched.
Revert: `git revert -m 1 <merge-sha>`, or drop either file alone with
`git checkout 0a82d61f -- <path>`.
Refs REN-21, REN-22.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Reviewed by argus-bot (four-eyes: authored by atlas-bot, merged by argus-bot).
Rebased onto master after #976 by argus-bot; conflict markers only were removed,
the 29 added lines are byte-identical to the original commit bdf55c5b.
blackbox-http 19 -> 20 targets; other two Probe documents unchanged.
Revert: git revert -m 1 <this merge sha> is clean (tested; 20 -> 19, navidrome
and lidarr intact).
Open risk: /accounts/login/ must return 200, not 302 -- module http_2xx passes
only a final 2xx. If it is a 302, revert this PR rather than widening
valid_status_codes. Unverified at merge time: no Prometheus read path. See REN-22.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
paperless.renner.uno is served entirely through the paperless-gate
Authentik outpost. gatus already records the consequence at
apps/gatus/configmap.yaml:176-185: every path including /api/ answers
302 to an unauthenticated caller, so its check of that hostname — and
the UptimeRobot monitor REN-2 adds — is reachability only and stays
green while paperless is dead behind a live gate. This adds the app
health half of the pair, from the in-cluster vantage.
The target is /accounts/login/ because paperless-ngx has no
unauthenticated health endpoint. It is the app's own established
"the webserver answers" signal — the path its startup, liveness and
readiness probes already use — and it renders a Django view rather
than a static file. It does not cover the consumer/celery workers,
and the comment says so.
No CiliumNetworkPolicy change, and no edit to the paperless namespace.
The gatus comment above asserts an in-cluster probe needs one first;
that was written for gatus, which runs in `apps`. paperless-ns.yaml:24-26
already admits the whole monitoring namespace, where blackbox-exporter
runs, and Cilium unions the allow rules of every CNP selecting an
endpoint — the same mechanism apps/paperless/network-policy.yaml:8-12
relies on.
No PrometheusRule change: the target is covered by the existing
BlackboxProbeFailing catch-all and is correctly absent from its
exclusion list, which per prometheusrule-blackbox.yaml:18-33 holds only
instances that have a dedicated rule elsewhere.
Refs REN-21.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Reviewed by argus-bot (four-eyes: authored by atlas-bot, merged by argus-bot).
One file, kubernetes/apps/monitoring-rules/probes-blackbox.yaml, additive only.
blackbox-http 17 -> 19 targets; other two Probe documents unchanged.
Revert: delete the added target lines and comment block (tested clean).
Do NOT use git revert -m 1 on this merge once #977 has landed -- the natural
conflict resolution silently removes the paperless probe too. See REN-22.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
navidrome.renner.uno and lidarr.renner.uno are served entirely through
Authentik outposts. An external probe of either name therefore measures
DNS, TLS, Traefik and the gate outpost — and stays green while the app
behind a live gate is dead. This adds the second signal the
monitoring-vantage-point-rule asks for: an in-cluster probe of the
ClusterIP, which goes red on the app dying regardless of the gate.
Targets are /ping on both, the same path the pods' own kubelet
liveness/readiness probes already use. That it is ungated is not an
assumption: lidarr's reconcile script curls /ping in a loop before it
has read the API key out of config.xml (lidarr.yaml:29).
No CiliumNetworkPolicy change, and no edit to the navidrome namespace at
all. The wiki gotcha
cnp-cross-namespace-allow-apps-gatus-to-monitored-services is about
`apps`, where gatus runs; the influxdb allow-list it describes already
contained `monitoring`. navidrome-ns.yaml:25-27 likewise already admits
the whole monitoring namespace, which is where blackbox-exporter runs.
No PrometheusRule change either: both targets are covered by the
existing BlackboxProbeFailing catch-all and are correctly absent from
its exclusion list, which per prometheusrule-blackbox.yaml:18-33 holds
only instances that have a dedicated rule elsewhere.
Refs REN-21.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Paperclip agents get their own wiki machine identity: OpenBao
apps/ai/wiki-api-keys field `paperclip` (already written, version 5),
mapped to Auth__ApiKeys__paperclip. Reloader rolls the wiki on the new
key.
Agents reach the service in-cluster at wiki.ai.svc.cluster.local:8080,
like the CI agents, so only the app-level API key applies (no Traefik
BasicAuth user needed). The wiki CNP admits the paperclip pod on 8080
and the paperclip egress CNP allows exactly that pod and port.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Lidarr in the navidrome namespace as a "what is missing" UI: artist
discographies (Wanted -> Missing) and Spotify followed artists / saved
albums / playlists as import lists.
- Mounts navidrome-music-bulk read-write (Lidarr has no read-only root
folder mode); config on ceph-block.
- A reconcile sidecar (same image, curl+jq) reads the API key from
/config/config.xml and on every start sets renameTracks=false,
writeAudioTags=no, no recycle bin, no empty-folder deletes, no
permission changes -- then adds /music as root folder only if absent,
so the automatic library import never runs with file-mutating
settings.
- No download client or indexer.
- AuthenticationMethod=External behind a dedicated lidarr-gate outpost
restricted to a new "Music Librarians" group (k.renner).
- Image automation on the linuxserver build number.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Second named key in apps/ai/wiki-api-keys (OpenBao version 4), mapped to
Auth__ApiKeys__paperclip; writes through the API are attributed to it.
Reloader rolls the pod when the Secret changes.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The cluster-internal name keeps working when runs move to sandbox pods
(option C), where loopback would not reach the server. The name is
added to PAPERCLIP_ALLOWED_HOSTNAMES because the private-hostname guard
otherwise returns 403 for it. Same-namespace traffic is already allowed
by the baseline CNP in both directions.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Upstream derives the agents' PAPERCLIP_API_URL from the public URL, so
agent API calls went out through Traefik to the paperclip-gate outpost.
With intercept_header_auth the outpost treats the agent's Authorization
header as an Authentik credential and returns 401; agents could not
create tasks, post cards or update status.
Agents run in the server pod (option B), so PAPERCLIP_API_URL is set to
http://localhost:3100 (upstream keeps an explicit value and passes it to
every run). Loopback passes the private-hostname guard and agent JWTs
still authenticate each call; the gate stays fully enforced for browser
traffic instead of carving out /api/.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The first admin is claimed, so PAPERCLIP_AUTH_DISABLE_SIGN_UP flips to
true.
Spec: Claude auth is a Paperclip-managed subscription connection created
once via the local sign-in. The server-env CLAUDE_CODE_OAUTH_TOKEN was
never used (stripAiAuthBindings blanks it for managed runs; upstream
accepts no pasted setup-token), so it is dropped from the spec. The
login must run as the node user: exec sessions start as root and
root-owned 0600 credentials are unreadable to the server.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Paperclip's private-hostname guard (authenticated+private) returns 403
for any Host but loopback and the public URL. Kubelet probes send the
pod IP, so the pod could never become Ready. Probes now send
Host: localhost.
- Liveness is a TCP check: /api/health runs SELECT 1, so a Postgres
outage would have restarted the server and killed in-flight runs.
- Baseline CNP allowed world on every port; Cilium unions allows across
CNPs, so the app-level :443 restriction was not enforced. world is
now :443-only in the baseline as well.
- automountServiceAccountToken: false. Every agent shares the pod
filesystem; bots bring their own cluster credentials.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Sign-up stays enabled: upstream's first-admin claim requires an
existing signed-in Better Auth account (routes/access.ts
/bootstrap/claim), so disabling it would lock out the claim. A
follow-up flips PAPERCLIP_AUTH_DISABLE_SIGN_UP after the claim.
- Telemetry is off via PAPERCLIP_TELEMETRY_DISABLED=1, which
resolveTelemetryConfig honours; config.json can't carry it alone
because the schema requires a complete file.
- Container starts as root: docker-entrypoint.sh remaps the node user
and chowns /paperclip before dropping privileges with gosu.
- Image pinned to the amd64 manifest digest of nightly; Flux digest
reflection takes over from there.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
digestReflectionPolicy: Always requires spec.interval on the ImagePolicy
(admission rejects it otherwise); set to 1h to match the ImageRepository.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The public edge is proxy-1/proxy-2; proxy.renner.uno has not pointed at
netbird.renner.uno since the netcup fleet took over. The proxy that kept
running on the management host has served nothing since the controller
moved every service to TLS passthrough (2026-09-07): passthrough routes
live on the proxy's own :443 listener, while the host's Traefik forwards
HostSNI(*) to its :8443 main listener, which answers "unknown domain".
Its watchdog therefore failed every probe round and ran `docker restart`
every ~3 minutes since 2026-09-07 12:53 (last healthy round). Each
restart registered a fresh proxy peer and pushed a network-map update to
every peer in the mesh — ~10 stale proxy-* identities per ingress peer
and a map update every 1-2 minutes.
- netbird-server: drop the proxy service, its Traefik TCP passthrough,
proxy.env and the proxy vars; `compose up --remove-orphans` removes
the container, and one-off tasks stop/remove both watchdogs and delete
the proxy volumes, config and state.
- install-netbird-server.yml no longer applies netbird-proxy-watchdog;
its pipeline no longer triggers on that role.
- Comments that described the management host as a cluster member now
describe the proxy hosts only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Navidrome (Subsonic API) as the Spotify replacement for phone apps.
- Library PVC on ceph-filesystem-bulk (RWX, 300Gi, mounted read-only);
SQLite data on ceph-block (RWO).
- Web UI behind a dedicated navidrome-gate Authentik proxy outpost;
Navidrome trusts X-Authentik-Username (ExtAuth) and auto-creates the
user on first SSO login (first user = admin).
- /rest and /share routed straight to Navidrome with the identity header
stripped, so Subsonic clients use their Navidrome password.
- Namespace baseline CNP, gate egress CNP, Flux image automation,
Velero daily schedule (09:15), bootstrap wait.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
immich-server mounts /data from immich-library-bulk (EC 2+1) and
/data/thumbs from immich-thumbs (3x replicated). Data was copied with
rsync -aH from immich-library and verified per folder: file counts and
byte totals match for library, upload, encoded-video, profile, backups
and thumbs.
immich-library stays in pvcs.yaml until a Velero backup of the new
volumes has completed; its removal is a separate change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds immich-library-bulk (ceph-filesystem-bulk, EC 2+1, 300Gi) for
the media root and immich-thumbs (ceph-filesystem, 3x, 30Gi) for
thumbs. Thumbnail reads stay on the replicated pool, where CSI read
affinity serves them from the local OSD; an EC read always
reassembles chunks from two OSDs.
Both PVCs start empty and nothing mounts them yet. The data copy and
the server cutover follow separately.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ImageRepository and ImagePolicy for gitea.renner.uno/konairius/wiki, same
shape as shelly-manager; the deployment carries the setter marker, so the
existing ImageUpdateAutomation bumps the tag on every main build.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Wiki main after PR #12: /facts is one text box; the drafter turns the
statement into a raw source under the signed-in user.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Wiki main after PR #11: /facts deposits a raw source under the signed-in
user through the same use case as the API.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Books live on audiobookshelf-books-bulk since #955; nothing mounts
audiobookshelf-books any more. The PVC has prune disabled, so removing
it from git does not delete it; the PVC, its Retain PV and the CephFS
subvolume are deleted by hand after merge.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Wiki main after PR #10: every typed question rides in the single request
that carries the state, halving TypeSafe input tokens per ask.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Points the audiobookshelf Deployment and the libation CronJob at
audiobookshelf-books-bulk (ceph-filesystem-bulk, EC 2+1). The data was
copied with rsync -aH from the old PVC and verified: 574 files,
87315610958 bytes on both sides.
audiobookshelf-books stays in pvcs.yaml (prune disabled) until the
new volume is confirmed in use; its removal is a separate change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Enables StripAudibleBrandAudio (Libation trims the branding using Audible's
own chapter offsets, lossless) and OverwriteExisting, so a re-liberated book
replaces its file instead of landing next to it as 'Title (1)'.
Settings.json on the PVC is seeded once and the entrypoint copies it into
ephemeral /config-internal each run, so the flags are applied there with jq
(already in the image; the entrypoint uses it for Books/InProgress).
Only affects books decrypted from now on; existing files need a re-liberate.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds a secondary erasure-coded (2+1, failureDomain host) data pool
'bulk' to the cephfs filesystem and a ceph-filesystem-bulk StorageClass
targeting it. data0 stays the default data pool, as Rook requires for
EC CephFS pools. Bulk media at 1.5x instead of 3x raw overhead.
Behaviour matches the existing migration.rgw.buckets.data EC pool:
Rook sets ec_overwrites, min_size 2 (=k), so IO continues through a
single-node reboot without redundancy for that window.
Also provisions an empty audiobookshelf-books-bulk PVC on the new
class. Deployment and libation CronJob still mount audiobookshelf-books;
the data copy and cutover follow separately.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The open3e-esp32 gateway on the Vitocal 250-A internal CAN (ECU 0x6e0,
room setpoints) publishes and listens under open3e/int/#. A separate
broker user keeps its command topic out of edge-1's open3e/cmnd listener
and gives the credential its own lifecycle.
Follows the health credential pattern: OpenBao-backed via ExternalSecret,
Class-A rotator CronJob (quarterly, months 3/6/9/12), per-secret policy
and kubernetes auth role, Reloader annotation on the broker.
Out-of-band steps done on 2026-09-24 with an admin token: policy written,
auth role created, path seeded with a 32-char alphanumeric password.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QNSJsCKwMip9y9YEZhDqxw
All credentials reach the service via envFrom, which is read once at pod
start. Reloader watches the four Secrets and restarts the Deployment on a
change, so a corrected or rotated key takes effect without a manual restart.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Wiki main at 8fa9f20: POST /api/v1/facts, POST /api/v1/questions with the
Anthropic answer model and the Answers page, POST /api/v1/lab/eval and the
Lab page. The pod restart also picks up the Anthropic key from wiki-anthropic.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Wiki PR #7 adds the questions endpoint backed by the Anthropic Messages API.
The key arrives as Wiki__AnswerModels__Models__claude-sonnet__ApiKey via an
ExternalSecret; the secretRef is optional so the pod keeps starting while the
OpenBao key does not exist yet. Namespace egress already admits world, so no
CNP change.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The .NET wiki service has parity (same recall on the labelled set), serves
wiki-index.renner.uno and the notes webhook, and the workstation scripts
use it. Removes the app, its Flux Kustomization, image source and build
step, its CNP entries and the OpenBao policy file. The OpenBao role and
the old Gitea webhook are removed live after the merge.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
BasicAuth as before, X-Authentik-Username stripped on that path, Traefik
admitted by the wiki CNP again. The Python service keeps running without
an ingress until the scripts are verified against the new one.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Proxy provider, application and outpost blueprint; outpost egress CNP;
ExternalName mirror; the ingress backends the outpost and the BasicAuth
middleware is gone. The ai CNP admits the outpost and Gitea only, so the
X-Authentik-Username header the service trusts from pod addresses cannot
arrive from the LAN.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Gitea's egress to ai was scoped to the wiki-index pod; deliveries to
wiki.ai.svc timed out (hook 17, 2026-09-24).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
PVC, Deployment (build.1790245544), Service, ExternalSecrets for the
shared Gitea token/webhook secret and the API keys, LAN-only ingress
wiki.renner.uno behind BasicAuth, Flux Kustomization, CNP admitting
Traefik and Gitea. No OpenBao access from the pod.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Our hand-written Alertmanager config replaced the chart's default and
dropped two pieces kube-prometheus relies on:
- a route sending InfoInhibitor to the null receiver
- the inhibit rules that let InfoInhibitor (and warning/critical)
suppress severity=info alerts
So both the helper alert and the info alerts it exists to silence were
emailed, daily. InfoInhibitor fired over the last 7 days in crowdsec,
kube-system, woodpecker-ci and without a namespace, driven by
CPUThrottlingHigh (crowdsec-agent, whereabouts-reconciler),
KubeletTooManyPods and KubeQuota* in woodpecker-ci.
Route and the three inhibit rules are copied from kube-prometheus
manifests/alertmanager-secret.yaml. Info alerts now reach email only
while a warning or critical alert fires in the same namespace, which is
the upstream design. amtool check-config passes; amtool config routes
test sends InfoInhibitor to null.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Final step of the CVE-2025-30156 remediation: allowedCiphers
[aes256k], which Rook applies as auth_allowed_ciphers. That is step 10
of Ceph's CephX aes256k upgrade procedure and clears
AUTH_INSECURE_KEYS_ALLOWED; per Ceph's health-check reference that also
resolves AUTH_INSECURE_KEYS_CREATABLE.
Precondition from the procedure, checked without printing any key:
AUTH_INSECURE_CLIENT_KEY_TYPE and AUTH_INSECURE_SERVICE_KEY_TYPE are both
clear (ceph health detail lists only KEYS_ALLOWED and KEYS_CREATABLE).
Rook status: daemon keys and CSI/rbdMirrorPeer at generation 2, prior CSI
keys deleted (#934).
Lockout recovery if auth breaks (Rook cephx-key-rotation doc): set
allowedCiphers [aes, aes256k] and daemon.keyType aes, wait for the mons
to carry --mon-auth-emergency-allowed-ciphers, then remove
daemon.keyType.
Closes#527
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
keepPriorKeyCountMax 1 -> 0. Rook deletes the generation-1 CSI keys
(client.csi-*.1, aes), which clears AUTH_INSECURE_CLIENT_KEY_TYPE.
Every PVC has been remounted with the generation-2 aes256k keys: all
three nodes were drained and uncordoned after the rotation, and no
kernel session on any mon uses a .1 entity any more. The remaining .1
sessions are idle librados connections in the CSI nodeplugins' pool;
ceph-csi keys pool entries by user and keyfile, and the CSI secrets now
name the .2 users, so nothing reuses them.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Run 10608 aborted before its first edit: the agent calls
~/Notes/bin/wiki-snapshot.sh and git -C ~/Notes rev-parse/status, the
rules carried only /root/Notes. Both spellings are allowed for the vault
scripts and the read-only git subcommands on the vault checkout, config is
denied there too, and the consolidate step derives its --allowedTools
from the settings file instead of a second copy. Verified headless:
rev-parse, status and wiki-delete.sh allowed, config denied.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The last five CephX entities on aes are the four CSI clients and
client.rbd-mirror-peer. The CSI keys waited on node kernel support:
Rook's key-rotation doc asks for kernel 7.0+ for aes256k over kernel
mounts. Talos v1.13.10 (now on all nodes) backports those libceph
patches to 6.18 (siderolabs/pkgs 2da2cdc); CONFIG_CRYPTO_KRB5 and
CONFIG_CRYPTO_KRB5ENC are set on every node. ceph-csi is v3.17.1, the
minimum the doc names; Rook v1.20.7.
csi: KeyGeneration 2, keyType aes256k, keepPriorKeyCountMax 1. Rotated
keys only apply to new mounts; the prior key stays valid for PVCs
mounted before the rotation, per the Rook doc.
rbdMirrorPeer: KeyGeneration 2, aes256k. No CephRBDMirror and no peers
exist, so rotating the peer token affects nothing.
allowedCiphers / mon_auth_allow_insecure_key are NOT changed here: that
is the lockout-capable step and follows only once ceph health shows no
aes entity left.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Review on #927: an empty or truncated /tmp/consolidate.json made jq the
failing command instead of the run. Commit subject of push-notes names
consolidate too.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
All three nodes run v1.13.10 (applied by hand, one node at a time:
drain, talosctl upgrade --image, wait for 217/217 active+clean). This
brings the repo in step so a rebuild or a new node installs the same
version.
Why this release: its pkgs include siderolabs/pkgs@2da2cdc, 'backport
aes256k support (Ceph)', the libceph patches from Linux 7.x on top of
6.18.48. Kernel config on every node now has CONFIG_CRYPTO_KRB5=y and
CONFIG_CRYPTO_KRB5ENC=y. That was the blocker for rotating the CSI
CephX keys to aes256k (#527).
Patch release within 1.13; Kubernetes stays v1.36.3. Schematic is
unchanged; factory installer and ISO verified to exist for the tag.
talosctl checksum matches the release's sha256sum.txt and a local
download.
Refs #527
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Draining k8s-3 for the Talos 1.13.10 upgrade stopped on four pods
(wp-pg-1, ha-pg-1, gf-pg-1, gitea-0) whose PDBs allow zero disruptions,
and each needed a manual --disable-eviction.
CNPG: ha-pg, gf-pg, wp-pg and tr-pg go to instances: 2. With a replica,
CNPG switches the primary over ahead of a drain, the same way ak-pg,
im-pg and pprl-pg already do. With one instance it creates a primary PDB
and 'will prevent draining the node where the instance is housed'
(CNPG docs, kubernetes_upgrade). smartShutdownTimeout 10 / stopDelay 60
as on ak-pg, which bound switchover time and the barman-plugin demote
hang (cloudnative-pg#9770). Network policies already allow intra-cluster
replication, operator :8000 and WAL-restore egress in all four
namespaces; nothing references a specific instance name.
Single-replica RWO workloads (gitea, woodpecker-server,
protonmail-bridge, syncthing, influxdb): drop the minAvailable:1 PDBs.
They cannot fail over, and eviction already stops the pod gracefully
with SIGTERM and its grace period; the PDB only forced a manual
--disable-eviction on every drain. influxdb's comes from the chart
(pdb.create, influxdb2 2.1.2 defaults).
The 'single-instance cluster, the archive is the only off-node copy'
comments no longer hold and are removed (im-pg's was already stale).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Review on #927: the git -C /root/source/ prefix let config and push
through; rm /root/Notes/wiki/ matched path traversal. Allowed git forms
are now log/show/diff/status/rev-parse/ls-files per repo (both the ~ and
/root spelling), config is denied for those forms too, rm is denied and
notes are deleted through the vault's wiki-delete.sh, which validates the
slug. Verified headless: log allowed, config denied, push not allowed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The pass that verifies notes against sources, repos and the live cluster
and edits them still ran on the workstation timer, with no TypeSafe key,
no cost record, and no run on days the machine was off. It joins the
nightly pipeline after the audit: full clones of homelab and homeassistant
under ~/source, read-only kubectl via the ci-diagnostics token, its own
permission scope, JSON output so the report and total_cost_usd land in
the step log. push-notes also commits the rotation state and pushes the
agent's own snapshot commits.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Every night with an empty queue started a full claude run that read the
directory and stopped, paying the agent's startup context and a model
pick for nothing (runs 10362, 10583).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The audit agent had no executable path in CI (Bash limited to the two
retrieval tools), wrote itself scripts it could not run and reported one
of seven checks (runs 10262, 10362). The checks are mechanical, so
notes/bin/wiki_audit.py does them without a model. push-notes adds only
wiki/, raw/ and output/, so scratch files in the checkout root are no
longer committed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The project moved to Codeberg; GitHub is a read-only mirror and the old
chart index at k8s-gateway.github.io returns 404, so the HelmRelease has
had no artifact. The chart is now published as an OCI artifact in the
project's own Codeberg registry (oci://codeberg.org/k8s-gateway/charts/
k8s-gateway), alongside the image.
OCIRepository + chartRef as for spegel and mariadb-operator; semver
range unchanged. Chart 3.7.2 -> 3.7.3, image moves to
codeberg.org/k8s-gateway/k8s_gateway:1.8.3. The rendered Corefile is
identical to the live one.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
NETWORK-traffic-classes.md described table 48 and rules 980/981/990/991,
which #857 replaced with one table (47) decided by longest prefix, and
listed management as not started. Rewritten to the live state: five
classes, table 47 contents owned by bird, node IP and etcd on node0, the
etcd leave-and-rejoin procedure, and the mixed-state hazard hit during
the node IP move. The bird DaemonSet comment still named table 48.
Closes#576
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Records the live state after moving all three nodes:
- site-homelab.yaml: kubelet nodeIP.validSubnets fd02:46::/64 and
10.2.46.0/24 (was 10.2.40.0/24 and the legacy fd40::/64).
- cluster.yaml: etcd advertisedSubnets 10.2.46.0/24.
- mgmt0 comments: VLAN 40 is world-to-node only now.
Applied per node (k8s-3, k8s-2, k8s-1): drain, stage the config (delete
the old lists, set the new ones, one staged patch call), etcd
forfeit-leadership and leave, reboot. Each node rejoined etcd with its
VLAN 46 peer URL; EPHEMERAL was not wiped.
Refs #576
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Pod-sourced traffic is routed from table 47. Once the node IP moves to
VLAN 46 (step 3), pods reach node addresses there: the kubernetes
ClusterIP (socket LB rewrites it to the apiserver's advertise address),
hostNetwork pod IPs, and replies to kubelet probes. Without the attached
node0 route in table 47 those flows take the default to the UDM and every
in-cluster API client breaks. Taking it from the interface, like the
other attached networks, keeps the table in one owner.
Refs #576
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- node0: VLANConfig on the uplink trunk, VLAN 46, static fd02:46::1x/64
and 10.2.46.1x/24, accept_ra 0. Closed like pod0. Unused until the node
IP moves there (step 3).
- cluster.etcd.advertisedSubnets pinned to 10.2.40.0/24 so a reboot
cannot pick a node0 address: Talos never updates an existing member's
peer URLs.
Applied live with talosctl patch --mode=no-reboot, k8s-3 first. Node IPs
unchanged, etcd 3/3 healthy, node0 reachable node to node over v6 and v4.
Refs #576
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Nothing selects cni.renner.uno/cilium-uninstall or
cni.renner.uno/multus-sole-writer since stage B (#915, #916). Removed
live from all three nodes with a $patch: delete strategic merge patch
(--mode=no-reboot); Talos dropped them from the Kubernetes nodes and
left the other labels in place.
Closes#832
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
cni.confPath: /etc/cni/net.d/multus mounts that host directory as the
agent's /host/etc/cni/net.d, so write-cni-conf-when-ready (unchanged,
.../05-cilium.conflist) lands in /etc/cni/net.d/multus/05-cilium.conflist,
the file the per-node CiliumNodeConfigs already produced on all three
nodes. The agent finds it unchanged and does not rewrite it.
updateStrategy maxUnavailable: 1 (chart default 2) so the roll takes one
node's datapath at a time.
bootstrap.sh renders the chart from these values, so a cold rebuild
lands directly in this layout.
Refs #832
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
All three nodes run the sole-writer layout. Converge the Multus side and
drop the per-node Cilium overrides:
- kube-multus-ds-subdir becomes the only Multus DaemonSet, no node
affinity; kube-multus-ds (0 pods) and its ConfigMap are pruned.
- /etc/cni/net.d/multus is mounted DirectoryOrCreate so Multus waits for
the Cilium conflist instead of exiting on a missing directory.
- CiliumNodeConfigs removed. They only act when a cilium pod is created;
stage B2 moves the same path into the HelmRelease (cni.confPath) and
rolls the agents.
Refs #832
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
k8s-1 carries cni.renner.uno/cilium-uninstall and
cni.renner.uno/multus-sole-writer, applied live with talosctl patch.
Verified on the node: /etc/cni/net.d holds only 00-multus.conf at the
top level, and after a reboot with immediate uncordon the OSD came up
with net1 (10.99.0.1) and no restarts. All three nodes are converted.
Refs #832
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The Deployment tolerated every NoSchedule taint (operator: Exists, no
key), including node.kubernetes.io/unschedulable. During the #832 node
reboots its ReplicaSet kept placing replacement pods on the cordoned,
shutting-down node; the kubelet rejected each with NodeShutdown, about
200 failed pods per reboot. Tolerate only the control-plane taint the
comment names.
Refs #832
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
k8s-2 carries cni.renner.uno/cilium-uninstall and
cni.renner.uno/multus-sole-writer, applied live with talosctl patch.
Verified on the node: /etc/cni/net.d holds only 00-multus.conf at the
top level, and after a reboot with immediate uncordon the OSD came up
with net1 (10.99.0.2) and no restarts.
Refs #832
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
k8s-3 carries cni.renner.uno/cilium-uninstall and
cni.renner.uno/multus-sole-writer, applied live with talosctl patch.
Verified on the node: /etc/cni/net.d holds only 00-multus.conf at the
top level, Cilium writes multus/05-cilium.conflist, and after a reboot
with immediate uncordon the OSD came up with net1 (10.99.0.3) and no
restarts.
Refs #832
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Nothing changes until a node is labelled:
- post-crds/cilium-nodeconfig.yaml: CiliumNodeConfig cni-uninstall
(label cni.renner.uno/cilium-uninstall) arms the agent's preStop
conflist cleanup; multus-sole-writer (label
cni.renner.uno/multus-sole-writer) moves write-cni-conf-when-ready to
/host/etc/cni/net.d/multus/05-cilium.conflist, a subdirectory libcni
does not read.
- multus/multus-subdir.yaml: kube-multus-ds-subdir with
multusAutoconfigDir /host/etc/cni/net.d/multus, scheduled only on
labelled nodes; kube-multus-ds gets the inverse affinity.
The kube-multus-ds template change rolls Multus node by node once on
merge.
Refs #832
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- kubernetes/infrastructure/netbird-proxy-services -> netbird-service-sync:
SA, ClusterRole(Binding) and Deployment renamed, in namespace
netbird-service-sync, image gitea_admin/netbird-service-sync pinned to
the master build digest, BAO_ROLE=netbird-service-sync.
- Old policy doc netbird-proxy-services.hcl removed; Traefik comment
follows the name.
The infrastructure KS owns both old and new objects, so prune and apply
happen in one pass. OpenBao policy and role netbird-service-sync are
already written.
Closes#876
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Additive; the running controller is untouched:
- ci/images/netbird-proxy-services -> ci/images/netbird-service-sync,
build step and image name renamed, so the merge builds
gitea_admin/netbird-service-sync.
- Namespace netbird-service-sync with a baseline policy: probes in;
out only to kube-dns, kube-apiserver, OpenBao :8200 and world :443
(NetBird management API).
- openbao-ns admits netbird-service-sync on :8200, like wiki-index.
- policies/netbird-service-sync.hcl documents the new policy and role
(applied by hand, as before).
Refs #876
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Gitea runs in the gitea namespace since #906. Remove what only served it
in apps:
- openbao rotator egress (flux-git, review-bot): apps/gitea target.
- ai-ns wiki-index ingress: apps/gitea source.
- apps default-deny egress to wiki-index :8080 (gitea's webhook; now in
gitea-ns.yaml).
- Velero schedule comments.
Refs #878
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- kubernetes/apps/gitea: every object in namespace gitea; applied by a
new gitea Flux Kustomization (clusters/homelab/gitea.yaml) instead of
apps. gitea-allow-metrics-scrape dropped: the gitea-ns baseline
already admits monitoring.
- Rotators (flux-git, review-bot) and the Prometheus gitea job use
gitea.gitea.svc.
- bootstrap.sh waits for the gitea KS.
Data: data-gitea-0 is not copied. The PV (reclaimPolicy Retain) is
rebound to a PVC gitea/data-gitea-0 pre-created with volumeName, which
the new StatefulSet adopts. gitea-tls is pre-issued in the gitea
namespace so HTTPS has no certificate gap.
Refs #878
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Additive only, nothing moves yet:
- Namespace gitea, declared in infrastructure like woodpecker.
- network-policies/gitea-ns.yaml: baseline (same-ns, monitoring, host,
ICMP; egress to traefik, kube-dns, wiki-index, world) and
gitea-allow-traefik for :3000 and :2222.
- openbao rotator egress (flux-git, review-bot) and wiki-index ingress
also admit gitea in the gitea namespace; the apps entries stay until
stage 3.
- Velero schedule gitea-daily (07:45) so data-gitea-0 stays backed up
after the move; apps-daily comment updated (Woodpecker left apps).
Refs #878
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
decide required every consumer to pin the same ci-claude tag and aborted
otherwise (pipeline 10497: [2.1.250 2.1.278]), which needed a hand fix
before the latest version could roll out. Record all distinct tags, bump
whenever any is off latest, and rewrite every version-pinned tag to the
new one with a validated x.y.z version.
Merging this is itself a push touching the pipeline file, so the refresh
runs, builds the latest image, and opens the bump PR.
Refs #889
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Pipeline 10186 failed in bump-pr with 'url contains a newline in its
username component': ci_claude_bump_bot_token carries a trailing newline,
so the push URL (and the PR API auth header) broke. Strip CR/LF once at
the top of the step and fail fast if the token ends up empty. This makes
the step independent of how the secret value was written.
Closes#889
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Unset, paperless derives cpu_count / TASK_WORKERS, i.e. 20 parallel
ocrmypdf workers on k8s-1. Memory during OCR scales with that count, so
the limit alone is not enough headroom for large scans.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
An 18 MB scan from the ADS-1700W has kept the paperless container in an
OOM crash loop since 2026-09-21 10:21: ocrmypdf runs with 20 workers
(paperless default, cpu_count / TASK_WORKERS on a 20-core node) and blows
the 2Gi limit ~40s into OCR. The consume task is re-queued on every
restart, so the same file kills every attempt. 117 restarts so far, web
UI down as a side effect since worker and webserver share the container.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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} \
- Mark the block you cite with a comment line `# anchor: <name>` (kebab-case, unique in that file). If the block is inside a `|` block scalar, put the marker just above the key, never inside the scalar.
- Cite it as `path.yaml#<name>`, with the path relative to the citing file's directory or to the repo root. Never cite `path.yaml:<line>`, because line numbers go stale on any edit above them.
-`scripts/ci/yaml-citations.sh` enforces this in `ci-manifests-test.yml`: it fails on line-number citations and on citations whose file or anchor does not exist.
You are running as the scheduled wiki-maintenance job in CI. The Notes vault is checked out at ~/Notes (the repository k.renner/notes).
Run the standard audit pass over ~/Notes/wiki/ and write the dated report to ~/Notes/output/. Do not modify any wiki note. Do not commit or push; the pipeline does that.
End with two lines: notes scanned, and issues found by category.
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.
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.
> **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.
- 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`.
- 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.
# 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).
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:
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.
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:
- 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}`.
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.
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>`); Paperclip stores and refreshes it. 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.
(later: Database, Ollama/Onnx, local Jev, pgvector/Qdrant, OpenAI-compatible answer models)
```
Rules that keep it modular, enforced by project references and a test:
-`Wiki.Core` references nothing. It holds the domain types and the port
interfaces. Adapters reference Core only; they never reference each other
or the host. `Wiki.Host` is the only project that knows which adapters
exist.
- Every port has a **contract test suite** in `test/Wiki.Contracts` that
runs against every adapter of that port. A new adapter is done when the
suite is green. That is what makes "swap the storage" a safe operation.
- Adapters are selected by configuration and registered by name (keyed
services), so several can be active at once. The UI's lab page and the
evaluator use that to compare, the API uses the one marked active.
- Nothing in Core or Application knows about HTTP, Gitea, TypeSafe, files or
Kubernetes.
### Ports
| port | what it abstracts | first adapter | examples of the next ones |
|---|---|---|---|
| `INoteStore` | read notes, raw sources and reports at a head; create a new document (a fact becomes a raw note in the application layer, the store only moves text) | Gitea repository API (`k.renner/notes`, blob SHA) | git checkout on a volume; a database (CNPG Postgres) with the vault imported; an Obsidian sync folder |
| `IEmbeddingModel` | text → vector, with a model id and dimension | llama-server `/v1/embeddings` (bge-m3 via wiki-embed, the model that won the German-document comparison on accuracy per cost) | the qwen3 embedding variants from that comparison; ONNX in-process; any OpenAI-compatible endpoint |
| `IVectorIndex` | store vectors per (model id, document), query top-k | in-memory cosine with a file snapshot per model, as today | pgvector in the database; Qdrant |
| `IChunker` | split a document into addressable units for typed questions | paragraph splitter (headings, fences), as in `wiki_ask.py` | sentence windows; semantic chunks |
| `IJudge` | typed questions over a state: Choice, Noul, Score, with probabilities | TypeSafe System One (`jev-latest`, cloud; the wiki records no locally hosted Jev today) | a locally hosted Jev with the same request shape, should TypeSafe offer one; a constrained-output local LLM adapter, flagged as uncalibrated |
| `IAnswerModel` | prose from a question plus evidence: the selected paragraphs with their notes and the judgements; must cite by slug; streaming | Anthropic Messages API (Claude, model from config, Sonnet by default) | any OpenAI-compatible endpoint, including the local llama-server; a different Claude model per question |
| `IEvaluator` | measure a retrieval or judge configuration against the labelled set (questions and cited sources in `output/`) | recall@k, hit rate, MRR, as `wiki_rank_eval.py` | judge accuracy against hand-labelled paragraphs |
| `IIdentity` | who is calling, for attribution of facts and saved answers | Authentik proxy headers (humans), API key (machines), as in shelly-manager | OIDC bearer tokens |
Two ports deserve a sentence of scope each. The judge returns judgements
with probabilities, never prose, and the UI never presents its output as an
answer, only as ranked, quoted evidence. The answer model turns that
evidence into words and is allowed nothing else: it sees the selected
paragraphs and the judgements, not the whole context, and its output is
shown next to the evidence it was given. Both are single model calls inside
the request. The service runs no agent: no tool loop, no vault edits. The
wiki-synth agent in a Claude session remains the path for artifacts and for
multi-question synthesis, and it can call `/questions` itself.
### Solution layout
```
Wiki.slnx
src/
Wiki.Core/ domain types, ports, frontmatter and link parsing
Wiki.Application/ use cases; depends on Core only
Wiki.Adapters.Gitea/ INoteStore (read at a head, create)
| `POST /api/v1/rank` | `{question, k, model?}` → ranked slugs; also served at `/rank` for the existing scripts |
| `POST /api/v1/ask` | `{queries, questions, k, judge?, model?}` → context, typed judgements with the answering paragraphs verbatim, `covered`. For agents and code. |
| `POST /api/v1/questions` | `{question, k?, judge?, model?, answerModel?, save?}` → the answer text with `[[slug]]` citations, the evidence (paragraphs, probabilities), `covered`, model and token usage; streamed as server-sent events when requested; with `save`, the `output/` path and commit |
| `POST /api/v1/facts` | deposit a raw source: title, source, `kind: fact`, observed_at, content, tags → path and commit |
| `GET /api/v1/raw` | the distill queue with ages |
| `GET /api/v1/reports` | `output/` documents by type, newest first |
| `POST /api/v1/lab/eval` | run the evaluator for a (model, judge) pair |
| `POST /webhook` | Gitea push, HMAC as today |
`POST /api/v1/facts` is rejected without `source`, with a `kind` other than
`fact`, or with content under a minimum length. The raw rule is enforced by
shape: there is no field for a hypothesis. The file gets the vault's
provenance frontmatter with `captured_by` set from the API key's identity.
`POST /api/v1/ask` is `wiki_ask.py` moved server-side, so the scripts become
thin clients and agents no longer need a vault checkout. Its result is
judgements and quotations, never prose.
`POST /api/v1/questions` is `/ask` plus one answer-model call, in process:
rank the question, build the context, run the default typed questions
(answered, where, covered), hand the selected paragraphs and judgements to
the answer model, stream the prose back. Below the `covered` threshold it
returns the gap instead of an answer. Time is the sum of the parts, about
0.2 s rank, 1 to 4 s judge, a few seconds of generation. Cost is one model
call over a few thousand tokens, a few cents on Sonnet, against a quarter
dollar for a full agent run.
## Auth and network
- Humans: `wiki.renner.uno`, LAN-only (no `dns.renner.uno/public` label),
Ingress backend `ak-outpost-wiki-gate:9000`, a `wiki-gate-egress` CNP in
`authentik`, the proxy-header handler trusts `X-Authentik-*` only from that
outpost.
- Machines: `wiki-index.renner.uno` with the API-key handler; the workstation
pod as environment variables from Kubernetes Secrets filled by
ExternalSecret from the `openbao` ClusterSecretStore. The pod itself has
no OpenBao access. The Python service fetched its secrets from OpenBao
directly through its own Kubernetes-auth role; that role, its policy and
the `ai` → `openbao` CNP entry are retired with it.
- Egress from the pod: Gitea, wiki-embed, `api.typesafe.ai:443`,
`api.anthropic.com:443`. Every
further adapter that talks to a new endpoint gets its own CNP line, which
is the one place adding an adapter touches the cluster.
## Health endpoints
Three endpoints with three different meanings, wired to the .NET health
checks with tags so that the probes and the humans see the right thing:
| endpoint | meaning | probe |
|---|---|---|
| `GET /health` | the process serves HTTP. Always 200 once Kestrel listens; no dependency checks. | liveness |
| `GET /ready` | the active note store has synced at least once and the active index is loaded; 503 while the first sync or index build runs. A failed later sync keeps the last good state and stays ready. | readiness, plus a startup probe with enough budget for the first sync (the Python service was killed mid-build when its `/health` returned 503 during the initial sync) |
| `GET /api/v1/status` | the full picture for humans and the Settings page: index commit and age, counts, queue lengths, and one entry per registered adapter with its last check result and latency. | none |
# Names the pool, which POOL_NO_REDUNDANCY does not.
#
# There is no pool-size metric, but bytes_used/stored IS the
# replication factor. The >1e6 floor drops pools too small to measure
# meaningfully, empty ones included.
# There is no pool-size metric, but for a REPLICATED pool
# bytes_used/stored IS the replication factor. The >1e6 floor drops
# pools too small to measure meaningfully, empty ones included.
#
# No pool is exempt.
# ERASURE-CODED POOLS ARE EXCLUDED, AND MUST STAY EXCLUDED (REN-88).
#
# That ratio is not a replication factor for an EC pool, it is the
# coding overhead: k+m shards over k data shards. For the two ec:2+1
# pools here that is 3/2 = exactly 1.5 — sitting precisely on this
# rule's own threshold, on the wrong side of it half the time.
# Measured on `cephfs-bulk` (pool 15): used 480042086400 / stored
# 320028049408 = 1.5000000384, a margin of 12 KiB in 480 GB, so which
# way the comparison lands is decided by the last bits of a float
# division. It paged critical for 199 minutes on 2026-09-25 (12:37 to
# 15:56 UTC, two emails) with the cluster HEALTH_OK, zero degraded
# PGs and every OSD up, then cleared on its own. 50 of the 210
# samples in the preceding 30h were below 1.5; only that run held the
# 30m `for`. `migration.rgw.buckets.data` (pool 12) is the same ec:2+1
# shape and was one drift away from doing the same thing.
#
# The premise fails for EC too, not just the arithmetic: the old
# description claimed a single OSD loss destroys data, but 2+1 carries
# a parity shard and survives one OSD loss, same as replica-2.
#
# Do NOT "fix" this by nudging the threshold to 1.4 or 1.49. That
# keeps a knife-edge, just a quieter one, and it also narrows the real
# replica-2 -> replica-1 signal this rule exists to catch (1.0).
#
# EC pools keep full redundancy coverage without this rule: a real
# loss raises POOL_NO_REDUNDANCY, which is a live series in
# `ceph_health_detail` and is deliberately in scope for
# CephHealthWarn above. That check reads Ceph's own verdict instead of
# inferring one from a byte ratio, which is the right instrument here
# — an EC pool that loses a shard does not change its stored/used
# ratio at all, because Ceph does not delete the surviving shards.
# So there is no gap to reopen, and no second EC-specific threshold
# worth inventing.
#
# `on(pool_id)` is deliberately the whole matcher. ceph_pool_metadata
# carries labels ceph_pool_bytes_used does not (name, type,
# description, application, compression_mode), so default all-label
# matching would never join. Adding `cluster` would be defensive
# against a second Ceph cluster that does not exist, at the cost of
# the rule silently matching nothing if that label is ever dropped —
# an alert that fails open invisibly is the worse trade.
expr:|
ceph_pool_bytes_used / ceph_pool_stored < 1.5 and ceph_pool_stored > 1e6
ceph_pool_bytes_used / ceph_pool_stored < 1.5
and on(pool_id) ceph_pool_metadata{type="replicated"} == 1
and ceph_pool_stored > 1e6
for:30m
labels:
severity:critical
annotations:
summary:"Ceph pool {{ $labels.pool_id }} is running unreplicated"
description:"Pool {{ $labels.pool_id }} has a replication factor below 1.5, meaning a single OSD loss destroys data. No pool is meant to be unreplicated — an unreplicated pool also blocks ALL OSD maintenance cluster-wide, since `ceph osd ok-to-stop` refuses while its PGs would go offline. Check with `kubectl -n rook-ceph exec deploy/rook-ceph-tools -- ceph osd pool ls detail`."
summary:"Ceph replicated pool {{ $labels.pool_id }} is running unreplicated"
description:"Replicated pool {{ $labels.pool_id }} has a replication factor below 1.5, meaning it is down to a single copy and one OSD loss destroys data. No replicated pool is meant to be unreplicated — it also blocks OSD maintenance for as long as it lasts, since `ceph osd ok-to-stop` refuses while its PGs would go offline. Erasure-coded pools are out of scope for this rule and are covered by POOL_NO_REDUNDANCY via CephHealthWarn (REN-88). Check with `kubectl -n rook-ceph exec deploy/rook-ceph-tools -- ceph osd pool ls detail`."
- alert:CephPoolLostRedundancyBlind
# Canary for the rule directly above. Keep the two adjacent.
#
# CephPoolLostRedundancy is `critical` and it fails CLOSED and
# SILENTLY. Its expression joins three metrics on one label and
# filters on one specific label VALUE, and if any of those stops
# matching the expression returns nothing, which is indistinguishable
# from "every pool is fine". Health stays ok, no email is sent, and
# nothing anywhere reports that a critical storage alert has gone
# dark. Measured at REN-88 review time: `type` carried exactly two
# values, `replicated` and `erasure`, across 15 series — so the
# dependency is real, and a Rook or Ceph upgrade that renames the
# label, renames the value, or drops it in a relabel rule is enough.
#
# This expression is CephPoolLostRedundancy's with both thresholds
# removed, wrapped in absent(): "can that rule produce any series at
# all". It goes to 1 if either metric disappears, if `pool_id` is
# dropped from either side of the join, if the `type` label is
# renamed, or if `replicated` stops being its value.
#
# The one deliberate difference is that `ceph_pool_stored > 1e6` is
# NOT mirrored here. That floor is about data volume, not structure —
# replicated pools that are all legitimately tiny is a quiet state,
# not a broken dependency, and copying it in would make this fire for
# a benign reason.
#
# ONE CANARY PER FRAGILE JOIN, NOT ONE PER RULE GROUP. Judged on
# drift: a group-level canary has to enumerate every metric the group
# reads, and adding a rule to the group does not update it. Six months
# on it silently under-covers while LOOKING comprehensive, which is the
# same failure shape as a discovery step that passes having checked
# nothing. This one's scope is exactly one rule sitting six lines
# above it, so "is it still true" is answerable by reading the two
# blocks together. It can still drift — someone edits the expr above
# and not this — but that drift shows up in the same diff hunk, and
# when it does bite it FIRES rather than going quiet. For a guard,
# loud and wrong beats silent and wrong.
#
# 1h, not the 30m above. The exporter moved through 17 distinct mgr
# instances in 30d, and every one of those failovers makes all ceph_*
# metrics absent for as long as it takes the new mgr to be scraped;
# anything under ~15m would page on routine failover, and noise is
# what REN-88 was about. A long `for` costs nothing in detection value
# because the failure this catches is a schema change — permanent, not
# transient — and a blindness that self-heals inside an hour is not
# worth an email. Transient exporter loss is already covered by the
# chart's own TargetDown.
#
# `warning`, not `critical`: an alert stopped being able to fire.
# Storage is not broken.
expr:|
absent(
ceph_pool_bytes_used / ceph_pool_stored
and on(pool_id) ceph_pool_metadata{type="replicated"} == 1
)
for:1h
labels:
severity:warning
annotations:
summary:"CephPoolLostRedundancy cannot fire — its metrics no longer join"
description:"The CephPoolLostRedundancy alert has been unable to produce any series for an hour, so a `critical` storage alert is silently dark. It joins ceph_pool_bytes_used / ceph_pool_stored against ceph_pool_metadata{type=\"replicated\"} on `pool_id`; one of those metrics, the `pool_id` label, the `type` label, or the `replicated` value has stopped matching — most likely a Rook or Ceph upgrade, or a relabel rule. Check what the exporter emits now with `kubectl -n rook-ceph exec deploy/rook-ceph-tools -- ceph osd pool ls detail` and by querying `ceph_pool_metadata` directly, then fix the join in kubernetes/apps/monitoring-rules/prometheusrule-ceph.yaml — do not delete this canary (REN-93)."
- alert:CephHealthError
# HEALTH_ERR means data availability or redundancy is at risk.
description:"/var on {{ $labels.instance }} is under 30% free, which is Ceph's mon_data_avail_warn threshold, so the mon on this node will report MON_DISK_LOW. Find the actual consumer with `talosctl -n <node> usage /var/lib --depth 1` and then `/var/lib/containerd --depth 1` — note that overlayfs snapshots belong to running containers and are not reclaimable by image GC."
- alert:CephOSDNearfullApproaching
# Warns BEFORE Ceph's own nearfull check, which is the incident and
# not a warning. mon_osd_nearfull_ratio is 0.85 and is not
# overridden here.
#
# There is no rebalance to hide behind: failureDomain is host with
# size 3 on exactly three OSDs, so every OSD holds a copy of every
# replicated object and all three cross 0.85 within seconds of each
# 0.75 is 0.85 minus the largest bulk-tier burst on record. Measured
# at 30s resolution over the 14d to 2026-09-26: the fullest OSD went
# 71.63% -> 81.87% between 05:18 and 05:43Z (+10.24 pp, ~275 GiB raw)
# and reclaimed within the hour. At a SUSTAINED 75%, one more burst of
# that size reaches nearfull, which is the point at which the level
# itself is the finding.
#
# `for: 30m` is the shortest wait that outlives a self-reclaiming
# burst. That same burst held >= 75% for 19.0 min, so 30m clears it by
# 11 min; a permanent step holds it indefinitely. Do NOT shorten this
# to 15m — the 09-26 burst then pages, with the cluster reclaiming on
# its own while someone reads the mail.
#
# Nor should it be lengthened to 1h. The fastest observed SUSTAINED
# climb was +11.44 pp in one hour (2026-09-25 08:02-09:02Z, a real
# ingest that never reclaimed). From 75% that reaches 0.85 in ~53 min,
# so a 1h pending window would fire after nearfull, not before it.
# 30m fires at ~80.7% in that worst case — about 22 min of lead — and
# at 2.24 pp/h (the worst 6h-sustained rate) it fires at ~76.1%, about
# 4h of lead. The 30m/1h choice is the whole trade: a burst reclaims
# in ~20 min and a step does not, so ~30m is the earliest honest
# verdict. REN-162 is measuring the burst envelope; if a burst is ever
# seen holding >= 75% for more than ~25 min, this `for` moves out, not
# the threshold.
#
# Replay over the 14d to 2026-09-26 08:00Z: the worst 30m-sustained
# floor on any OSD was 71.632% (09-26 04:31Z), so this rule fires ZERO
# times on the observed normal shape, including the 09-26 excursion to
# 81.87% and the 09-25 step from 59% to 71%. Margin 3.37 pp.
#
# `max by (ceph_daemon)` is not cosmetic. rook-ceph-exporter pods are
# replaced often — 32 distinct series for three OSDs in 14 days — and
# the raw expression carries `pod` and `instance`, so every restart
# would change the alert identity and reset a pending window.
expr:|
max by (ceph_daemon) (
ceph_osd_stat_bytes_used / ceph_osd_stat_bytes
) >= 0.75
for:30m
labels:
severity:warning
annotations:
summary:"Ceph {{ $labels.ceph_daemon }} sustained above 75% — one bulk burst from nearfull"
description:"{{ $labels.ceph_daemon }} has been at or above 75% utilisation for 30 minutes ({{ $value | humanizePercentage }} now). That is 0.85 nearfull minus the largest bulk-tier burst measured here (+10.24 pp in 25 minutes), so a single repeat of that burst trips mon_osd_nearfull_ratio — and with failureDomain host, size 3 and three OSDs, it trips on all three at once with no rebalance available. This is sustained, not a spike: a burst clears in about 20 minutes and never holds this for 30. Find what grew with `kubectl -n rook-ceph exec deploy/rook-ceph-tools -- ceph df` and `ceph osd df tree`; the bulk tier (cephfs-bulk, pool 15) is the usual mover."
- alert:CephReplicapoolHeadroomLow
# The `replicapool` counterpart to the OSD rule above, in the units a
# reader can act on: GiB that still fit.
#
# These two are NOT independent signals and must not be read as
# corroboration. `ceph_pool_max_avail` for a size-3 pool on three
# equal OSDs is exactly (full_ratio - fullest_OSD_utilisation) x
# osd_size; verified against live data, the identity holds at 0.95 to
# five decimals. So this rule is the OSD series rescaled — a second
# TIER, deliberately set to trip after the OSD rule, not a second
# opinion. Ceph's own nearfull line of 0.85 sits at 89.4 GiB here.
#
# 160 GiB is what must still fit, measured:
# 89 GiB max_avail at the 0.85 nearfull line, i.e. the part of
# max_avail that is not ours to spend
# 38 GiB ha-pg's unconsumed allocation after the approved 20Gi ->
# 32Gi expansion: 2 instances x (31.33 GiB provisioned -
# 12.4 GiB used) as of 2026-09-26
# 35 GiB replicapool churn allowance — the worst observed single
# day (+14.3 GiB, 09-23) plus eleven days at the 14d mean
# of 1.9 GiB/day
# = 162 GiB, rounded DOWN to 160 so the rounding costs quiet, not
# headroom.
#
# Selecting on the pool NAME through ceph_pool_metadata, not on a
# hardcoded pool_id: ids are assignable and replicapool is only id 1
# by history. `on(pool_id)` is the whole matcher for the same reason
# given on CephPoolLostRedundancy above — ceph_pool_metadata carries
# labels ceph_pool_max_avail does not, so default matching never
# joins.
#
# `max by (pool_id)` twice over: it collapses the churning mgr `pod`
# and `instance` labels (8 series in 14 days), and during an mgr
# failover it takes the live value rather than a stale low one.
#
# `for: 30m` also covers a real exporter artefact: ceph_pool_max_avail
# reads exactly 0 for a single 30s sample after an mgr restart, twice
# in the 14d window (e.g. 09-23 12:27Z), on every mgr series at once.
# A `for` shorter than a few minutes would page on that alone. Note
# the glitch is one-directional, so it cannot cancel a genuine breach
# — 0 is still below the floor.
#
# Replay over the 14d to 2026-09-26 08:00Z: the lowest 30m-sustained
# ceiling was 208.97 GiB (09-26 04:31Z), so ZERO fires on the observed
# normal shape. The 09-26 burst took max_avail to 117.4 GiB for about
# 10 minutes and did not come close to holding it.
expr:|
max by (pool_id) (
ceph_pool_max_avail
and on(pool_id) ceph_pool_metadata{name="replicapool"}
description:"replicapool has had under 160 GiB usable for 30 minutes ({{ $value | humanize1024 }}B now). 160 GiB is the 89 GiB that max_avail still holds above Ceph's 0.85 nearfull line, plus the 38 GiB ha-pg has not yet consumed of its 32Gi expansion, plus 35 GiB of observed replicapool churn — below this, what is already committed no longer fits before nearfull. This is the same series as CephOSDNearfullApproaching rescaled (max_avail = (0.95 - fullest OSD) x OSD size), so it is a second tier and not independent confirmation. Check `kubectl -n rook-ceph exec deploy/rook-ceph-tools -- ceph df`; growth here is usually the bulk tier on the shared OSDs, not replicapool itself."
# Threshold: >5 segments sustained for 20m. A segment is 16 MiB, so 5
# is roughly 80 MiB of un-archived WAL — well below anything that
# threatens the volume, but far above idle noise.
#
# WHAT THIS RULE KEYS ON: persistence, not depth. The failure that
# costs something is an archiver that has *stopped*, and a stopped
# archiver can sit at 60 segments indefinitely without ever reaching
# some larger number. A depth threshold tuned above the nightly
# envelope below would miss exactly that case, and would need raising
# again every time the recorder grows. So there is one rule here, and
# the tuning knob is `for` (REN-152).
#
# THE NIGHTLY BURST, which this rule must stay silent through.
# ha-pg-1 queues WAL every night on the same clock:
#
# flat 0 until 02:12Z; fill from 02:13Z; peak 02:14-02:15Z;
# drained by 02:26Z at the worst. 02:12Z is 04:12 local, which is
# when Home Assistant's recorder purge runs.
#
# It is a write burst, not IO contention. Drain is a flat
# 13-15 segments/min every night — including nights when Kopia's
# repository-maintenance batch was saturating the RBD devices, and
# nights when it did not start until after the burst had already
# drained. That batch drifts across 02:03-02:24Z while the peak stays
# pinned to 02:15Z, and a drifting cause cannot produce an effect
# fixed to the clock. What varies is the fill (19-40 segments/min),
# so the height of the backlog is set by how much WAL is produced
# against an archiver with a flat ~14 segments/min ceiling.
#
# WHY 20m. Measured against Prometheus' full 27-night retention
# (2026-08-30..2026-09-26), at the real 30s scrape resolution, across
# all 12 CNPG instances. Only ha-pg-1 ever exceeds 5 at all; every
# other instance peaks at 2. Longest contiguous excursion above 5 on
# any night: 13.0 minutes, on 2026-09-11, at a peak of 141 segments.
# Firing evaluations over those 27 nights: 0 at 20m, and also 0 at
# 15m — but 15m leaves only 2 minutes of margin against the worst
# night, and the nightly peak climbed from 116 to 141 inside the
# measured month as the recorder grew. 20m leaves 7 minutes and does
# not have to be re-derived every time the database gets bigger.
#
# The 5 minutes 20m costs in detection latency buy nothing back: a
# real stall lasts hours, and at 32Gi (REN-141) the volume holds
# roughly four nights of stalled WAL before free space is in danger.
#
# `for` was 30m before REN-129. Outside the nightly windows the
# metric is a hard 0 on all 12 instances, so there was never a noise
# floor that needed 30m to wait out — the extra 10m only delayed the
# page.
#
# WHEN IT FIRES, the fix is drain rate or headroom, never a higher
# threshold here. ObjectStore `wal.maxParallel`
# (kubernetes/apps/home-assistant/ha-pg-objectstore.yaml) is unset,
# which the barman-cloud plugin reads as one WAL segment at a time;
# that is where the ~14 segments/min ceiling comes from. Headroom is
# `spec.storage.size` in ha-pg-cluster.yaml. Both are REN-135.
#
# `max by (namespace, pod)` is not cosmetic. The raw series carries
# `role`, which flips on failover; a changed label is a new series,
# and a new series restarts the `for` timer. Aggregating `role` away
# means a backlog that persists across a failover still pages 20m
# after the backlog started rather than 20m after the failover.
expr:max by (namespace, pod) (cnpg_collector_pg_wal_archive_status{value="ready"}) > 5
for:20m
labels:
severity:warning
annotations:
summary:"CNPG WAL archiving is falling behind on {{ $labels.namespace }}/{{ $labels.pod }}"
description:"{{ $value }} WAL segments have been waiting to archive for over 30 minutes. Point-in-time recovery is broken for anything after the last archived segment, and pg_wal will grow until the PVC fills. Check the barman-cloud sidecar logs and the ObjectStore credentials: `kubectl -n {{ $labels.namespace }} logs {{ $labels.pod }} -c plugin-barman-cloud`."
description:"{{ $value }} WAL segments have been waiting to archive for over 20 minutes. NOTE ON TIMING: a nightly write burst on ha-pg-1 at 02:14Z (04:14 local) is normal and drains itself within about 13 minutes, and this rule is tuned to stay silent through it — so if this fired, the backlog did NOT clear and the archiver is stuck. Point-in-time recovery is broken for anything after the last archived segment, and pg_wal will grow until the PVC fills. Check the barman-cloud sidecar logs and the ObjectStore credentials: `kubectl -n {{ $labels.namespace }} logs {{ $labels.pod }} -c plugin-barman-cloud`."
- alert:CNPGBackupFailed
# cnpg_collector_last_failed_backup_timestamp is a unix timestamp, 0
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.