Compare commits

...
Author SHA1 Message Date
9a3c6ef9af ci(monitoring-rules): machine-check PromQL with promtool (REN-161)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-26 08:25:59 +00:00
argus-bot (Paperclip service account) a003e1c126 Merge pull request 'test(uptime-monitor): restore the audiobooks EndpointMonitor, and correct the 403 reading — REN-7' (#1024) from atlas/ren-7-restore-audiobooks into master 2026-09-26 08:18:37 +00:00
argus-bot (Paperclip service account) 25b6fa573f Merge pull request 'docs(monitoring-rules): repoint stale gatus citation 176-185 -> 205-214 (REN-128)' (#1008) from atlas/ren-128-probes-blackbox-citation into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-09-26 08:14:37 +00:00
argus-bot (Paperclip service account) ea0abda79c Merge pull request 'fix(uptime-monitor): fail only on the ESO reasons that never retry (REN-106)' (#999) from atlas/ren-106-narrow-eso-failed into master 2026-09-26 08:10:58 +00:00
atlas-botandClaude Opus 5 8967db1001 test(uptime-monitor): restore the audiobooks EndpointMonitor, and correct the 403 reading — REN-7
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-26 08:08:29 +00:00
k.renner f96f04cd9d make paperclip externaly accasable
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-09-26 07:42:38 +02:00
argus-bot (Paperclip service account) 101c11f476 Merge pull request 'test(uptime-monitor): remove the audiobooks EndpointMonitor — REN-7 criterion 3 removal test' (#1018) from atlas/ren-7-delete-test-audiobooks into master
ci/woodpecker/cron/alert-monitor Pipeline failed
ci/woodpecker/cron/issue-fix Pipeline was successful
2026-09-26 03:56:13 +00:00
c2afbcdd4d docs(monitoring-rules): repoint stale gatus citation 176-185 -> 205-214 (REN-128)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-26 03:55:53 +00:00
argus-bot (Paperclip service account) 5f8b6ca0a9 Merge pull request 'feat(monitoring): Flux per-object readiness via kube-state-metrics (REN-102)' (#1013) from atlas/ren-102-flux-ksm-crs into master
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.
2026-09-26 03:54:03 +00:00
argus-bot (Paperclip service account) 15e81f8301 Merge pull request 'fix(cilium): declare the agent metrics Service so cilium_* is scraped (REN-147)' (#1017) from atlas/ren-147-cilium-agent-metrics-service into master
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
2026-09-26 03:53:28 +00:00
argus-bot (Paperclip service account)andPaperclip ec7a82eca9 feat(home-assistant): grow ha-pg storage 20Gi -> 32Gi (REN-141) (#1014)
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>
2026-09-26 03:52:21 +00:00
argus-bot (Paperclip service account) d9e54b40e9 Merge pull request 'feat(monitoring): make a stalled WAL archiver reportable (REN-129)' (#1010) from atlas/ren-129-wal-archive-backlog-visibility into master
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.
2026-09-26 03:51:58 +00:00
atlas-botandClaude Opus 5 f20fe8bf61 test(uptime-monitor): remove the audiobooks EndpointMonitor — REN-7 criterion 3 removal test
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-26 03:50:54 +00:00
c1b9b259b6 docs(home-assistant): correct the WAL stall attribution in ha-pg sizing (REN-141)
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-26 03:47:59 +00:00
argus-bot (Paperclip service account) 02a23d1670 test(monitoring): negative-control Probe for paperless ingress scoping (REN-28 PR 2a) (#1016)
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.
2026-09-26 03:46:43 +00:00
17c1dece48 fix(cilium): declare the agent metrics Service so cilium_* is scraped (REN-147)
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-26 03:44:02 +00:00
argus-bot (Paperclip service account) dd2256ea05 test(monitoring): temporary negative-test probes for REN-28 §7 (REN-140) (#1015)
ci/woodpecker/cron/alert-monitor Pipeline was successful
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>
2026-09-26 03:43:58 +00:00
argus-bot (Paperclip service account) d3f20d6d16 Merge pull request 'feat(netpol): scope the navidrome monitoring ingress to blackbox-exporter (REN-28)' (#1012) from atlas/ren-28-navidrome-monitoring-scope into master
ci/woodpecker/cron/issue-fix Pipeline was successful
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.
2026-09-26 03:40:22 +00:00
7aff525001 feat(monitoring): make a stalled WAL archiver reportable (REN-129)
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-26 03:37:15 +00:00
3b96b3ab1c feat(monitoring): negative-control Probe for paperless ingress scoping (REN-28 PR 2a)
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-26 03:34:55 +00:00
atlas-botandPaperclip 1c1a637c66 fix(netpol): drop the entity-scoped ICMP allow from navidrome (REN-144)
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-26 03:33:25 +00:00
6ee36d9f61 test(monitoring): temporary negative-test probes for REN-28 §7 (REN-140)
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-26 03:33:08 +00:00
dd6720f28d feat(home-assistant): grow ha-pg storage 20Gi -> 32Gi (REN-141)
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-26 03:30:40 +00:00
atlas-botandClaude Opus 5 088db00e62 feat(monitoring): Flux per-object readiness via kube-state-metrics (REN-102)
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-26 03:27:41 +00:00
argus-bot (Paperclip service account) 560b3588ea Merge pull request 'feat(uptime-monitor): pilot EndpointMonitors for www and audiobooks (REN-7)' (#1011) from atlas/ren-7-pilot-endpointmonitors into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
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.
2026-09-26 03:25:15 +00:00
argus-bot (Paperclip service account) f04d37aba4 Merge pull request 'feat(paperclip): bind the built-in view ClusterRole to the paperclip SA (REN-111)' (#1003) from atlas/ren-111-cluster-view-binding into master
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.
2026-09-26 03:23:24 +00:00
argus-bot (Paperclip service account)andClaude Opus 5 d1e168ea43 Merge pull request 'fix(monitoring): widen OpenBaoUnavailable for: 1m -> 5m (REN-131)' (#1009) from atlas/ren-131-openbao-unavailable-for into master
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>
2026-09-26 03:22:26 +00:00
atlas-botandClaude Opus 5 88010430ad feat(netpol): scope the navidrome monitoring ingress to blackbox-exporter (REN-28)
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-26 03:17:56 +00:00
31b1d1170d feat(uptime-monitor): pilot EndpointMonitors for www and audiobooks (REN-7)
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-26 03:16:55 +00:00
97b7f79b5f fix(monitoring): widen OpenBaoUnavailable for: 1m -> 5m (REN-131)
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-26 03:10:22 +00:00
atlas-botandClaude Opus 5 5f335847a3 docs(uptime-monitor): give the third ExternalSecret status shape a verdict (REN-106)
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-26 03:08:38 +00:00
argus-bot (Paperclip service account) f177685dbd Merge pull request 'docs(uptime-monitor): correct two comments in the IMC manifests (REN-6)' (#1007) from atlas/ren-6-imc-comment-corrections into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
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).
2026-09-26 03:05:11 +00:00
argus-bot (Paperclip service account) b061e3be9f Merge pull request 'docs: repoint seven stale line-number citations (REN-119)' (#1006) from atlas/ren-119-stale-citations into master
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.
2026-09-26 03:04:05 +00:00
argus-bot (Paperclip service account) 10240ae50b Merge pull request 'docs(paperclip): full repo paths for the two manifests behind the proxy grant (REN-47)' (#1005) from atlas/ren-47-manifest-paths into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
ci/woodpecker/cron/wiki-maintenance Pipeline failed
2026-09-26 00:53:23 +00:00
2821629eb1 docs(uptime-monitor): correct two comments in the IMC manifests (REN-6)
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-26 00:52:28 +00:00
atlas-botandClaude Opus 5 317ddbe950 docs: repoint seven stale line-number citations (REN-119)
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-26 00:50:26 +00:00
argus-bot (Paperclip service account) 64ee49ab7e Merge pull request 'feat(monitoring): scrape the Flux controllers (REN-97)' (#992) from atlas/ren-97-flux-metrics-alerts into master 2026-09-26 00:48:39 +00:00
atlas-botandClaude Opus 5 4c4501b42b docs(paperclip): full repo paths for the two manifests behind the proxy grant (REN-47)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-26 00:48:11 +00:00
argus-bot (Paperclip service account) d9ccaef89f Merge pull request 'docs(netpol): make the paperless probe citation a resolvable path (REN-116)' (#1004) from atlas/ren-116-probe-citation-path into master
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>
2026-09-26 00:46:14 +00:00
argus-bot (Paperclip service account) 1c00e8bf6d Merge pull request 'feat(uptime-monitor): deploy IngressMonitorController with zero monitors (REN-6)' (#995) from atlas/ren-6-imc-deploy into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
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.
2026-09-26 00:42:20 +00:00
atlas-botandClaude Opus 5 a8c48b54d6 docs(netpol): make the paperless probe citation a resolvable path (REN-116)
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
`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>
2026-09-26 00:39:57 +00:00
argus-bot (Paperclip service account) bebf9aad0e Merge pull request 'docs(paperclip): record how the read-only proxy grant behaves (REN-47)' (#1000) from atlas/ren-47-proxy-grant-handover-notes into master
ci/woodpecker/cron/issue-fix Pipeline was successful
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.
2026-09-26 00:38:55 +00:00
argus-bot (Paperclip service account) 0995e8d50b Merge pull request 'docs(netpol): correct three comments that assert a boundary Cilium does not enforce (REN-108)' (#997) from atlas/ren-108-trust-boundary-comments into master 2026-09-26 00:37:11 +00:00
atlas-botandClaude Opus 5 230756c35e feat(paperclip): bind the built-in view ClusterRole to the paperclip SA (REN-111)
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-26 00:37:09 +00:00
c40c337e36 feat(uptime-monitor): deploy IngressMonitorController with zero monitors (REN-6)
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-26 00:29:56 +00:00
atlas-botandClaude Opus 5 2b7502b5d3 docs(paperclip): record how the read-only proxy grant behaves (REN-47)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-26 00:29:42 +00:00
atlas-botandClaude Opus 5 c7d1b1ff4a feat(monitoring): scrape the Flux controllers (REN-97)
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-26 00:28:15 +00:00
atlas-botandClaude Opus 5 16c0c0a7ca fix(uptime-monitor): fail only on the ESO reasons that never retry (REN-106)
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-26 00:27:54 +00:00
a557eea7bb docs(netpol): correct three comments that assert a boundary Cilium does not enforce (REN-108)
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-26 00:25:56 +00:00
40 changed files with 4558 additions and 91 deletions
+77 -15
View File
@@ -17,30 +17,43 @@
# here is a static check that either passes or fails on the checkout alone, so it
# can gate PRs without becoming a bottleneck or a flake source.
when:
# Path-filtered on purpose, unlike ci-scripts-test.yml. That pipeline is one
# step on one already-cached image, so running it everywhere is free; this one
# pulls three images and builds every kustomization in the tree, which is real
# Path-filtered on purpose, unlike ci-scripts-test.yml. That pipeline is two
# steps on already-cached images, so running it everywhere is free; this one
# pulls four images and builds every kustomization in the tree, which is real
# cost to put on a docs-only PR. The filter covers everything the steps actually read:
# kubernetes/** for the manifests and scripts, .woodpecker/** for the lint (and
# so an edit to this file re-runs its own checks).
# kubernetes/** for the manifests, scripts and promtool suites, .woodpecker/**
# for the lint (and so an edit to this file re-runs its own checks), and the
# promtool driver script by name.
#
# That last entry is NOT covered by kubernetes/**: promtool-rules.sh lives
# under scripts/, so without it a PR that only edits the script would never run
# the check it drives. It is listed as one file rather than scripts/ci/**
# because the rest of that directory is CI-agent helpers with nothing to do
# with this pipeline, and pulling four images for them is the cost this filter
# exists to avoid. Their own tests run in ci-scripts-test.yml on every PR.
- event: pull_request
path:
- "kubernetes/**"
- ".woodpecker/**"
- "scripts/ci/promtool-rules.sh"
# ADDING A THIRD DISCOVERY CHECK: kustomize-build and shellcheck both hand-roll
# the same shape — `find`, a floor guard against empty discovery, then a
# file-fed `while read`. Two copies is deliberate: the two traps being commented
# around (BusyBox find lacking `-printf`, and a piped loop running in a subshell
# where `fail` cannot propagate) are different, and both are worth spelling out
# where they bite. A third instance is the point to stop copying and factor it
# into a shared script under `scripts/ci/` — which is already covered by
# ci-scripts-test.yml, so it would gain unit tests in the process.
# THE THIRD DISCOVERY CHECK WENT TO A SCRIPT. kustomize-build and shellcheck
# both hand-roll the same shape — `find`, a floor guard against empty discovery,
# then a file-fed `while read`. Two copies was deliberate: the two traps being
# commented around (BusyBox find lacking `-printf`, and a piped loop running in
# a subshell where `fail` cannot propagate) are different, and both are worth
# spelling out where they bite. An earlier revision of this comment said a third
# instance would be the point to stop copying and factor it into `scripts/ci/`,
# where it would gain unit tests — and that is what `promtool` below does. Its
# discovery, floors and guards live in scripts/ci/promtool-rules.sh and are
# tested by scripts/ci/test-promtool-rules.sh on every PR via
# ci-scripts-test.yml. A FOURTH inline copy should go the same way; do not add
# one here.
#
# Every step carries `depends_on: []` so all four run in PARALLEL and every
# Every step carries `depends_on: []` so all five run in PARALLEL and every
# failure shows in one run. Woodpecker's default is sequential, which would stop
# at the first failure and make a contributor fix-and-repush to discover the
# next — and these four are independent, cheap, offline checks with no ordering
# next — and these five are independent, cheap, offline checks with no ordering
# between them. Keep it on every step: `depends_on` is per-step, so dropping it
# from one silently re-serialises that step behind the others.
steps:
@@ -258,3 +271,52 @@ steps:
exit 1
fi
echo "ok no digest pins under openbao/rotation/"
# Machine-check the alert rules: parse every PrometheusRule, run the committed
# promtool unit suites, then damage the rules on purpose and require the suites
# to notice. Everything it does lives in scripts/ci/promtool-rules.sh; read
# that file and kubernetes/apps/monitoring-rules/tests/README.md for the why.
#
# The gap it closes: the other four steps prove a PrometheusRule assembles into
# valid YAML and nothing more — not that an expression parses, not that a label
# join matches anything, not that a `for` window does what its comment claims.
# On REN-129 the CNPG surge rule was written `for: 2m`, which fires on six of
# seven measured nights and would have mailed the owner twice a night at 04:16
# and 04:22 local for a backlog that clears itself. Nothing here would have
# caught it, and the arithmetic that did catch 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 mutations.txt:surge-for-2m.
#
# Offline, like the rest of this pipeline: no cluster, no registry, no secrets,
# no network. promtool reads files and exits.
#
# IMAGE — prom/prometheus, not a registry-local ci-* image, and pinned to an
# exact upstream version rather than a dated tag. The dated-tag convention
# guarded by no-digest-pins above is about OUR images, whose digests are
# garbage-collected out from under a pin; this is upstream and immutable.
#
# The version must be 3.x, and that is not a preference. The cluster runs
# whatever kube-prometheus-stack >= 72.0.0 ships, which is Prometheus 3, and
# alert `for` evaluation is version-sensitive — a 2.x promtool would be
# checking a different evaluator than production, so the margins the suites
# assert would be claims about the wrong program. promtool-rules.sh asserts the
# major version it was handed and fails on 2.x, so a careless bump here is a
# red step rather than a quietly wrong check. Keep this at the latest stable
# 3.x, per the standing rule that any software uses the latest stable version.
#
# This image is NOT covered by image-automation (same reason as
# woodpecker-lint above: a Woodpecker pipeline file has no apiVersion/kind, so
# an $imagepolicy marker here is silently ignored — fluxcd/flux2#2826). The
# bump is manual.
#
# `prom/prometheus` is busybox-based and ships /bin/sh, so it can run a
# `commands:` block — unlike the plain woodpecker-cli tag, which cannot. Its
# find/grep/awk/sed are BusyBox, which is why promtool-rules.sh avoids
# `find -printf` and `grep -r --include`; it was verified against BusyBox
# 1.35.0 as well as GNU. The image also declares `USER nobody`, so the script
# writes its stripped mirror under mktemp and never into the workspace.
- name: promtool
depends_on: [] # see note above `steps:`
image: prom/prometheus:v3.15.0
commands:
- sh scripts/ci/promtool-rules.sh
+27 -3
View File
@@ -8,15 +8,39 @@
# against adversarial PR input. Running it here fails the offending PR's checks
# before it can merge.
#
# scripts/ci/test-promtool-rules.sh is the same idea for the alert-rule gate.
# What it tests is not whether promtool evaluates PromQL — ci-manifests-test.yml
# does that with the real binary against the real rules — but whether
# promtool-rules.sh REFUSES TO REPORT SUCCESS when it has checked nothing: zero
# manifests discovered, zero suites discovered, a `rule_files` path that does not
# resolve (promtool warns, loads zero rules, and passes every assertion), a
# mutation the suites no longer catch. Those are the states a green pipeline
# cannot tell apart from real coverage, so they need a test that asserts the
# failure. It stubs promtool, so it needs neither the binary nor the heavy image.
#
# Both are unfiltered by path on purpose: this pipeline is two steps on one
# already-cached image, so running it everywhere is free, and a guard whose test
# only runs when someone remembers to touch the right directory is not a guard.
#
# Only in homelab (scripts/ci/ lives here); the file is not deployed to the
# mirrored repos, so it does not run on their PRs. The test is offline (no token,
# no network, no cluster) — the ci-claude image just provides sh + jq, and it is
# already pulled by pr-review.yml on the same event, so the runner has it cached.
# mirrored repos, so it does not run on their PRs. Both tests are offline (no
# token, no network, no cluster) — the ci-claude image just provides sh + jq, and
# it is already pulled by pr-review.yml on the same event, so the runner has it
# cached.
when:
- event: pull_request
# `depends_on: []` on both so they run in parallel and one failure does not hide
# the other. Same reasoning as the note above `steps:` in ci-manifests-test.yml.
steps:
- name: model-selection-tests
depends_on: []
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.280
commands:
- sh scripts/ci/test-model-selection.sh
- name: promtool-rules-guard-tests
depends_on: []
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.280
commands:
- sh scripts/ci/test-promtool-rules.sh
@@ -68,8 +68,28 @@ All modeled on `kubernetes/apps/paperless/` unless stated.
`automountServiceAccountToken: true`. All agents share the pod filesystem, so all
of them can read that token — it is mounted anyway because the Role behind it
grants exactly `get` on `services/proxy` for
`kube-prometheus-stack-prometheus:9090` in `monitoring` and nothing else
(`prometheus-read-rbac.yaml`, REN-36). It was `false` until then.
`kube-prometheus-stack-prometheus:9090` and
`kube-prometheus-stack-alertmanager:9093` in `monitoring`, and nothing else
(`kubernetes/apps/paperclip/prometheus-read-rbac.yaml`, REN-36 then REN-95).
It was `false` until then.
Three things about that grant are worth knowing before you use or debug it:
- **It scopes the HTTP method, not the endpoint.** The apiserver maps GET→`get`,
POST→`create`, PUT→`update` on a service-proxy request, so `get` alone refuses
`POST /-/reload` and `POST /-/quit` in the authorizer. It does *not* narrow
which GET endpoints are reachable: the whole read surface of both services is,
including `/api/v1/status/config` (scrape credentials there render as
`<secret>`) and the web UI.
- **Force GET in the caller.** Prometheus accepts both GET and POST on
`/api/v1/query`; under this Role only the GET form works, so a client library
that switches to POST for long queries will 403 for a reason that has nothing
to do with the query.
- **If reads start 403ing, check the Service name against `resourceNames`
first.** `resourceNames` is matched verbatim, and
`kubernetes/apps/monitoring/helmrelease-prometheus.yaml` floats the chart at
`>=72.0.0` — a release rename breaks this path silently,
with everything still looking correct in git. The port-qualified form is the
only one granted: `svc`, `svc:9090` and `http:svc:9090` are three different
strings to the authorizer.
- Port 3100; `Service paperclip` ClusterIP :3100.
- Probes: startup and readiness on `GET /api/health` with `Host: localhost` (the
private-hostname guard rejects the pod IP); liveness is a TCP check, since
+1 -1
View File
@@ -44,7 +44,7 @@ metadata:
# production restart for a comment. This header is outside `data:`, so it is
# inert; the served config is byte-identical.
#
# * infrastructure/network-policies/paperless-ns.yaml:24-26 ALREADY admits
# * infrastructure/network-policies/paperless-ns.yaml:34-36 ALREADY admits
# the whole monitoring namespace on every port, and monitoring is where
# blackbox-exporter runs. Cilium unions the allow rules of every CNP
# selecting an endpoint, so no extra policy is required.
@@ -36,12 +36,32 @@ spec:
imageName: ghcr.io/cloudnative-pg/postgresql:16
storage:
storageClass: ceph-block
# Sized for the migrated statistics plus 7 days of states at the current
# rate: statistics 10.8M rows + statistics_short_term 1.9M (~1.8 GB with
# indexes in Postgres) and ~18M states rows for the purge_keep_days:7 window
# (~4 GB). 20Gi leaves room for autovacuum bloat and index churn.
# ceph-block has allowVolumeExpansion: true, so this can grow in place.
size: 20Gi
# Sized for WAL headroom during an archiving stall, not for table growth.
# The data itself needs far less: migrated statistics 10.8M rows +
# statistics_short_term 1.9M (~1.8 GB with indexes in Postgres) and ~18M
# states rows for the purge_keep_days:7 window (~4 GB).
#
# 20Gi left only 6.63 GiB free, and that is the number that matters here.
# WAL archiving stalled for 10 minutes and the un-archived backlog reached
# 1.84 GiB — 28% of that free space (REN-129).
#
# That stall was first blamed on Velero repository maintenance. It is not
# the cause: REN-136 measured seven nights and the archiver's sustained
# drain (~14 segments/min, its serial ceiling) is flat whether Kopia is
# running or not, and Kopia's nearest batch drifts across 02:03-02:24Z
# while the burst stays pinned to 02:13Z. What is real 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. Postgres cannot recycle a WAL
# segment until the archiver has shipped it, so a burst that arrives while
# archiving is stalled walks the volume toward full, and a full PGDATA
# stops the recorder outright.
#
# 32Gi takes free space to ~18.6 GiB, which puts the same 1.84 GiB backlog
# at 10% of headroom rather than 28%. In REN-136's terms: at 6.36 GiB free
# one stalled night survives and two fill the volume; ~18.6 GiB carries
# about four. Approved by the owner on REN-135.
# ceph-block has allowVolumeExpansion: true, so this grows in place.
size: 32Gi
plugins:
- name: barman-cloud.cloudnative-pg.io
isWALArchiver: true
@@ -1,8 +1,75 @@
---
# The agent metrics Service, declared here because the chart does not render
# one under our values (REN-147).
#
# cilium's templates/cilium-agent/service.yaml is gated twice:
#
# {{- if and .Values.agent (not .Values.preflight.enabled) .Values.prometheus.enabled }}
# {{- if (or .Values.prometheus.serviceMonitor.enabled .Values.prometheus.metricsService) }}
#
# infrastructure/cilium/helmrelease.yaml sets `prometheus.enabled: true` and
# `prometheus.serviceMonitor.enabled: false`, and leaves `metricsService` at its
# default of false. First gate passes, second does not. The chart's fallback
# branch is envoy-only and needs envoy in the agent pod, but envoy runs as its
# own DaemonSet here — so no `cilium-agent` Service was rendered at all.
#
# The consequence was silent: agents exported metrics on :9962 all along, the
# ServiceMonitor below selected a Service that did not exist, and the scrape
# pool sat at 0 active / 46 dropped targets. `cilium_*` had zero series, so
# CiliumBPFMapPressureHigh and CiliumBPFMapFull in prometheusrule-cilium.yaml
# evaluated an empty vector forever and could never fire.
#
# Why a Service here and not `prometheus.metricsService: true` in the
# HelmRelease: that flag is the chart-native fix and its rendered diff is
# exactly this one object (it appears in no other template — not the
# ConfigMap, not the DaemonSet). But setting it changes the HelmRelease values,
# which makes helm-controller run `helm upgrade` on the CNI release, and that
# three-way-merges against live objects. PROPOSAL-cilium-single-owner.md
# records that `cilium-config` has carried a `kubectl-patch` field manager; if
# any of that drift is still live, the upgrade reconciles it, and the
# `configmap.reloader.stakater.com/reload: cilium-config` annotation on the
# HelmRelease then rolls every agent node by node. A Service applied by the
# apps Kustomization cannot do that. If the drift is ever proven gone, delete
# this object and set `prometheus.metricsService: true` instead.
#
# Name and selector follow authentik-outpost-servicemonitors.yaml: a distinct
# name plus `app.kubernetes.io/managed-by: homelab`, so flipping either chart
# flag later renders the chart's own `cilium-agent` Service without an
# ownership conflict and without double-scraping the same endpoints.
apiVersion: v1
kind: Service
metadata:
name: cilium-agent-metrics
namespace: kube-system
labels:
app.kubernetes.io/managed-by: homelab
app.kubernetes.io/name: cilium-agent
app.kubernetes.io/part-of: cilium
# Copied onto every series by the ServiceMonitor's targetLabels below.
# The dashboard the chart ships queries
# cilium_bpf_map_pressure{k8s_app="cilium"}, so this label is load-bearing.
k8s-app: cilium
spec:
type: ClusterIP
# Headless on purpose: agent metrics are per-node and every endpoint has to
# be scraped. A ClusterIP would hand Prometheus one random agent per scrape.
clusterIP: None
# Agent pods only. cilium-envoy carries k8s-app: cilium-envoy and exposes
# 9964, so it cannot match.
selector:
k8s-app: cilium
ports:
# Named and numbered exactly as the chart's own Service, so the
# ServiceMonitor below stays a field-for-field copy of the chart's.
- name: metrics
port: 9962
targetPort: prometheus
protocol: TCP
---
# Cilium's agent ServiceMonitor, declared here rather than by the chart.
#
# Reproduces what cilium 1.20.1 renders with prometheus.serviceMonitor.enabled,
# taken field for field off the live object so scraping is unchanged: same name,
# same namespace, same selector, same 10s interval and node relabeling.
# Reproduces what cilium renders with prometheus.serviceMonitor.enabled: same
# name, same namespace, same 10s interval and node relabeling.
#
# It lives here because the chart's template is gated on monitoring.coreos.com/v1
# existing at install time, and on an empty cluster it does not — those CRDs
@@ -22,13 +89,14 @@ metadata:
app.kubernetes.io/part-of: cilium
spec:
selector:
# Pinned to the Service above. Without managed-by this would also match a
# chart-rendered `cilium-agent` Service and scrape the same endpoints twice.
matchLabels:
app.kubernetes.io/managed-by: homelab
app.kubernetes.io/name: cilium-agent
namespaceSelector:
matchNames:
- kube-system
# The chart's Service keeps rendering (prometheus.enabled), so the target and
# its `metrics` port are there regardless of who declares the ServiceMonitor.
targetLabels:
- k8s-app
endpoints:
@@ -0,0 +1,80 @@
# Scrape the Flux controllers' own metrics. Until this existed Prometheus held
# no `gotk_*` series at all, so nothing about Flux's own behaviour was
# queryable and no rule could be written about it (REN-97).
#
# THIS IS THE SCRAPE ONLY, and the readiness alert is not merely deferred —
# it cannot be written from this endpoint. `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
# no controller calls the recorder that populates them: grepping the six
# tagged source trees for RecordReadiness / RecordCondition / RecordSuspend
# returns zero call sites, and an unpopulated GaugeVec exports no series. So
# the readiness metrics would stay empty however this file is written. Flux
# moved per-object readiness to kube-state-metrics custom-resource-state
# (`gotk_resource_info{ready=...}`); that is REN-102, and it is a prerequisite
# for the not-Ready alert rather than a follow-up to it.
#
# What this scrape does yield, and why it is still worth merging on its own:
# * `gotk_reconcile_duration_seconds` per kind/name/namespace (RecordDuration
# IS called, by all six controllers) — reconcile latency and, by its
# absence, an object that has stopped being reconciled at all.
# * controller-runtime's own series: reconcile totals and errors per
# controller, workqueue depth and retry counts, leader election.
# * the target itself, so `up{job="monitoring/flux-controllers"}` and the
# chart's default TargetDown rule start covering the Flux control plane.
# * the scrape REN-102 needs in place anyway.
#
# A PodMonitor, NOT a ServiceMonitor, and that is forced rather than preferred:
# gotk-components.yaml creates Services for only two of the six controllers
# (source-controller, notification-controller), and both publish port 80 ->
# targetPort `http`, which is the artifact/webhook server on :9090 — not the
# metrics port. kustomize-, helm-, image-reflector- and image-automation-
# controller have no Service at all, so there is nothing for a ServiceMonitor to
# select. All six pods do carry the same two labels and the same `http-prom`
# (:8080) container port, so one PodMonitor covers the set.
#
# NO NETWORK POLICY CHANGE IS NEEDED, verified rather than assumed, and unlike
# external-secrets-podmonitor.yaml which did need one: flux-system has no
# CiliumNetworkPolicy of its own (infrastructure/network-policies/ has no
# flux-system file), and gotk-components.yaml ships an `allow-scraping`
# NetworkPolicy admitting every namespace to :8080 (`from: [namespaceSelector:
# {}]`, port 8080). So `monitoring` is already admitted, as it was for REN-21.
# Nothing in the Flux install is touched.
apiVersion: monitoring.coreos.com/v1
kind: PodMonitor
metadata:
name: flux-controllers
namespace: monitoring
labels:
app.kubernetes.io/part-of: flux
# Not required for discovery — podMonitorSelector is empty, see
# apps/monitoring/helmrelease-prometheus.yaml — but set for consistency
# with the other monitors in this directory.
release: kube-prometheus-stack
spec:
# Lives in `monitoring` with a namespaceSelector rather than in flux-system,
# matching external-dns-servicemonitor.yaml and external-secrets-podmonitor.yaml.
# Flux owns every object in flux-system via gotk-components.yaml; keeping this
# out of that namespace means this change cannot interact with the Flux install
# itself.
namespaceSelector:
matchNames:
- flux-system
selector:
matchLabels:
app.kubernetes.io/part-of: flux
app.kubernetes.io/instance: flux-system
podMetricsEndpoints:
# Matches the controllers' `http-prom` port (containerPort 8080). Present on
# all six Deployments in gotk-components.yaml; a future controller without
# it simply yields no target rather than a broken scrape config.
- port: http-prom
path: /metrics
# 60s, not the 30s used elsewhere here, matching the ESO PodMonitor: the
# useful series are slow-moving per-object gauges and histograms over a
# few hundred Flux objects, and any rule built on them (REN-102) will use
# a multi-minute `for:`. 30s would double the samples for no gain in
# detection time — and Prometheus is already near its retention cap
# (REN-107).
interval: 60s
scrapeTimeout: 10s
@@ -30,6 +30,9 @@ resources:
- prometheusrule-dns-resolution.yaml
- probes-dns.yaml
- alloy-servicemonitor.yaml
# Agent metrics. Ships the metrics Service as well as the ServiceMonitor:
# the chart renders neither under our values, so this scraped nothing and
# the two CiliumBPFMapPressure alerts could never fire. See the file.
- cilium-servicemonitor.yaml
- cnpg-podmonitors.yaml
- crowdsec-servicemonitor.yaml
@@ -52,4 +55,18 @@ resources:
# infrastructure/network-policies/external-secrets-ns.yaml in the same change,
# or it scrapes nothing. See the file.
- external-secrets-podmonitor.yaml
# Flux's own control plane. The controllers were never scraped, so nothing
# about Flux's behaviour was queryable. Scrape only: per-object readiness is
# not on this endpoint at the pinned controller versions and needs
# kube-state-metrics instead. See the file.
- flux-podmonitor.yaml
# The readiness half the PodMonitor above cannot provide. Reads
# `gotk_resource_info`, which comes from the kube-state-metrics
# customResourceState block in apps/monitoring/helmrelease-prometheus.yaml —
# these two files must land and be reverted together.
- prometheusrule-flux.yaml
- probes-blackbox.yaml
# TEMPORARY, REN-140. Negative-test probes for the REN-28 §7 bar, taking the
# "reachable before" reading that stops being takeable once the paperless PR
# merges. Delete this line and the file once the "after" reading is recorded.
- probes-ren28-negative.yaml
@@ -104,12 +104,15 @@ spec:
# cnp-cross-namespace-allow-apps-gatus-to-monitored-services fails to
# apply here. It applies: its Generalization section names
# blackbox-exporter by hand. The reason we are safe is that the allow
# that note demands is ALREADY PRESENT — the navidrome baseline CNP
# admits the whole monitoring namespace on every port
# (infrastructure/network-policies/navidrome-ns.yaml:25-27), which is
# where blackbox-exporter runs. Run that check for the next namespace
# you add; do not skip it on the grounds that the note "is about
# gatus". Nothing in the navidrome namespace is edited by this change.
# that note demands is PRESENT AND DEDICATED — the navidrome baseline
# CNP (infrastructure/network-policies/navidrome-ns.yaml) admits
# blackbox-exporter by pod label on exactly the two listeners these
# two probes reach, :4533 and :8686. It used to admit the whole
# monitoring namespace on every port; REN-28 narrowed it, and the
# ports in that rule are the ones these probes depend on, so a probe
# added here against a THIRD listener in that namespace would need
# that rule widened first. Run that check for the next namespace you
# add; do not skip it on the grounds that the note "is about gatus".
#
# EGRESS — the half the wiki note is silent on, and the half that is
# actually load-bearing here. Cilium enforces at BOTH ends, so the
@@ -153,7 +156,7 @@ spec:
# `flux reconcile kustomization monitoring-rules --with-source`.
#
# /ping on both: unauthenticated, and already the pods' own kubelet
# probe path (navidrome/deployment.yaml:61-74,
# probe path (navidrome/deployment.yaml:80-93,
# navidrome/lidarr.yaml:114-127). For lidarr that it is ungated is
# proven independently of the gate — lidarr.yaml:29 curls /ping in a
# loop BEFORE it has read the API key out of config.xml.
@@ -206,7 +209,7 @@ spec:
- http://lidarr.navidrome.svc.cluster.local:80/ping
# APP-HEALTH probe for paperless. Same rule as the block above
# (monitoring-vantage-point-rule); the blind spot it closes is the one
# gatus already documents at apps/gatus/configmap.yaml:176-185 —
# gatus already documents at apps/gatus/configmap.yaml:205-214 —
# through the paperless-gate outpost EVERY path, /api/ included,
# answers 302 to an unauthenticated caller, so no external probe of
# paperless.renner.uno can tell a live app from a live gate. That
@@ -215,10 +218,10 @@ spec:
#
# That gatus comment says the in-cluster probe "needs a
# CiliumNetworkPolicy first". That sentence is simply WRONG, not
# merely inapplicable: paperless-ns.yaml:24-26 already admits the
# merely inapplicable: paperless-ns.yaml:34-36 already admits the
# whole monitoring namespace, which is where blackbox-exporter runs,
# and Cilium unions the allow rules of every CNP selecting an
# endpoint (the mechanism apps/paperless/network-policy.yaml:8-12
# endpoint (the mechanism apps/paperless/network-policy.yaml:22-26
# relies on). The EGRESS paragraph in the block above applies here
# identically. Nothing in the paperless namespace is edited by this
# change.
@@ -310,3 +313,76 @@ spec:
static:
- proxy-1.renner.uno:33080
- proxy-2.renner.uno:33080
---
# NEGATIVE CONTROLS for the paperless ingress scoping (REN-28 PR 2a).
#
# These four targets are here TO BE REFUSED. They are not availability
# monitoring: every one of them is a workload in the `paperless` namespace that
# blackbox-exporter has no business reaching, and each must read
# `probe_success == 1` today and `0` once REN-28's PR 2b lands. That flip is the
# test. Without them, "the whole-namespace admit is gone" is an assertion about
# a CNP diff rather than a measured property of the cluster, and a later
# re-widening of the grant would be silent.
#
# Why this can only be the ingress policy under test: blackbox-exporter runs in
# `monitoring`, whose baseline CNP (infrastructure/network-policies/
# monitoring-ns.yaml) is ingress-only — it declares no `egress:` at all — and
# the cluster's default-deny.yaml is scoped `namespace: apps`, which covers
# neither `monitoring` nor `paperless`. So the only thing that can turn these
# red is paperless's own ingress policy, which is exactly the variable. Nothing
# else can make the test pass or fail.
#
# WHY A SEPARATE jobName, and why these must NEVER be folded into the
# blackbox-http Probe above: after PR 2b these targets fail permanently and BY
# DESIGN. BlackboxProbeFailing and BlackboxProbesAbsent
# (prometheusrule-blackbox.yaml:35,50) are both scoped to job="blackbox-http",
# so a distinct job is outside both and stays silent — and the hand-maintained
# instance!~ exclusion regex at :35, whose fragility that file warns about, does
# not have to grow a fifth entry. Adding these to blackbox-http instead would
# page the on-call forever.
#
# The inverted alert that gives these targets teeth — CNPNegativeControlReachable,
# `probe_success{job="blackbox-negative-control"} == 1` — ships in PR 2b, NOT
# here. Between 2a and 2b these controls are SUPPOSED to read 1, so the alert
# would fire on the correct state. Do not add it before the policy change.
#
# A bare TCP connect is the right probe: it completes or it times out, with no
# application-layer ambiguity about whether a 403 meant the policy or the app.
# Known, accepted side effect: the pprl-pg-rw connect opens and closes without a
# startup packet, so Postgres logs "incomplete startup packet" at LOG level once
# a minute until PR 2b lands and the connect stops arriving.
apiVersion: monitoring.coreos.com/v1
kind: Probe
metadata:
name: blackbox-negative-control-paperless
namespace: monitoring
labels:
# Load-bearing, as noted at the top of this file: probeSelector matches on
# it. Without it this Probe is never discovered and the control reads
# `absent`, not `1` — which would look like a passing negative test.
release: kube-prometheus-stack
spec:
jobName: blackbox-negative-control
prober:
url: blackbox-exporter-prometheus-blackbox-exporter.monitoring.svc.cluster.local:9115
module: tcp_connect
interval: 60s
scrapeTimeout: 10s
targets:
staticConfig:
static:
# Proves BOTH halves of the pprl-pg scoping PR 2b will introduce: the
# source (blackbox is not Prometheus, and the pprl-pg-allow-prometheus
# rule 2b adds admits only Prometheus) and the port (5432 is not the
# exporter's 9187). Neither that rule nor paperless-allow-blackbox
# exists yet at this commit — that is the point, and why this target
# must read 1 now. Service is CNPG-generated from Cluster `pprl-pg`
# (paperless/cluster.yaml:4) and is the name paperless itself dials
# (paperless.yaml:46).
- pprl-pg-rw.paperless.svc.cluster.local:5432
# Three witnesses that the whole-namespace `monitoring` admit is gone
# for workloads no new rule selects. Ports from the Services themselves:
# redis.yaml:76, tika.yaml:57, gotenberg.yaml:63.
- redis.paperless.svc.cluster.local:6379
- tika.paperless.svc.cluster.local:9998
- gotenberg.paperless.svc.cluster.local:3000
@@ -0,0 +1,98 @@
# TEMPORARY — negative-test probes for REN-28 §7. DELETE AFTER THE "AFTER"
# READING IS TAKEN. This file exists to make two of the four §7 negative tests
# measurable without granting `pods/exec` in `monitoring`, which the owner
# declined on REN-140 as a wider grant than the change it would verify.
#
# WHAT IT IS. blackbox-exporter is already an HTTP/TCP prober running in the
# `monitoring` namespace. Pointing it at targets that the REN-28 paperless PR
# is supposed to CLOSE turns "is this path still open?" into a number in
# Prometheus, readable through the existing read-only REN-36 grant. Every
# target below answers today and MUST stop answering after the PR lands. A
# `probe_success` that stays 1 after the merge means the rule does not do what
# §5 claims.
#
# WHY IT DOES NOT PAGE ANYONE. jobName is `blackbox-ren28-negative`, not
# `blackbox-http`. Every rule in prometheusrule-blackbox.yaml is scoped to
# job="blackbox-http" (BlackboxProbeFailing) or absent(...{job="blackbox-http"})
# (BlackboxProbesAbsent), so neither can match these series — before or after.
# TargetDown (general.rules) cannot match either: the scrape target of a Probe
# is blackbox-exporter itself, which answers 200 with probe_success 0 on a
# failed probe, so `up` stays 1 regardless of the result. Verified against the
# live rule set on 2026-09-26. This is deliberate: a monitor that pages for an
# expected failure is worse than no monitor.
#
# NO POLICY CHANGE IS NEEDED TO MAKE THESE WORK TODAY. Ingress: the paperless
# baseline still admits the whole `monitoring` namespace on every port
# (infrastructure/network-policies/paperless-ns.yaml:34-36) — that admission is
# exactly what REN-28 removes. Egress: the `monitoring` namespace is in no
# egress enforcement at all; its only two Cilium policies, monitoring-ns.yaml
# and monitoring-alloy-syslog.yaml, are ingress-only. Same dependency the three
# cross-namespace targets in probes-blackbox.yaml already rest on.
#
# TWO POD-IP TARGETS. pprl-pg's :9187 metrics port is on the POD, not on any
# Service — that is why it is scraped by a PodMonitor and not a ServiceMonitor
# (cnpg-podmonitors.yaml). There is no stable name for it, so the literal pod
# IPs are used. They were read live on 2026-09-26; if either pod is rescheduled
# the probe goes red for the wrong reason. BEFORE TRUSTING AN "AFTER" READING,
# re-confirm the IP still belongs to the same pod:
#
# kube_pod_info{namespace="paperless", pod=~"pprl-pg-.*"} -> pod_ip
#
# The pprl-pg-r:5432 target below is the stable-name version of the same
# statement and survives a reschedule; it is on the database port rather than
# the metrics port, so the two are kept together on purpose.
#
# REVERT. Delete this file and its line in kustomization.yaml. Nothing else
# references it; no workload is touched; Prometheus drops the job on the next
# operator reconcile.
apiVersion: monitoring.coreos.com/v1
kind: Probe
metadata:
name: blackbox-ren28-negative
namespace: monitoring
labels:
# probeSelector is NOT loosened on this cluster (helmrelease-prometheus.yaml
# keeps ruleSelector/probeSelector/scrapeConfigSelector strict), so this
# label is load-bearing, not decorative. Without it the Probe is created and
# never discovered — and a test that silently does not run reads exactly
# like a test that passed.
release: kube-prometheus-stack
spec:
jobName: blackbox-ren28-negative
prober:
url: blackbox-exporter-prometheus-blackbox-exporter.monitoring.svc.cluster.local:9115
module: tcp_connect
interval: 60s
# 10s matches blackbox-tcp-relays. It is also the discriminator: a Cilium
# deny DROPS the SYN, so a refused path completes in ~1ms and a denied path
# burns the full timeout. probe_duration_seconds distinguishes "nothing is
# listening" from "policy stopped it" — probe_success alone does not.
scrapeTimeout: 10s
targets:
staticConfig:
static:
# REN-28 §7 test 4 — "from a monitoring pod that is not Prometheus, a
# connection to pprl-pg :9187 must time out". blackbox-exporter is
# precisely that pod. Both CNPG instances, because the PR's
# pprl-pg-allow-prometheus rule selects on a label both carry: if the
# selector were wrong in a way that left one instance open, one target
# would tell us and the other would not.
- 10.244.4.166:9187 # pprl-pg-2, node k8s-2
- 10.244.2.220:9187 # pprl-pg-3, node k8s-3
# Same statement on a stable name and the port that actually matters:
# after the PR the only monitoring→pprl-pg allow is Prometheus on
# :9187, so a non-Prometheus monitoring pod must not reach Postgres.
- pprl-pg-r.paperless.svc.cluster.local:5432
# REN-28 §7 test 2, workload dimension — "from blackbox-exporter, a
# paperless target other than the one it is scoped to must time out".
# The test as written in §7 says "a paperless POD port other than
# 8000"; there is no such port. The paperless pod's only other listener
# is the bridge-tls-proxy socat, bound to 127.0.0.1:11143
# (apps/paperless/paperless.yaml:289), so every other port on that pod
# is closed today and answers RST — there is no "reachable before" to
# record and the port half of paperless-allow-blackbox (:8000) cannot
# be tested at all. gotenberg is the half that CAN be: it is one of the
# other eight workloads the endpointSelector:{} baseline currently
# exposes to all of `monitoring`, and the PR's per-workload selector is
# what closes it.
- gotenberg.paperless.svc.cluster.local:3000
@@ -34,9 +34,20 @@ spec:
# query parameter, and an exact match would break silently if the
# instance label ever encodes it differently.
expr: probe_success{job="blackbox-http", instance=~"https://openbao\\.renner\\.uno/v1/sys/health.*"} == 0
# Two consecutive failed probes: enough to skip a single lost probe
# through Traefik, not enough to sit on a sealed store.
for: 1m
# Five minutes of continuously failing probes — about ten consecutive
# samples, since the probe and the rule evaluation both run at 30s
# (probes-blackbox.yaml, helmrelease-prometheus.yaml). Matches
# WoodpeckerDown above.
#
# This was 1m, the tightest probe window in the cluster by 5x, and it
# went pending on a single 5s HTTPS timeout with OpenBao healthy
# (REN-131). Isolated blips are ambient on this probe fleet at roughly
# one per two hours, so a window that clears on three samples pages on
# jitter. The widening costs nothing real: per the description below
# nothing breaks the moment OpenBao goes away, and OpenBaoQuorumLost
# (prometheusrule-openbao.yaml, for: 2m) still covers the fast
# failure, which is quorum loss rather than a lost probe.
for: 5m
labels:
severity: critical
annotations:
@@ -13,7 +13,7 @@ spec:
rules:
# The Postgres clusters back up to Hetzner. HetznerBackupEndpointDown
# probes that S3 endpoint, which proves the bucket is reachable and
# nothing more — these two rules check that something is actually being
# nothing more — these rules check that something is actually being
# written to it.
- alert: CNPGWalArchiveBacklog
@@ -23,22 +23,106 @@ spec:
# that moment, and pg_wal keeps growing until it fills the PVC and
# takes Postgres down.
#
# This is the load-bearing alert of the two. It reads the
# This is the load-bearing alert of the group. It reads the
# instance-level collector directly, so unlike the backup-age metric
# discussed below it does not depend on Cluster status being
# populated.
#
# Threshold: >5 segments sustained for 30m. Brief non-zero values are
# Threshold: >5 segments sustained for 15m. Brief non-zero values are
# normal under write bursts — 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.
expr: cnpg_collector_pg_wal_archive_status{value="ready"} > 5
for: 30m
#
# `for` was 30m and is now 15m (REN-129). To be clear about what that
# does and does not buy: this rule does NOT fire on the nightly write
# burst documented under the Surge rule below, at 30m or at 15m, and
# that is correct — across seven consecutive measured nights the
# longest contiguous excursion above 5 was 10 minutes and every one
# of them drained itself. What 15m is for is the case that actually
# costs something: an archiver that has stopped. Outside those
# nightly windows the metric is a hard 0 on all 12 CNPG instances in
# this cluster, so there was never a noise floor that needed 30m to
# wait out — the extra 15m only delayed that page.
#
# `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 15m
# after the backlog started rather than 15m after the failover.
expr: max by (namespace, pod) (cnpg_collector_pg_wal_archive_status{value="ready"}) > 5
for: 15m
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 15 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`."
- alert: CNPGWalArchiveBacklogSurge
# Companion to CNPGWalArchiveBacklog above, for the opposite failure
# shape: not a stuck archiver, but a working archiver that cannot
# drain as fast as Postgres is producing.
#
# CNPGWalArchiveBacklog cannot see that shape at any `for` value that
# is also safe against a flapping archiver, because the event is over
# before the window closes.
#
# What is normal here, measured on ha-pg-1 at 1-minute steps over the
# seven nights 2026-09-19 to 2026-09-26 (REN-129). Every night is the
# same burst, and nothing in that week exceeds 5 at any other hour:
#
# flat 0 until 02:12Z; fill from 02:13Z; peak 02:14-02:15Z;
# drained by 02:22Z. Nightly peaks 56, 91, 100, 101, 106, 113, 119.
#
# This is a write burst, not IO contention. Drain was 13.0-14.9
# segments/min on all seven nights — including the night whose Kopia
# repository-maintenance batch did not start until after the burst had
# already drained, and the night when two such jobs were saturating
# the RBD devices. 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 (18.7-39.7
# 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. 02:12Z is 04:12 local, which is when Home Assistant's
# recorder purge runs.
#
# So the nightly burst is a benign, self-clearing fact of this
# cluster and this rule has to stay silent through it. Threshold:
# >64 segments (1 GiB of un-archived WAL) for 8m.
#
# - 64, not something nearer the >5 above, because this rule is
# deliberately not a faster copy of that one. 1 GiB is where the
# backlog becomes a material fraction of a CNPG PVC's free space
# (ha-pg's volume is 20Gi, holding steady at 6.4-7.7 GiB free),
# and a rule that trips on every write spike is how the
# load-bearing rule above gets ignored.
# - 8m because the measured envelope spends at most 5 consecutive
# minutes above 64 on any of the seven nights, so 8m clears the
# worst observed night by 3 minutes. An earlier revision of this
# rule used 2m; that fires at ~02:16Z on 6 of the 7 nights, which
# is two owner emails a night at 04:16 and 04:22 local for a
# condition that repairs itself.
#
# What 8m still catches: 1 GiB queued that is not moving. From 119,
# the highest peak in the week, a healthy archiver is back under 64
# inside 5 minutes. Sitting above it for 8 means the archiver has
# lost most of its throughput, and that is worth a 04:00 look.
#
# When it does fire, 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)` for the same reason as the rule above:
# `role` flips on failover and would restart the `for` timer.
expr: max by (namespace, pod) (cnpg_collector_pg_wal_archive_status{value="ready"}) > 64
for: 8m
labels:
severity: warning
annotations:
summary: "CNPG WAL archiving cannot keep up on {{ $labels.namespace }}/{{ $labels.pod }}"
description: "{{ $value }} WAL segments (>1 GiB) have been queued for archiving for 8 minutes, which is longer than a healthy archiver needs to clear that much — from a backlog of 119 it takes under 5. Point-in-time recovery is degraded for everything after the last archived segment, and pg_wal grows against the PVC's free space until it clears. Start with the archiver: `kubectl -n {{ $labels.namespace }} logs {{ $labels.pod }} -c plugin-barman-cloud`, and check whether its ObjectStore sets `wal.maxParallel` — unset means one segment at a time, a ceiling of about 14 segments/min (REN-129)."
- alert: CNPGBackupFailed
# cnpg_collector_last_failed_backup_timestamp is a unix timestamp, 0
@@ -0,0 +1,274 @@
# Per-object Flux readiness, and the groundwork for the Ready-but-frozen
# ImagePolicy signal (REN-102, closing REN-97).
#
# EVERYTHING HERE READS `gotk_resource_info`, WHICH IS A kube-state-metrics
# SERIES, NOT A FLUX CONTROLLER SERIES. apps/monitoring-rules/flux-podmonitor.yaml
# scrapes the controllers and yields reconcile durations and controller-runtime
# counters, but readiness is not on that endpoint at the pinned controller
# versions: `gotk_reconcile_condition` and `gotk_suspend_status` are declared by
# fluxcd/pkg/runtime and populated by nothing. The series these rules read are
# created by the `customResourceState` block in
# apps/monitoring/helmrelease-prometheus.yaml. If that block is removed, every
# rule below goes blind — which is what FluxImagePolicyMetricsMissing exists to
# say out loud.
#
# THE STALENESS HALF DELIBERATELY SHIPS WITHOUT AN ALERT. See the second group.
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: flux
namespace: monitoring
labels:
# ruleSelector matches on this label; without it the rule is created but
# never loaded into Prometheus.
release: kube-prometheus-stack
spec:
groups:
- name: flux.rules
rules:
# Why `max by (...)` and not the bare selector: it collapses the KSM
# target labels (`pod`, `instance`, `endpoint`, ...). Without it, a
# kube-state-metrics rollout changes `pod`, which resolves the alert on
# the old series and re-fires it on the new one — a fresh mail for an
# unchanged failure. KSM rolled 11 times in the last 30 days, so this is
# routine, not hypothetical.
#
# Why `ready=~"False|Unknown"` and not `ready="False"`: Flux sets Ready
# False on failure but Unknown while a reconcile is in flight, and an
# object wedged in Unknown is stuck just as hard as one that is False —
# this repo has already had a Progressing CephCluster deadlock the whole
# apps layer (see rook-servicemonitors.yaml). Matching the two values
# explicitly, rather than `ready!="True"`, keeps an object whose `ready`
# label is absent entirely (freshly created, condition not yet written)
# out of the alert.
#
# Why `suspended!="true"` and not an `unless` join: `suspended` is a
# label on the same Info series, so one matcher does what the withdrawn
# draft needed a second vector for. The `!=` form also covers kinds where
# the label is absent. There is no deleted-object guard because KSM stops
# emitting the series when the object goes away — under `gotk_*` that
# guard had to be written, here it is structural.
#
# for: 2h — 31 of the 34 ImageRepositories scan at `interval: 1h`
# (2 at 30m: shelly-manager, vsftpd; 1 at 10m: wiki), so 91% of the
# population cannot recover from a transient registry failure faster
# than one hour whatever this value says. Anything under 1h mails about
# a single self-healing missed scan. 2h means two consecutive failed
# scans. Detection latency added by going through KSM is one 30s scrape.
- alert: FluxImageAutomationNotReady
expr: >-
max by (customresource_kind, name, exported_namespace) (
gotk_resource_info{
customresource_kind=~"ImageRepository|ImagePolicy|ImageUpdateAutomation",
ready=~"False|Unknown",
suspended!="true"
}
)
for: 2h
labels:
severity: warning
annotations:
summary: "Flux {{ $labels.customresource_kind }}/{{ $labels.name }} has not been Ready for 2h"
description: >-
An image-automation object has been not-Ready for two reconcile
intervals. Nothing is broken in the running cluster, but the image
it governs has stopped moving, security rebuilds included.
`flux get image all -n {{ $labels.exported_namespace }}` gives the
reason; the usual causes are registry credentials, a rate limit, or
a tag format the policy's `pattern`/`extract` no longer matches.
flux-notifications/alerts.yaml already mailed once at the moment of
transition — this is the "still broken two hours later" signal that
edge-triggered mail cannot give.
# The exact negation of the matcher above, so a kind added to
# helmrelease-prometheus.yaml's customResourceState config is covered by
# one of these two rules without anyone remembering to widen a list.
#
# for: 1h, derived from the intervals actually in this repo: all 33
# Kustomizations are at 10m (six retries inside the hour), all 25
# HelmReleases at 30m (two), and the flux-system GitRepository at 1m0s
# (sixty). The dominant non-failure source of ready="False" is a
# `dependsOn` wait, whose `reason` is not a label and so cannot be
# filtered out; an hour clears a whole-cluster reconcile chain.
#
# KNOWN TRADE, stated rather than hidden: this matcher also picks up the
# 17 HelmRepository and 4 OCIRepository objects, and every one of those is
# at `interval: 24h`. A failed index or manifest fetch there cannot
# self-heal inside an hour, so a transient failure yields up to two mails
# (repeat_interval is 12h) before the next scheduled reconcile. That is
# accepted as correct — a chart source stale for an hour is worth knowing
# — and it is real information rather than a blip. If it turns out to be
# the noisy one, the fix is a third rule for the 24h-interval kinds, not
# a global bump of this value, which would make Kustomization alerts
# useless.
- alert: FluxResourceNotReady
expr: >-
max by (customresource_kind, name, exported_namespace) (
gotk_resource_info{
customresource_kind!~"ImageRepository|ImagePolicy|ImageUpdateAutomation",
ready=~"False|Unknown",
suspended!="true"
}
)
for: 1h
labels:
severity: critical
annotations:
summary: "Flux {{ $labels.customresource_kind }}/{{ $labels.name }} has not been Ready for 1h"
description: >-
A Flux object outside image automation has been not-Ready for an
hour, so the desired state in git is not reaching the cluster.
`flux get all -A --status-selector ready=false` lists them with a
reason. For a Kustomization or HelmRelease this means a rollout is
stuck; for a HelmRepository or OCIRepository it means the chart or
artifact index is stale and dependent releases will not upgrade.
# THE DEAD MAN'S SWITCH, and the reason it is safe here and was not in
# #992. This asserts that the 34 ImagePolicy series exist — and they
# exist only because this change created them. Merged against `gotk_*`
# alone, as the withdrawn draft had it, this rule would have been
# permanently true and mailed admin@renner.uno every 12h forever.
#
# Its value is catching the removal of the customResourceState block, or
# a malformed edit to it, which would otherwise take all three rules
# above silently blind. `KubeStateMetricsDown` and `TargetDown` overlap it
# when the whole pod is down; neither notices a config that parses but
# emits nothing.
#
# for: 30m rides out the kube-state-metrics pod roll that applying this
# change itself causes, and any later roll.
- alert: FluxImagePolicyMetricsMissing
expr: absent(gotk_resource_info{customresource_kind="ImagePolicy"})
for: 30m
labels:
severity: critical
annotations:
summary: "Flux per-object readiness metrics have stopped being exported"
description: >-
kube-state-metrics is exporting no ImagePolicy series, so
FluxImageAutomationNotReady and FluxResourceNotReady cannot fire and
Flux readiness is unmonitored. Check the `customResourceState` block
in apps/monitoring/helmrelease-prometheus.yaml, then the
kube-state-metrics pod log — a custom-resource-state config that
fails to parse is logged there and yields no series.
# The Ready-but-frozen ImagePolicy signal: a policy whose `filterTags` or
# semver range excludes everything new keeps resolving the tag it resolved
# months ago, holds Ready=True the whole time, and no readiness rule can see
# it. `tika.yaml`'s own comment names the case.
#
# THIS GROUP DELIBERATELY CONTAINS NO ALERT, AND THAT IS A MEASURED DECISION,
# NOT AN OMISSION. Every candidate threshold was tested against the real
# release history and the real registries on 2026-09-26:
#
# * A flat "frozen for N days" duration does not exist. Over the 191
# automated bump commits in the 147 days to 2026-09-25, gatus went 127
# days between bumps and paperless-ngx 126, both provably healthy — they
# bumped. Six policies have never bumped at all. Any N below 127 fires on
# the healthy pair; any N above 147 is longer than the whole observation
# window.
#
# * The "tag count grew >= 10%" comparator from this card's first design is
# wrong, and the registries say so. Of the 18 policies frozen 14 days or
# more, a 10% threshold fires on exactly three — gotenberg (+87 tags/14d
# on 526), valkey (+68 on 301), eclipse-mosquitto (+13 on 90) — and all
# three churn variant tags their filters reject on purpose. It does NOT
# fire on tika (+4 on 136 = 3.0%), which is the canonical case this card
# was opened for.
#
# * Relaxing to "grew by >= 1 tag" does catch tika, but then also fires on
# those same three plus velero-plugin-for-aws, whose `1.13.x` pin is
# deliberate. Alertmanager routes every severity to the same email
# receiver with repeat_interval: 12h, so that is four standing mails
# every twelve hours, indefinitely, for four non-faults.
#
# * `.status.lastScanResult.latestTags` looks like the way out and is not:
# the CRD defines it as "the first 10 tags when sorting all the tags in
# descending ALPHABETICAL order", which is not newest-first for any tag
# scheme in this repo.
#
# So the recorded series below materialise the candidate set and, more to the
# point, START THE HISTORY that a threshold has to be derived from — a real
# time series of tagCount per repository, which has never existed here. The
# alert is deferred to a follow-up card that reads 14+ days of it. Recording
# rules cannot mail anyone, so this costs nothing while it warms up.
#
# interval: 15m is LOAD-BEARING and is declared here rather than inherited.
# Global evaluationInterval is 30s and no other rule group in this directory
# sets one (45 of the 46 loaded groups run at 30s). The `>= 1250` warm-up
# guard below counts samples, so at 30s it would be satisfied in 10.4 hours
# instead of 13 days and the frozen series would read true for policies that
# had not been observed frozen at all.
- name: flux-image-policy-staleness.rules
interval: 15m
rules:
# Stage one exists only to strip the KSM target labels. Counting distinct
# tags means counting distinct series, and `gotk_resource_info` carries
# `pod` and `instance` — there were 11 distinct kube-state-metrics pod
# identities in the last 30 days, so counting the raw series would report
# "several tags seen" after every roll, including the roll this change
# itself causes, and would keep reporting it for the whole window.
- record: flux:image_policy_tag:info
expr: >-
max by (name, exported_namespace, source_name, tag) (
gotk_resource_info{customresource_kind="ImagePolicy"}
)
# Distinct resolved tags seen in a trailing day. A tag change retires one
# series and starts another, so `count by (...)` over `count_over_time`
# is a count of tag values, and `== 1` means the tag did not move.
- record: flux:image_policy_tags_seen:1d
expr: >-
count by (name, exported_namespace, source_name) (
count_over_time(flux:image_policy_tag:info[1d])
)
# The repository side, keyed by `source_name` so it joins on the actual
# `spec.imageRepositoryRef.name` reference rather than on the convention
# that a policy is named after its repository. All 34 pairs match by name
# today, so this is drift insurance, not a bug fix.
- record: flux:image_repository_tag_count:1d
expr: >-
max by (source_name, exported_namespace) (
label_replace(
max_over_time(gotk_image_repository_tag_count[1d]),
"source_name", "$1", "name", "(.+)"
)
)
# The candidate set: resolved tag unmoved for 14 days while the
# repository's tag count is genuinely higher than it was.
#
# 14d, not the 30d this card was first designed around, because 30d does
# not fit. `retentionSize: 30GB` binds long before `retention: 90d`:
# (time() - prometheus_tsdb_lowest_timestamp/1000)/86400 measured 26.63
# days on 2026-09-26, at 84.5% of the size cap and growing ~1.09 GB/day
# (REN-107). A 30d window can never be satisfied. 14d uses 53% of that
# floor.
#
# >= 1250 is the warm-up guard and its arithmetic is: at interval 15m a
# full 14d window holds 14*24*4 = 1344 samples, so 1250 is 93% of a full
# window = 13.02 days of history, leaving ~23h of slack for Prometheus
# restarts and KSM rolls. Without it the series would read true on day
# one, when "unmoved for 14 days" is simply not yet knowable. 13.02 days
# against a 26.63-day floor is 2.0x margin; re-check it with the
# lowest_timestamp query above if retention is ever cut.
#
# `last_over_time - min_over_time >= 1` and not a max/min ratio: a ratio
# is direction-agnostic and is satisfied by a tag count that DROPPED
# (registry pruning, an edited exclusionList, a partial scan) exactly as
# by one that grew. Comparing the current value against the window
# minimum only counts net growth.
- record: flux:image_policy_tag_frozen:14d
expr: >-
(
max_over_time(flux:image_policy_tags_seen:1d[14d]) == 1
and
count_over_time(flux:image_policy_tags_seen:1d[14d]) >= 1250
)
and on (source_name, exported_namespace) (
(
last_over_time(flux:image_repository_tag_count:1d[1h])
- min_over_time(flux:image_repository_tag_count:1d[14d])
) >= 1
)
@@ -0,0 +1,125 @@
# promtool unit suites for the PrometheusRules in this directory
`promtool test rules` suites, run on every pull request that touches
`kubernetes/**` by the `promtool` step in
[`.woodpecker/ci-manifests-test.yml`](../../../../.woodpecker/ci-manifests-test.yml)
via [`scripts/ci/promtool-rules.sh`](../../../../scripts/ci/promtool-rules.sh).
## Why this exists
Before this, CI proved that every `PrometheusRule` in the tree assembled into
valid YAML and nothing else. It did not check that an expression parses, that a
label join matches anything, or that a `for` window behaves the way its comment
claims.
That was not theoretical. On [REN-129](/REN/issues/REN-129) the surge rule in
`prometheusrule-cnpg-backup.yaml` 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. It was caught by a human writing the arithmetic out by hand,
twice, in two separate runs, and throwing the work away both times because
there was nowhere in the repo to put it. That rule is now
`mutations.txt:surge-for-2m`.
## Layout
| File | What it is |
| --- | --- |
| `cnpg-backup_test.yaml` | The suite. **Generated** — see below. |
| `generate-cnpg-backup-test.py` | Regenerates it from the REN-129 measurement table. |
| `mutations.txt` | Negative controls. Each must make the suite fail. |
## Adding a suite
Name it `<something>_test.yaml` in this directory. Discovery is by that suffix,
and `promtool-rules.sh` fails if it finds none, so a suite in the wrong place
is a red pipeline rather than a silent gap.
**`rule_files` entries are relative to this directory, not the repo root.**
promtool joins `dirname(suite)` onto each entry, so a manifest in the parent
directory is `../name.yaml`. Getting this wrong is not a test failure: promtool
prints `WARNING: no file match pattern …`, loads **zero rules**, and every
`exp_alerts: []` assertion then passes against an empty rule set. The first
draft of the suite here shipped exactly that way — ninety silence assertions,
green, testing nothing. `promtool-rules.sh` resolves every entry itself before
running promtool and fails on a miss, because promtool has no flag that makes
it fatal.
`promtool` cannot read a `PrometheusRule` directly: it wants a bare `groups:`
document, and a `PrometheusRule` carries that under `spec:`. The script strips
`spec:` into a temporary mirrored tree with the same relative layout and runs
there, so `../name.yaml` resolves in the mirror exactly as it reads here.
Nothing is written back into the checkout.
## Regenerating `cnpg-backup_test.yaml`
```sh
python3 kubernetes/apps/monitoring-rules/tests/generate-cnpg-backup-test.py \
> kubernetes/apps/monitoring-rules/tests/cnpg-backup_test.yaml
```
and commit the result. `--report` prints the reconstruction self-check table
instead of the suite.
The generator exists so the numbers in the suite are auditable: the seven
measured nights are rebuilt from the `(peak, peak minute, fill, drain)` tuples
Iris published on REN-129, not hand-typed, so a reader can check them against
the measurement rather than taking them on trust. It is not a hermetic
artifact, and **CI does not regenerate and diff it** — the promtool image has
no Python, and byte-equality with a generator is not what stops a suite from
being weakened. The mutation phase is.
Two things the generator refuses to emit, both because they happened:
- a window that does not cover the bursts. An earlier revision had `T0` at
`8 * 60 + 8` (08:08Z) instead of `2 * 60 + 8`, seven hours after every burst,
so all seven nights rebuilt as forty zeroes and their silence assertions
passed without touching a threshold. `assert_window_covers_bursts()` now
fails instead.
- a night that clears the threshold *sooner* than the measured one, which would
be a weaker silence test than the night it claims to stand for. Longer is
fine and expected — a linear decay at the published mean drain rate holds the
tail above 5 a minute or two longer than the real trace on four of the seven
nights, which makes the suite harder to pass, not easier.
## Negative controls
A suite that passes proves the rules match the assertions. It does not prove
the assertions reach the rules — both failure modes above were green. So
`promtool-rules.sh` runs a second phase: for each entry in `mutations.txt` it
breaks one value the rules depend on and requires the suites to **fail**. A
mutation nothing notices is reported as a hole in the suite.
This is also the answer to "why is that case in there". `surge-for-5m` is
silent on all seven real nights and fires only on the adversarial envelope
case, so deleting the envelope case makes `for: 8m` reducible to `5m` with the
suite still green. The control is what makes the case load-bearing.
Mutations are `name|manifest-path|sed-program`, applied to the stripped copy in
the mirror (two spaces shallower than the manifest on disk — anchor on
`^[[:space:]]*`). A program that matches nothing is a failure, not a skip:
otherwise renaming a rule quietly turns its control into a no-op.
## Reading `for` margins
Margin is **pending** time, which is one minute less than the run length. An
alert goes pending at the first evaluation where the expression is true and
fires when `now - activeAt >= for`, so N consecutive one-minute samples above a
threshold accumulate N-1 minutes. A trace with 5 samples above 64 does not fire
`for: 5m`; it fires `for: 4m`. The `>5 pend` and `>64 pend` columns in
`--report` are the numbers to compare a `for` against — not the sample counts
next to them.
## Running it locally
```sh
# promtool must be 3.x: the cluster runs kube-prometheus-stack >= 72.0.0, and
# alert `for` evaluation is version-sensitive, so a 2.x promtool would be
# checking a different evaluator than production. The script enforces this.
sh scripts/ci/promtool-rules.sh
```
The script's own guards — the discovery floors, the `rule_files` resolution, the
mutation phase — are tested offline and without promtool by
`scripts/ci/test-promtool-rules.sh`, which runs on every pull request via
`.woodpecker/ci-scripts-test.yml`.
@@ -0,0 +1,592 @@
# GENERATED by generate-cnpg-backup-test.py — see README.md in this
# directory before editing by hand.
#
# Unit tests for kubernetes/apps/monitoring-rules/prometheusrule-cnpg-backup.yaml.
# Every assertion below is either one of Iris's seven measured nights
# (REN-129) rebuilt from its published (peak, fill, drain) numbers, an
# adversarial combination of the extremes of those numbers, or a failure
# this cluster has not had but the rules exist to catch.
#
# `rule_files` entries are resolved RELATIVE TO THIS FILE'S DIRECTORY, not
# to the working directory promtool was invoked from — promtool joins
# dirname(testfile) onto each entry. So this is `../`, and it resolves in
# the mirrored tree scripts/ci/promtool-rules.sh builds, where the
# `spec:`-stripped manifests sit in the same layout as the real ones.
#
# A path that does NOT resolve is only a WARNING to promtool: it loads zero
# rules, every exp_alerts: [] assertion passes against nothing, and the
# suite exits 0. promtool-rules.sh resolves every entry itself before
# running and fails on a miss; that check is the only thing standing
# between a typo here and a green pipeline that tests nothing.
rule_files:
- ../prometheusrule-cnpg-backup.yaml
evaluation_interval: 1m
tests:
# ---------------------------------------------------------------------
# 1-7. THE SEVEN MEASURED NIGHTS. Both rules must stay silent through
# every one of them. This is the half of the suite that has already
# caught something: the surge rule shipped for review at `for: 2m` and
# fires here on 6 of these 7 nights (mutations.txt:surge-for-2m).
# ---------------------------------------------------------------------
# 2026-09-20: the nightly Home Assistant recorder purge at 04:12 local.
# peak 106 at 02:15Z, fill 35.3/min, drain 13.2/min -> 10 samples above 5 (9m pending vs for: 15m), 5 above 64 (4m pending vs for: 8m).
- name: nightly burst 2026-09-20 — recorder purge, benign, self-clearing
interval: 1m
input_series:
- series: 'cnpg_collector_pg_wal_archive_status{namespace="home-assistant",pod="ha-pg-1",container="postgres",role="primary",value="ready"}'
# 02:08Z .. 02:47Z, one sample per minute
values: '0 0 0 0 0 35 71 106 93 80 66 53 40 27 14 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0'
- series: 'cnpg_collector_pg_wal_archive_status{namespace="home-assistant",pod="ha-pg-1",container="postgres",role="primary",value="done"}'
# The cumulative archived counter on the same metric name. The
# rules must never read it; see mutations.txt:no-value-selector.
values: '148000 148014 148028 148042 148056 148070 148084 148098 148112 148126 148140 148154 148168 148182 148196 148210 148224 148238 148252 148266 148280 148294 148308 148322 148336 148350 148364 148378 148392 148406 148420 148434 148448 148462 148476 148490 148504 148518 148532 148546'
alert_rule_test:
# 02:15Z, backlog 106
- eval_time: 7m
alertname: CNPGWalArchiveBacklog
exp_alerts: []
# 02:15Z, backlog 106
- eval_time: 7m
alertname: CNPGWalArchiveBacklogSurge
exp_alerts: []
# 02:18Z, backlog 66
- eval_time: 10m
alertname: CNPGWalArchiveBacklog
exp_alerts: []
# 02:18Z, backlog 66
- eval_time: 10m
alertname: CNPGWalArchiveBacklogSurge
exp_alerts: []
# 02:22Z, backlog 14
- eval_time: 14m
alertname: CNPGWalArchiveBacklog
exp_alerts: []
# 02:22Z, backlog 14
- eval_time: 14m
alertname: CNPGWalArchiveBacklogSurge
exp_alerts: []
# 02:24Z, backlog 0
- eval_time: 16m
alertname: CNPGWalArchiveBacklog
exp_alerts: []
# 02:24Z, backlog 0
- eval_time: 16m
alertname: CNPGWalArchiveBacklogSurge
exp_alerts: []
# 2026-09-21: the nightly Home Assistant recorder purge at 04:12 local.
# peak 100 at 02:15Z, fill 33.3/min, drain 14.3/min -> 9 samples above 5 (8m pending vs for: 15m), 4 above 64 (3m pending vs for: 8m).
- name: nightly burst 2026-09-21 — recorder purge, benign, self-clearing
interval: 1m
input_series:
- series: 'cnpg_collector_pg_wal_archive_status{namespace="home-assistant",pod="ha-pg-1",container="postgres",role="primary",value="ready"}'
# 02:08Z .. 02:47Z, one sample per minute
values: '0 0 0 0 0 33 67 100 86 71 57 43 28 14 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0'
- series: 'cnpg_collector_pg_wal_archive_status{namespace="home-assistant",pod="ha-pg-1",container="postgres",role="primary",value="done"}'
# The cumulative archived counter on the same metric name. The
# rules must never read it; see mutations.txt:no-value-selector.
values: '148000 148014 148028 148042 148056 148070 148084 148098 148112 148126 148140 148154 148168 148182 148196 148210 148224 148238 148252 148266 148280 148294 148308 148322 148336 148350 148364 148378 148392 148406 148420 148434 148448 148462 148476 148490 148504 148518 148532 148546'
alert_rule_test:
# 02:15Z, backlog 100
- eval_time: 7m
alertname: CNPGWalArchiveBacklog
exp_alerts: []
# 02:15Z, backlog 100
- eval_time: 7m
alertname: CNPGWalArchiveBacklogSurge
exp_alerts: []
# 02:17Z, backlog 71
- eval_time: 9m
alertname: CNPGWalArchiveBacklog
exp_alerts: []
# 02:17Z, backlog 71
- eval_time: 9m
alertname: CNPGWalArchiveBacklogSurge
exp_alerts: []
# 02:21Z, backlog 14
- eval_time: 13m
alertname: CNPGWalArchiveBacklog
exp_alerts: []
# 02:21Z, backlog 14
- eval_time: 13m
alertname: CNPGWalArchiveBacklogSurge
exp_alerts: []
# 02:23Z, backlog 0
- eval_time: 15m
alertname: CNPGWalArchiveBacklog
exp_alerts: []
# 02:23Z, backlog 0
- eval_time: 15m
alertname: CNPGWalArchiveBacklogSurge
exp_alerts: []
# 2026-09-22: the nightly Home Assistant recorder purge at 04:12 local.
# peak 101 at 02:15Z, fill 33.7/min, drain 14.4/min -> 9 samples above 5 (8m pending vs for: 15m), 4 above 64 (3m pending vs for: 8m).
- name: nightly burst 2026-09-22 — recorder purge, benign, self-clearing
interval: 1m
input_series:
- series: 'cnpg_collector_pg_wal_archive_status{namespace="home-assistant",pod="ha-pg-1",container="postgres",role="primary",value="ready"}'
# 02:08Z .. 02:47Z, one sample per minute
values: '0 0 0 0 0 34 67 101 87 72 58 43 29 15 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0'
- series: 'cnpg_collector_pg_wal_archive_status{namespace="home-assistant",pod="ha-pg-1",container="postgres",role="primary",value="done"}'
# The cumulative archived counter on the same metric name. The
# rules must never read it; see mutations.txt:no-value-selector.
values: '148000 148014 148028 148042 148056 148070 148084 148098 148112 148126 148140 148154 148168 148182 148196 148210 148224 148238 148252 148266 148280 148294 148308 148322 148336 148350 148364 148378 148392 148406 148420 148434 148448 148462 148476 148490 148504 148518 148532 148546'
alert_rule_test:
# 02:15Z, backlog 101
- eval_time: 7m
alertname: CNPGWalArchiveBacklog
exp_alerts: []
# 02:15Z, backlog 101
- eval_time: 7m
alertname: CNPGWalArchiveBacklogSurge
exp_alerts: []
# 02:17Z, backlog 72
- eval_time: 9m
alertname: CNPGWalArchiveBacklog
exp_alerts: []
# 02:17Z, backlog 72
- eval_time: 9m
alertname: CNPGWalArchiveBacklogSurge
exp_alerts: []
# 02:21Z, backlog 15
- eval_time: 13m
alertname: CNPGWalArchiveBacklog
exp_alerts: []
# 02:21Z, backlog 15
- eval_time: 13m
alertname: CNPGWalArchiveBacklogSurge
exp_alerts: []
# 02:23Z, backlog 0
- eval_time: 15m
alertname: CNPGWalArchiveBacklog
exp_alerts: []
# 02:23Z, backlog 0
- eval_time: 15m
alertname: CNPGWalArchiveBacklogSurge
exp_alerts: []
# 2026-09-23: the nightly Home Assistant recorder purge at 04:12 local.
# peak 119 at 02:15Z, fill 39.7/min, drain 14.9/min -> 10 samples above 5 (9m pending vs for: 15m), 5 above 64 (4m pending vs for: 8m).
- name: nightly burst 2026-09-23 — recorder purge, benign, self-clearing
interval: 1m
input_series:
- series: 'cnpg_collector_pg_wal_archive_status{namespace="home-assistant",pod="ha-pg-1",container="postgres",role="primary",value="ready"}'
# 02:08Z .. 02:47Z, one sample per minute
values: '0 0 0 0 0 40 79 119 104 89 74 59 44 30 15 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0'
- series: 'cnpg_collector_pg_wal_archive_status{namespace="home-assistant",pod="ha-pg-1",container="postgres",role="primary",value="done"}'
# The cumulative archived counter on the same metric name. The
# rules must never read it; see mutations.txt:no-value-selector.
values: '148000 148014 148028 148042 148056 148070 148084 148098 148112 148126 148140 148154 148168 148182 148196 148210 148224 148238 148252 148266 148280 148294 148308 148322 148336 148350 148364 148378 148392 148406 148420 148434 148448 148462 148476 148490 148504 148518 148532 148546'
alert_rule_test:
# 02:15Z, backlog 119
- eval_time: 7m
alertname: CNPGWalArchiveBacklog
exp_alerts: []
# 02:15Z, backlog 119
- eval_time: 7m
alertname: CNPGWalArchiveBacklogSurge
exp_alerts: []
# 02:18Z, backlog 74
- eval_time: 10m
alertname: CNPGWalArchiveBacklog
exp_alerts: []
# 02:18Z, backlog 74
- eval_time: 10m
alertname: CNPGWalArchiveBacklogSurge
exp_alerts: []
# 02:22Z, backlog 15
- eval_time: 14m
alertname: CNPGWalArchiveBacklog
exp_alerts: []
# 02:22Z, backlog 15
- eval_time: 14m
alertname: CNPGWalArchiveBacklogSurge
exp_alerts: []
# 02:24Z, backlog 0
- eval_time: 16m
alertname: CNPGWalArchiveBacklog
exp_alerts: []
# 02:24Z, backlog 0
- eval_time: 16m
alertname: CNPGWalArchiveBacklogSurge
exp_alerts: []
# 2026-09-24: the nightly Home Assistant recorder purge at 04:12 local.
# peak 56 at 02:14Z, fill 18.7/min, drain 14.0/min -> 6 samples above 5 (5m pending vs for: 15m), 0 above 64 (0m pending vs for: 8m).
- name: nightly burst 2026-09-24 — recorder purge, benign, self-clearing
interval: 1m
input_series:
- series: 'cnpg_collector_pg_wal_archive_status{namespace="home-assistant",pod="ha-pg-1",container="postgres",role="primary",value="ready"}'
# 02:08Z .. 02:47Z, one sample per minute
values: '0 0 0 0 19 37 56 42 28 14 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0'
- series: 'cnpg_collector_pg_wal_archive_status{namespace="home-assistant",pod="ha-pg-1",container="postgres",role="primary",value="done"}'
# The cumulative archived counter on the same metric name. The
# rules must never read it; see mutations.txt:no-value-selector.
values: '148000 148014 148028 148042 148056 148070 148084 148098 148112 148126 148140 148154 148168 148182 148196 148210 148224 148238 148252 148266 148280 148294 148308 148322 148336 148350 148364 148378 148392 148406 148420 148434 148448 148462 148476 148490 148504 148518 148532 148546'
alert_rule_test:
# 02:14Z, backlog 56
- eval_time: 6m
alertname: CNPGWalArchiveBacklog
exp_alerts: []
# 02:14Z, backlog 56
- eval_time: 6m
alertname: CNPGWalArchiveBacklogSurge
exp_alerts: []
# 02:17Z, backlog 14
- eval_time: 9m
alertname: CNPGWalArchiveBacklog
exp_alerts: []
# 02:17Z, backlog 14
- eval_time: 9m
alertname: CNPGWalArchiveBacklogSurge
exp_alerts: []
# 02:19Z, backlog 0
- eval_time: 11m
alertname: CNPGWalArchiveBacklog
exp_alerts: []
# 02:19Z, backlog 0
- eval_time: 11m
alertname: CNPGWalArchiveBacklogSurge
exp_alerts: []
# 2026-09-25: the nightly Home Assistant recorder purge at 04:12 local.
# peak 91 at 02:15Z, fill 22.8/min, drain 13.0/min -> 10 samples above 5 (9m pending vs for: 15m), 4 above 64 (3m pending vs for: 8m).
- name: nightly burst 2026-09-25 — recorder purge, benign, self-clearing
interval: 1m
input_series:
- series: 'cnpg_collector_pg_wal_archive_status{namespace="home-assistant",pod="ha-pg-1",container="postgres",role="primary",value="ready"}'
# 02:08Z .. 02:47Z, one sample per minute
values: '0 0 0 0 23 46 68 91 78 65 52 39 26 13 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0'
- series: 'cnpg_collector_pg_wal_archive_status{namespace="home-assistant",pod="ha-pg-1",container="postgres",role="primary",value="done"}'
# The cumulative archived counter on the same metric name. The
# rules must never read it; see mutations.txt:no-value-selector.
values: '148000 148014 148028 148042 148056 148070 148084 148098 148112 148126 148140 148154 148168 148182 148196 148210 148224 148238 148252 148266 148280 148294 148308 148322 148336 148350 148364 148378 148392 148406 148420 148434 148448 148462 148476 148490 148504 148518 148532 148546'
alert_rule_test:
# 02:15Z, backlog 91
- eval_time: 7m
alertname: CNPGWalArchiveBacklog
exp_alerts: []
# 02:15Z, backlog 91
- eval_time: 7m
alertname: CNPGWalArchiveBacklogSurge
exp_alerts: []
# 02:17Z, backlog 65
- eval_time: 9m
alertname: CNPGWalArchiveBacklog
exp_alerts: []
# 02:17Z, backlog 65
- eval_time: 9m
alertname: CNPGWalArchiveBacklogSurge
exp_alerts: []
# 02:21Z, backlog 13
- eval_time: 13m
alertname: CNPGWalArchiveBacklog
exp_alerts: []
# 02:21Z, backlog 13
- eval_time: 13m
alertname: CNPGWalArchiveBacklogSurge
exp_alerts: []
# 02:23Z, backlog 0
- eval_time: 15m
alertname: CNPGWalArchiveBacklog
exp_alerts: []
# 02:23Z, backlog 0
- eval_time: 15m
alertname: CNPGWalArchiveBacklogSurge
exp_alerts: []
# 2026-09-26: the nightly Home Assistant recorder purge at 04:12 local.
# peak 113 at 02:15Z, fill 28.5/min, drain 14.1/min -> 11 samples above 5 (10m pending vs for: 15m), 5 above 64 (4m pending vs for: 8m).
- name: nightly burst 2026-09-26 — recorder purge, benign, self-clearing
interval: 1m
input_series:
- series: 'cnpg_collector_pg_wal_archive_status{namespace="home-assistant",pod="ha-pg-1",container="postgres",role="primary",value="ready"}'
# 02:08Z .. 02:47Z, one sample per minute
values: '0 0 0 0 28 57 86 113 99 85 71 57 42 28 14 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0'
- series: 'cnpg_collector_pg_wal_archive_status{namespace="home-assistant",pod="ha-pg-1",container="postgres",role="primary",value="done"}'
# The cumulative archived counter on the same metric name. The
# rules must never read it; see mutations.txt:no-value-selector.
values: '148000 148014 148028 148042 148056 148070 148084 148098 148112 148126 148140 148154 148168 148182 148196 148210 148224 148238 148252 148266 148280 148294 148308 148322 148336 148350 148364 148378 148392 148406 148420 148434 148448 148462 148476 148490 148504 148518 148532 148546'
alert_rule_test:
# 02:15Z, backlog 113
- eval_time: 7m
alertname: CNPGWalArchiveBacklog
exp_alerts: []
# 02:15Z, backlog 113
- eval_time: 7m
alertname: CNPGWalArchiveBacklogSurge
exp_alerts: []
# 02:18Z, backlog 71
- eval_time: 10m
alertname: CNPGWalArchiveBacklog
exp_alerts: []
# 02:18Z, backlog 71
- eval_time: 10m
alertname: CNPGWalArchiveBacklogSurge
exp_alerts: []
# 02:22Z, backlog 14
- eval_time: 14m
alertname: CNPGWalArchiveBacklog
exp_alerts: []
# 02:22Z, backlog 14
- eval_time: 14m
alertname: CNPGWalArchiveBacklogSurge
exp_alerts: []
# 02:24Z, backlog 0
- eval_time: 16m
alertname: CNPGWalArchiveBacklog
exp_alerts: []
# 02:24Z, backlog 0
- eval_time: 16m
alertname: CNPGWalArchiveBacklogSurge
exp_alerts: []
# ---------------------------------------------------------------------
# 8. THE ADVERSARIAL ENVELOPE. A night that did not happen, built from
# the worst fill (39.7/min, 09-23), the highest peak (119, 09-23) and
# the slowest drain (13.0/min, 09-25) in the week. Inside what the
# cluster has demonstrated, worse than anything it actually did.
#
# This is where the two `for` windows get measured for margin rather
# than asserted about. Margin is PENDING time, which is one less than
# the run length — an alert goes pending on the first true evaluation
# and fires at activeAt + for, so 6 consecutive samples above 64
# accumulate 5 minutes, not 6:
#
# above 5: 11 samples -> 10m pending vs `for: 15m` -> 5m margin
# above 64: 6 samples -> 5m pending vs `for: 8m` -> 3m margin
#
# The worst MEASURED night accumulates 4m above 64 (5 samples, 09-20
# and 09-23), so the envelope is one minute tighter than anything that
# actually happened. 3 minutes is the number to defend if anyone
# proposes lowering `for: 8m` — and mutations.txt:surge-for-5m is the
# control that makes this case load-bearing rather than decorative: at
# `for: 5m` the surge rule is still silent on all seven real nights and
# fires only here. Delete this case and that mutation stops being
# caught.
# ---------------------------------------------------------------------
# Synthetic: max fill 39.7 + max peak 119 + min drain 13.0.
# peak 119 at 02:15Z, fill 39.7/min, drain 13.0/min -> 11 samples above 5 (10m pending vs for: 15m), 6 above 64 (5m pending vs for: 8m).
- name: adversarial envelope — worst measured fill and peak, slowest drain
interval: 1m
input_series:
- series: 'cnpg_collector_pg_wal_archive_status{namespace="home-assistant",pod="ha-pg-1",container="postgres",role="primary",value="ready"}'
# 02:08Z .. 02:47Z, one sample per minute
values: '0 0 0 0 0 40 79 119 106 93 80 67 54 41 28 15 2 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0'
- series: 'cnpg_collector_pg_wal_archive_status{namespace="home-assistant",pod="ha-pg-1",container="postgres",role="primary",value="done"}'
# The cumulative archived counter on the same metric name. The
# rules must never read it; see mutations.txt:no-value-selector.
values: '148000 148014 148028 148042 148056 148070 148084 148098 148112 148126 148140 148154 148168 148182 148196 148210 148224 148238 148252 148266 148280 148294 148308 148322 148336 148350 148364 148378 148392 148406 148420 148434 148448 148462 148476 148490 148504 148518 148532 148546'
alert_rule_test:
# 02:15Z, backlog 119
- eval_time: 7m
alertname: CNPGWalArchiveBacklog
exp_alerts: []
# 02:15Z, backlog 119
- eval_time: 7m
alertname: CNPGWalArchiveBacklogSurge
exp_alerts: []
# 02:19Z, backlog 67
- eval_time: 11m
alertname: CNPGWalArchiveBacklog
exp_alerts: []
# 02:19Z, backlog 67
- eval_time: 11m
alertname: CNPGWalArchiveBacklogSurge
exp_alerts: []
# 02:23Z, backlog 15
- eval_time: 15m
alertname: CNPGWalArchiveBacklog
exp_alerts: []
# 02:23Z, backlog 15
- eval_time: 15m
alertname: CNPGWalArchiveBacklogSurge
exp_alerts: []
# 02:26Z, backlog 0
- eval_time: 18m
alertname: CNPGWalArchiveBacklog
exp_alerts: []
# 02:26Z, backlog 0
- eval_time: 18m
alertname: CNPGWalArchiveBacklogSurge
exp_alerts: []
# ---------------------------------------------------------------------
# 9. THE FAILURE BOTH RULES EXIST FOR: an archiver that stopped with
# 70 segments (1.1 GiB) queued. Not a burst — it does not drain.
# Surge fires first (8m after the backlog crosses 64), the
# load-bearing rule 7 minutes later (15m after it crosses 5). Both
# assertions bracket the transition: one minute early nothing is
# firing, on the minute it is.
# ---------------------------------------------------------------------
- name: stalled archiver at 70 segments — both rules must fire
interval: 1m
input_series:
- series: 'cnpg_collector_pg_wal_archive_status{namespace="home-assistant",pod="ha-pg-1",container="postgres",role="primary",value="ready"}'
values: '0 0 0 0 0 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70'
- series: 'cnpg_collector_pg_wal_archive_status{namespace="home-assistant",pod="ha-pg-1",container="postgres",role="primary",value="done"}'
# Flat, because a stalled archiver archives nothing. The rules do
# not read this; it is here so the input stays realistic.
values: '148000 148000 148000 148000 148000 148000 148000 148000 148000 148000 148000 148000 148000 148000 148000 148000 148000 148000 148000 148000 148000 148000 148000 148000 148000 148000 148000 148000 148000 148000 148000 148000 148000 148000 148000 148000 148000 148000 148000 148000'
alert_rule_test:
# Backlog crosses 64 at t=5m, so `for: 8m` elapses at t=13m.
- eval_time: 12m
alertname: CNPGWalArchiveBacklogSurge
exp_alerts: []
- eval_time: 13m
alertname: CNPGWalArchiveBacklogSurge
exp_alerts:
# No `role` label: proof that `max by (namespace, pod)`
# aggregated it away. See mutations.txt:no-max-by.
- exp_labels:
severity: warning
namespace: home-assistant
pod: ha-pg-1
exp_annotations:
summary: "CNPG WAL archiving cannot keep up on home-assistant/ha-pg-1"
description: "70 WAL segments (>1 GiB) have been queued for archiving for 8 minutes, which is longer than a healthy archiver needs to clear that much — from a backlog of 119 it takes under 5. Point-in-time recovery is degraded for everything after the last archived segment, and pg_wal grows against the PVC's free space until it clears. Start with the archiver: `kubectl -n home-assistant logs ha-pg-1 -c plugin-barman-cloud`, and check whether its ObjectStore sets `wal.maxParallel` — unset means one segment at a time, a ceiling of about 14 segments/min (REN-129)."
# Backlog crosses 5 at t=5m, so `for: 15m` elapses at t=20m.
- eval_time: 19m
alertname: CNPGWalArchiveBacklog
exp_alerts: []
- eval_time: 20m
alertname: CNPGWalArchiveBacklog
exp_alerts:
- exp_labels:
severity: warning
namespace: home-assistant
pod: ha-pg-1
exp_annotations:
summary: "CNPG WAL archiving is falling behind on home-assistant/ha-pg-1"
description: "70 WAL segments have been waiting to archive for over 15 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 home-assistant logs ha-pg-1 -c plugin-barman-cloud`."
# ---------------------------------------------------------------------
# 10. THE STALL THE SURGE RULE CANNOT SEE: 12 segments, stuck. Under
# 1 GiB forever, so the surge rule is right to stay silent — and this
# is exactly the shape that makes the >5 rule load-bearing rather than
# a slower copy of it. Point-in-time recovery is just as broken at 12
# segments as at 70.
# ---------------------------------------------------------------------
- name: low stall at 12 segments — only the >5 rule may fire
interval: 1m
input_series:
- series: 'cnpg_collector_pg_wal_archive_status{namespace="home-assistant",pod="ha-pg-1",container="postgres",role="primary",value="ready"}'
values: '0 0 0 0 0 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12'
alert_rule_test:
- eval_time: 20m
alertname: CNPGWalArchiveBacklog
exp_alerts:
- exp_labels:
severity: warning
namespace: home-assistant
pod: ha-pg-1
exp_annotations:
summary: "CNPG WAL archiving is falling behind on home-assistant/ha-pg-1"
description: "12 WAL segments have been waiting to archive for over 15 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 home-assistant logs ha-pg-1 -c plugin-barman-cloud`."
# 34 minutes at 12 segments and the surge rule is still quiet.
- eval_time: 39m
alertname: CNPGWalArchiveBacklogSurge
exp_alerts: []
# ---------------------------------------------------------------------
# 11. PINNED AT EXACTLY 5 for the whole window. `> 5` is strict, so
# this is silence. A rule written `>= 5` would page on it, and a rule
# that had lost its `value="ready"` selector would page on the `done`
# counter regardless of what `ready` does — which is what the second
# series here is for.
# ---------------------------------------------------------------------
- name: pinned at exactly 5 — strict > means silence
interval: 1m
input_series:
- series: 'cnpg_collector_pg_wal_archive_status{namespace="home-assistant",pod="ha-pg-1",container="postgres",role="primary",value="ready"}'
values: '5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5'
- series: 'cnpg_collector_pg_wal_archive_status{namespace="home-assistant",pod="ha-pg-1",container="postgres",role="primary",value="done"}'
values: '148000 148014 148028 148042 148056 148070 148084 148098 148112 148126 148140 148154 148168 148182 148196 148210 148224 148238 148252 148266 148280 148294 148308 148322 148336 148350 148364 148378 148392 148406 148420 148434 148448 148462 148476 148490 148504 148518 148532 148546'
alert_rule_test:
- eval_time: 39m
alertname: CNPGWalArchiveBacklog
exp_alerts: []
- eval_time: 39m
alertname: CNPGWalArchiveBacklogSurge
exp_alerts: []
# ---------------------------------------------------------------------
# 12. FAILOVER DURING A STALL — the case `max by (namespace, pod)` was
# added for. The backlog starts at t=5m on the primary; at t=11m the
# instance is promoted and `role` changes, which is a NEW SERIES to
# Prometheus and restarts the `for` timer on the raw expression.
# Aggregating `role` away keeps one continuous series, so the page
# still lands 15m after the backlog started (t=20m) rather than 15m
# after the failover. Without `max by` this case is silent at t=20m —
# see mutations.txt:no-max-by.
# ---------------------------------------------------------------------
- name: role flip mid-stall — for timer must not restart
interval: 1m
input_series:
- series: 'cnpg_collector_pg_wal_archive_status{namespace="home-assistant",pod="ha-pg-1",container="postgres",role="primary",value="ready"}'
# t=0..10m, then this series ends.
values: '0 0 0 0 0 70 70 70 70 70 70'
- series: 'cnpg_collector_pg_wal_archive_status{namespace="home-assistant",pod="ha-pg-1",container="postgres",role="replica",value="ready"}'
# Starts at t=11m. `_` is a gap, not a zero.
values: '_ _ _ _ _ _ _ _ _ _ _ 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70'
alert_rule_test:
- eval_time: 19m
alertname: CNPGWalArchiveBacklog
exp_alerts: []
- eval_time: 20m
alertname: CNPGWalArchiveBacklog
exp_alerts:
- exp_labels:
severity: warning
namespace: home-assistant
pod: ha-pg-1
exp_annotations:
summary: "CNPG WAL archiving is falling behind on home-assistant/ha-pg-1"
description: "70 WAL segments have been waiting to archive for over 15 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 home-assistant logs ha-pg-1 -c plugin-barman-cloud`."
# ---------------------------------------------------------------------
# 13. ONE STALLED INSTANCE AMONG HEALTHY ONES. There are 12 CNPG
# instances in this cluster. `by (namespace, pod)` has to keep them
# apart: exactly one alert, naming the pod that is actually stuck, so
# the 04:00 reader is not sent to look at the wrong Postgres.
# ---------------------------------------------------------------------
- name: one stalled pod among healthy ones — exactly one alert
interval: 1m
input_series:
- series: 'cnpg_collector_pg_wal_archive_status{namespace="home-assistant",pod="ha-pg-1",container="postgres",role="primary",value="ready"}'
values: '0 0 0 0 0 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70'
- series: 'cnpg_collector_pg_wal_archive_status{namespace="home-assistant",pod="ha-pg-2",container="postgres",role="replica",value="ready"}'
values: '0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0'
- series: 'cnpg_collector_pg_wal_archive_status{namespace="immich",pod="immich-pg-1",container="postgres",role="primary",value="ready"}'
values: '0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0'
alert_rule_test:
- eval_time: 20m
alertname: CNPGWalArchiveBacklog
exp_alerts:
- exp_labels:
severity: warning
namespace: home-assistant
pod: ha-pg-1
exp_annotations:
summary: "CNPG WAL archiving is falling behind on home-assistant/ha-pg-1"
description: "70 WAL segments have been waiting to archive for over 15 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 home-assistant logs ha-pg-1 -c plugin-barman-cloud`."
# ---------------------------------------------------------------------
# 14. THE EPOCH-ZERO GUARD on CNPGBackupFailed. The metric is a unix
# timestamp and reads 0 when no failure was ever recorded, so the rule
# carries a `> 0` guard to stop that default being read as "failed in
# 1970". Held at 0 for the whole window: silence. Nothing else in this
# suite exercises that rule, and a dropped guard would page on every
# instance in the cluster forever.
# ---------------------------------------------------------------------
- name: never-failed backup reads timestamp 0 — must not fire
interval: 1m
input_series:
- series: 'cnpg_collector_last_failed_backup_timestamp{namespace="home-assistant",pod="ha-pg-1"}'
values: '0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0'
alert_rule_test:
- eval_time: 39m
alertname: CNPGBackupFailed
exp_alerts: []
@@ -0,0 +1,563 @@
#!/usr/bin/env python3
"""Regenerate cnpg-backup_test.yaml from the REN-129 measurements.
The committed YAML is what CI runs; this script exists so the numbers in it are
auditable and reproducible from the measurement table rather than being a
hand-typed artifact nobody can re-derive. Run it from the repo root:
python3 kubernetes/apps/monitoring-rules/tests/generate-cnpg-backup-test.py \
> kubernetes/apps/monitoring-rules/tests/cnpg-backup_test.yaml
Nothing in CI regenerates and diffs this — the promtool image has no Python, and
the defence against a hand-weakened suite is the mutation phase of
scripts/ci/promtool-rules.sh, not byte-equality with a generator. See README.md.
THE TRACE MODEL. Iris measured seven consecutive nights of
`cnpg_collector_pg_wal_archive_status{value="ready",pod="ha-pg-1"}` at 1-minute
steps and published, per night, a (peak, peak minute, fill seg/min, drain
seg/min) tuple. Those four numbers are consistent with one shape, which is the
shape every night has: flat 0, a linear ramp at `fill` per minute to `peak`,
then a linear decay at `drain` per minute back to 0. So a night is rebuilt as
ramp length = round(peak / fill) minutes
ramp start = peak minute - ramp length (value 0 at that minute)
during ramp = round(fill * minutes since ramp start)
at peak = peak (exact, not rounded from fill)
during decay = max(0, round(peak - drain * minutes since peak))
FIDELITY. The reconstruction reproduces Iris's published `Duration >5` column
exactly on 2026-09-20, -21 and -22, and runs one minute long on -23 and -24 and
two minutes long on -25 and -26. Every discrepancy is in the same direction:
the rebuild is never SHORTER above the threshold than the measurement. A linear
decay at the published mean drain rate holds the tail above 5 for slightly
longer than the real trace did, so each night is a marginally harder silence
test than the night it stands for. That is the safe direction for this suite,
whose whole job is to catch a rule that fires on a benign night. `--report`
prints the comparison; do not "fix" it toward the published column without
checking which way each night moves.
These are reconstructions, not the raw samples. They are faithful in the only
dimensions the rules read: how high the backlog goes, and how many consecutive
minutes it spends above each threshold. Do not read them as a recording.
"""
import argparse
import sys
# Iris's 7-night measurement, REN-129. `dur_gt5` is her published "Duration >5"
# column and is an INPUT TO THE SELF-CHECK ONLY — the traces are built from
# peak/fill/drain/peak_min, and dur_gt5 is what the result is compared against.
NIGHTS = [
# date peak peak_min fill drain dur_gt5
("2026-09-20", 106, "02:15", 35.3, 13.2, 10),
("2026-09-21", 100, "02:15", 33.3, 14.3, 9),
("2026-09-22", 101, "02:15", 33.7, 14.4, 9),
("2026-09-23", 119, "02:15", 39.7, 14.9, 9),
("2026-09-24", 56, "02:14", 18.7, 14.0, 5),
("2026-09-25", 91, "02:15", 22.8, 13.0, 8),
("2026-09-26", 113, "02:15", 28.5, 14.1, 9),
]
# The adversarial envelope: worst fill, worst peak, slowest drain observed in the
# week, combined into a night that did not happen but is inside what the cluster
# has demonstrated it can do. Nothing here is extrapolated past the measurements.
ENVELOPE = ("envelope", 119, "02:15", 39.7, 13.0, None)
# Series labels. `container`, `role` and `instance` are on the real series and
# are carried here on purpose: they are what `max by (namespace, pod)` has to
# aggregate away, and a test whose input is already aggregated cannot see whether
# the rule does that. `value="done"` is the cumulative counter that sits on the
# same metric name — it is in the input so that dropping the `value="ready"`
# selector from the rules is a detectable mutation and not a silent no-op.
NS = "home-assistant"
POD = "ha-pg-1"
def labels(pod=POD, value="ready", role="primary", ns=NS):
return (
"cnpg_collector_pg_wal_archive_status{"
f'namespace="{ns}",pod="{pod}",container="postgres",'
f'role="{role}",value="{value}"'
"}"
)
# Test window: t=0 is 02:08Z, 40 one-minute samples, so every night's burst sits
# inside it with lead-in and tail. promtool eval_times are relative to t=0, so
# every assertion below carries its UTC clock time in a comment.
#
# This MUST stay aligned with the peak minutes in NIGHTS. An earlier revision
# had `8 * 60 + 8` here (08:08Z), which put the whole window seven hours after
# every burst: all seven nights rebuilt as 40 zeroes and their ~90 silence
# assertions passed without exercising a single threshold. The
# assert_window_covers_bursts() call in main() exists so that cannot recur
# silently.
T0 = 2 * 60 + 8 # 02:08Z in minutes since midnight
SAMPLES = 40
def hhmm(minute):
return f"{minute // 60:02d}:{minute % 60:02d}"
def parse_hhmm(s):
h, m = s.split(":")
return int(h) * 60 + int(m)
def trace(peak, peak_min, fill, drain, n=SAMPLES, t0=T0):
"""Rebuild one night. See THE TRACE MODEL in the module docstring."""
ramp_len = max(1, round(peak / fill))
ramp_start = peak_min - ramp_len
out = []
for m in range(t0, t0 + n):
if m <= ramp_start:
v = 0
elif m < peak_min:
v = round(fill * (m - ramp_start))
elif m == peak_min:
v = peak
else:
v = max(0, round(peak - drain * (m - peak_min)))
out.append(v)
return out
def longest_run_above(vals, thr):
"""(length, index of last sample) of the longest run of vals > thr."""
best = (0, None)
run = 0
for i, v in enumerate(vals):
if v > thr:
run += 1
if run > best[0]:
best = (run, i)
else:
run = 0
return best
def pending_minutes(vals, thr):
"""Max minutes a `for` timer accumulates on `> thr` over this trace.
This is NOT the run length, and the difference is the whole margin
argument. Prometheus makes an alert pending at the first evaluation where
the expression is true and fires it when `now - activeAt >= for`, so a run
of N consecutive 1-minute samples above the threshold accumulates N-1
minutes of pending time, not N. A trace that spends 5 samples above 64
therefore does NOT fire `for: 5m`; it fires `for: 4m`. Quote this number,
not the run length, when arguing about a `for` window.
"""
n, _ = longest_run_above(vals, thr)
return max(0, n - 1)
def assert_window_covers_bursts():
"""Refuse to emit a suite whose trace window misses the bursts.
Guards the T0 bug described above T0: if the window and the peak minutes
ever drift apart again, every measured night rebuilds as flat zero and the
suite passes while testing nothing. A night that is flat 0 cannot be a
silence test for a burst, so treat it as a generator fault, not a result.
"""
for date, peak, pm, fill, drain, _dur in NIGHTS + [ENVELOPE]:
vals = trace(peak, parse_hhmm(pm), fill, drain)
if max(vals) != peak:
raise SystemExit(
f"generator fault: {date} rebuilt with max {max(vals)}, expected "
f"peak {peak}. The T0={T0} window ({hhmm(T0)}Z.."
f"{hhmm(T0 + SAMPLES - 1)}Z) does not cover peak minute {pm}Z. "
"Fix T0 or SAMPLES; do not emit a suite of flat-zero nights."
)
def counter(start, step, n=SAMPLES):
return [start + step * i for i in range(n)]
def fmt(vals):
return " ".join(str(v) for v in vals)
def eval_minutes(vals):
"""The minutes where a `for` window is closest to elapsing, so the tightest
places to assert silence. For each threshold: the last sample of the longest
run above it (the moment maximum pending time has accumulated). Plus the
peak, plus two minutes after the backlog has settled back to 0."""
picks = set()
for thr in (5, 64):
_, last = longest_run_above(vals, thr)
if last is not None:
picks.add(last)
picks.add(max(range(len(vals)), key=lambda i: vals[i]))
settled = next(
(i for i in range(len(vals) - 1, -1, -1) if vals[i] > 0),
len(vals) - 1,
)
picks.add(min(settled + 2, len(vals) - 1))
return sorted(picks)
L = []
def w(line=""):
L.append(line)
def burst_case(name, date, peak, peak_min_s, fill, drain, why):
peak_min = parse_hhmm(peak_min_s)
vals = trace(peak, peak_min, fill, drain)
n5, _ = longest_run_above(vals, 5)
n64, _ = longest_run_above(vals, 64)
w(f" # {why}")
w(
f" # peak {peak} at {peak_min_s}Z, fill {fill}/min, drain {drain}/min"
f" -> {n5} samples above 5 ({pending_minutes(vals, 5)}m pending vs"
f" for: 15m), {n64} above 64 ({pending_minutes(vals, 64)}m pending vs"
f" for: 8m)."
)
w(f" - name: {name}")
w(" interval: 1m")
w(" input_series:")
w(f" - series: '{labels()}'")
w(f" # {hhmm(T0)}Z .. {hhmm(T0 + SAMPLES - 1)}Z, one sample per minute")
w(f" values: '{fmt(vals)}'")
w(f" - series: '{labels(value='done')}'")
w(" # The cumulative archived counter on the same metric name. The")
w(" # rules must never read it; see mutations.txt:no-value-selector.")
w(f" values: '{fmt(counter(148_000, 14))}'")
w(" alert_rule_test:")
for i in eval_minutes(vals):
clock = hhmm(T0 + i)
for alert in ("CNPGWalArchiveBacklog", "CNPGWalArchiveBacklogSurge"):
w(f" # {clock}Z, backlog {vals[i]}")
w(f" - eval_time: {i}m")
w(f" alertname: {alert}")
w(" exp_alerts: []")
w("")
return vals, n5, n64
def main():
ap = argparse.ArgumentParser()
ap.add_argument(
"--report",
action="store_true",
help="print the reconstruction self-check table instead of the suite",
)
args = ap.parse_args()
assert_window_covers_bursts()
if args.report:
# `pend` columns are what the `for` windows are actually compared
# against; see pending_minutes(). `>5 pub` is Iris's published column
# and is the only number here that is not derived by this script.
print(f"{'night':12} {'peak':>5} {'fill':>6} {'drain':>6} "
f"{'>5 pub':>7} {'>5 n':>6} {'>5 pend':>8} "
f"{'>64 n':>6} {'>64 pend':>9}")
for date, peak, pm, fill, drain, dur in NIGHTS + [ENVELOPE]:
vals = trace(peak, parse_hhmm(pm), fill, drain)
n5, _ = longest_run_above(vals, 5)
n64, _ = longest_run_above(vals, 64)
print(f"{date:12} {peak:5} {fill:6} {drain:6} "
f"{('-' if dur is None else dur):>7} {n5:>6} "
f"{pending_minutes(vals, 5):>8} "
f"{n64:>6} {pending_minutes(vals, 64):>9}")
print()
print("for: 15m on >5 must exceed every '>5 pend' above.")
print("for: 8m on >64 must exceed every '>64 pend' above.")
return 0
w("# GENERATED by generate-cnpg-backup-test.py — see README.md in this")
w("# directory before editing by hand.")
w("#")
w("# Unit tests for kubernetes/apps/monitoring-rules/prometheusrule-cnpg-backup.yaml.")
w("# Every assertion below is either one of Iris's seven measured nights")
w("# (REN-129) rebuilt from its published (peak, fill, drain) numbers, an")
w("# adversarial combination of the extremes of those numbers, or a failure")
w("# this cluster has not had but the rules exist to catch.")
w("#")
w("# `rule_files` entries are resolved RELATIVE TO THIS FILE'S DIRECTORY, not")
w("# to the working directory promtool was invoked from — promtool joins")
w("# dirname(testfile) onto each entry. So this is `../`, and it resolves in")
w("# the mirrored tree scripts/ci/promtool-rules.sh builds, where the")
w("# `spec:`-stripped manifests sit in the same layout as the real ones.")
w("#")
w("# A path that does NOT resolve is only a WARNING to promtool: it loads zero")
w("# rules, every exp_alerts: [] assertion passes against nothing, and the")
w("# suite exits 0. promtool-rules.sh resolves every entry itself before")
w("# running and fails on a miss; that check is the only thing standing")
w("# between a typo here and a green pipeline that tests nothing.")
w("rule_files:")
w(" - ../prometheusrule-cnpg-backup.yaml")
w("evaluation_interval: 1m")
w("")
w("tests:")
w(" # ---------------------------------------------------------------------")
w(" # 1-7. THE SEVEN MEASURED NIGHTS. Both rules must stay silent through")
w(" # every one of them. This is the half of the suite that has already")
w(" # caught something: the surge rule shipped for review at `for: 2m` and")
w(" # fires here on 6 of these 7 nights (mutations.txt:surge-for-2m).")
w(" # ---------------------------------------------------------------------")
checks = []
for date, peak, pm, fill, drain, dur in NIGHTS:
vals, n5, n64 = burst_case(
f"nightly burst {date} — recorder purge, benign, self-clearing",
date, peak, pm, fill, drain,
f"{date}: the nightly Home Assistant recorder purge at 04:12 local.",
)
checks.append((date, dur, n5))
w(" # ---------------------------------------------------------------------")
w(" # 8. THE ADVERSARIAL ENVELOPE. A night that did not happen, built from")
w(" # the worst fill (39.7/min, 09-23), the highest peak (119, 09-23) and")
w(" # the slowest drain (13.0/min, 09-25) in the week. Inside what the")
w(" # cluster has demonstrated, worse than anything it actually did.")
w(" #")
w(" # This is where the two `for` windows get measured for margin rather")
w(" # than asserted about. Margin is PENDING time, which is one less than")
w(" # the run length — an alert goes pending on the first true evaluation")
w(" # and fires at activeAt + for, so 6 consecutive samples above 64")
w(" # accumulate 5 minutes, not 6:")
w(" #")
w(" # above 5: 11 samples -> 10m pending vs `for: 15m` -> 5m margin")
w(" # above 64: 6 samples -> 5m pending vs `for: 8m` -> 3m margin")
w(" #")
w(" # The worst MEASURED night accumulates 4m above 64 (5 samples, 09-20")
w(" # and 09-23), so the envelope is one minute tighter than anything that")
w(" # actually happened. 3 minutes is the number to defend if anyone")
w(" # proposes lowering `for: 8m` — and mutations.txt:surge-for-5m is the")
w(" # control that makes this case load-bearing rather than decorative: at")
w(" # `for: 5m` the surge rule is still silent on all seven real nights and")
w(" # fires only here. Delete this case and that mutation stops being")
w(" # caught.")
w(" # ---------------------------------------------------------------------")
burst_case(
"adversarial envelope — worst measured fill and peak, slowest drain",
*ENVELOPE[:5],
why="Synthetic: max fill 39.7 + max peak 119 + min drain 13.0.",
)
# ---- 9. stalled archiver, above the surge threshold -------------------
stalled = [0] * 5 + [70] * 35
w(" # ---------------------------------------------------------------------")
w(" # 9. THE FAILURE BOTH RULES EXIST FOR: an archiver that stopped with")
w(" # 70 segments (1.1 GiB) queued. Not a burst — it does not drain.")
w(" # Surge fires first (8m after the backlog crosses 64), the")
w(" # load-bearing rule 7 minutes later (15m after it crosses 5). Both")
w(" # assertions bracket the transition: one minute early nothing is")
w(" # firing, on the minute it is.")
w(" # ---------------------------------------------------------------------")
w(" - name: stalled archiver at 70 segments — both rules must fire")
w(" interval: 1m")
w(" input_series:")
w(f" - series: '{labels()}'")
w(f" values: '{fmt(stalled)}'")
w(f" - series: '{labels(value='done')}'")
w(" # Flat, because a stalled archiver archives nothing. The rules do")
w(" # not read this; it is here so the input stays realistic.")
w(f" values: '{fmt([148_000] * SAMPLES)}'")
w(" alert_rule_test:")
w(" # Backlog crosses 64 at t=5m, so `for: 8m` elapses at t=13m.")
w(" - eval_time: 12m")
w(" alertname: CNPGWalArchiveBacklogSurge")
w(" exp_alerts: []")
w(" - eval_time: 13m")
w(" alertname: CNPGWalArchiveBacklogSurge")
w(" exp_alerts:")
w(" # No `role` label: proof that `max by (namespace, pod)`")
w(" # aggregated it away. See mutations.txt:no-max-by.")
w(" - exp_labels:")
w(" severity: warning")
w(f" namespace: {NS}")
w(f" pod: {POD}")
w(" exp_annotations:")
w(f' summary: "CNPG WAL archiving cannot keep up on {NS}/{POD}"')
w(" description: \"70 WAL segments (>1 GiB) have been queued for archiving for 8 minutes, which is longer than a healthy archiver needs to clear that much — from a backlog of 119 it takes under 5. Point-in-time recovery is degraded for everything after the last archived segment, and pg_wal grows against the PVC's free space until it clears. Start with the archiver: `kubectl -n home-assistant logs ha-pg-1 -c plugin-barman-cloud`, and check whether its ObjectStore sets `wal.maxParallel` — unset means one segment at a time, a ceiling of about 14 segments/min (REN-129).\"")
w(" # Backlog crosses 5 at t=5m, so `for: 15m` elapses at t=20m.")
w(" - eval_time: 19m")
w(" alertname: CNPGWalArchiveBacklog")
w(" exp_alerts: []")
w(" - eval_time: 20m")
w(" alertname: CNPGWalArchiveBacklog")
w(" exp_alerts:")
w(" - exp_labels:")
w(" severity: warning")
w(f" namespace: {NS}")
w(f" pod: {POD}")
w(" exp_annotations:")
w(f' summary: "CNPG WAL archiving is falling behind on {NS}/{POD}"')
w(" description: \"70 WAL segments have been waiting to archive for over 15 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 home-assistant logs ha-pg-1 -c plugin-barman-cloud`.\"")
w("")
# ---- 10. low stall, below the surge threshold -------------------------
low = [0] * 5 + [12] * 35
w(" # ---------------------------------------------------------------------")
w(" # 10. THE STALL THE SURGE RULE CANNOT SEE: 12 segments, stuck. Under")
w(" # 1 GiB forever, so the surge rule is right to stay silent — and this")
w(" # is exactly the shape that makes the >5 rule load-bearing rather than")
w(" # a slower copy of it. Point-in-time recovery is just as broken at 12")
w(" # segments as at 70.")
w(" # ---------------------------------------------------------------------")
w(" - name: low stall at 12 segments — only the >5 rule may fire")
w(" interval: 1m")
w(" input_series:")
w(f" - series: '{labels()}'")
w(f" values: '{fmt(low)}'")
w(" alert_rule_test:")
w(" - eval_time: 20m")
w(" alertname: CNPGWalArchiveBacklog")
w(" exp_alerts:")
w(" - exp_labels:")
w(" severity: warning")
w(f" namespace: {NS}")
w(f" pod: {POD}")
w(" exp_annotations:")
w(f' summary: "CNPG WAL archiving is falling behind on {NS}/{POD}"')
w(" description: \"12 WAL segments have been waiting to archive for over 15 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 home-assistant logs ha-pg-1 -c plugin-barman-cloud`.\"")
w(" # 34 minutes at 12 segments and the surge rule is still quiet.")
w(" - eval_time: 39m")
w(" alertname: CNPGWalArchiveBacklogSurge")
w(" exp_alerts: []")
w("")
# ---- 11. exactly at the threshold ------------------------------------
w(" # ---------------------------------------------------------------------")
w(" # 11. PINNED AT EXACTLY 5 for the whole window. `> 5` is strict, so")
w(" # this is silence. A rule written `>= 5` would page on it, and a rule")
w(" # that had lost its `value=\"ready\"` selector would page on the `done`")
w(" # counter regardless of what `ready` does — which is what the second")
w(" # series here is for.")
w(" # ---------------------------------------------------------------------")
w(" - name: pinned at exactly 5 — strict > means silence")
w(" interval: 1m")
w(" input_series:")
w(f" - series: '{labels()}'")
w(f" values: '{fmt([5] * SAMPLES)}'")
w(f" - series: '{labels(value='done')}'")
w(f" values: '{fmt(counter(148_000, 14))}'")
w(" alert_rule_test:")
w(" - eval_time: 39m")
w(" alertname: CNPGWalArchiveBacklog")
w(" exp_alerts: []")
w(" - eval_time: 39m")
w(" alertname: CNPGWalArchiveBacklogSurge")
w(" exp_alerts: []")
w("")
# ---- 12. role flip ---------------------------------------------------
w(" # ---------------------------------------------------------------------")
w(" # 12. FAILOVER DURING A STALL — the case `max by (namespace, pod)` was")
w(" # added for. The backlog starts at t=5m on the primary; at t=11m the")
w(" # instance is promoted and `role` changes, which is a NEW SERIES to")
w(" # Prometheus and restarts the `for` timer on the raw expression.")
w(" # Aggregating `role` away keeps one continuous series, so the page")
w(" # still lands 15m after the backlog started (t=20m) rather than 15m")
w(" # after the failover. Without `max by` this case is silent at t=20m —")
w(" # see mutations.txt:no-max-by.")
w(" # ---------------------------------------------------------------------")
w(" - name: role flip mid-stall — for timer must not restart")
w(" interval: 1m")
w(" input_series:")
w(f" - series: '{labels(role='primary')}'")
w(" # t=0..10m, then this series ends.")
w(f" values: '{fmt([0] * 5 + [70] * 6)}'")
w(f" - series: '{labels(role='replica')}'")
w(" # Starts at t=11m. `_` is a gap, not a zero.")
w(f" values: '{'_ ' * 11}{fmt([70] * 29)}'")
w(" alert_rule_test:")
w(" - eval_time: 19m")
w(" alertname: CNPGWalArchiveBacklog")
w(" exp_alerts: []")
w(" - eval_time: 20m")
w(" alertname: CNPGWalArchiveBacklog")
w(" exp_alerts:")
w(" - exp_labels:")
w(" severity: warning")
w(f" namespace: {NS}")
w(f" pod: {POD}")
w(" exp_annotations:")
w(f' summary: "CNPG WAL archiving is falling behind on {NS}/{POD}"')
w(" description: \"70 WAL segments have been waiting to archive for over 15 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 home-assistant logs ha-pg-1 -c plugin-barman-cloud`.\"")
w("")
# ---- 13. one stalled pod among healthy ones --------------------------
w(" # ---------------------------------------------------------------------")
w(" # 13. ONE STALLED INSTANCE AMONG HEALTHY ONES. There are 12 CNPG")
w(" # instances in this cluster. `by (namespace, pod)` has to keep them")
w(" # apart: exactly one alert, naming the pod that is actually stuck, so")
w(" # the 04:00 reader is not sent to look at the wrong Postgres.")
w(" # ---------------------------------------------------------------------")
w(" - name: one stalled pod among healthy ones — exactly one alert")
w(" interval: 1m")
w(" input_series:")
w(f" - series: '{labels(pod='ha-pg-1')}'")
w(f" values: '{fmt([0] * 5 + [70] * 35)}'")
w(f" - series: '{labels(pod='ha-pg-2', role='replica')}'")
w(f" values: '{fmt([0] * SAMPLES)}'")
w(f" - series: '{labels(pod='immich-pg-1', ns='immich')}'")
w(f" values: '{fmt([0] * SAMPLES)}'")
w(" alert_rule_test:")
w(" - eval_time: 20m")
w(" alertname: CNPGWalArchiveBacklog")
w(" exp_alerts:")
w(" - exp_labels:")
w(" severity: warning")
w(f" namespace: {NS}")
w(" pod: ha-pg-1")
w(" exp_annotations:")
w(f' summary: "CNPG WAL archiving is falling behind on {NS}/ha-pg-1"')
w(" description: \"70 WAL segments have been waiting to archive for over 15 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 home-assistant logs ha-pg-1 -c plugin-barman-cloud`.\"")
w("")
# ---- 14. CNPGBackupFailed --------------------------------------------
w(" # ---------------------------------------------------------------------")
w(" # 14. THE EPOCH-ZERO GUARD on CNPGBackupFailed. The metric is a unix")
w(" # timestamp and reads 0 when no failure was ever recorded, so the rule")
w(" # carries a `> 0` guard to stop that default being read as \"failed in")
w(" # 1970\". Held at 0 for the whole window: silence. Nothing else in this")
w(" # suite exercises that rule, and a dropped guard would page on every")
w(" # instance in the cluster forever.")
w(" # ---------------------------------------------------------------------")
w(" - name: never-failed backup reads timestamp 0 — must not fire")
w(" interval: 1m")
w(" input_series:")
w(f" - series: 'cnpg_collector_last_failed_backup_timestamp{{namespace=\"{NS}\",pod=\"{POD}\"}}'")
w(f" values: '{fmt([0] * SAMPLES)}'")
w(" alert_rule_test:")
w(" - eval_time: 39m")
w(" alertname: CNPGBackupFailed")
w(" exp_alerts: []")
print("\n".join(L))
# Fidelity self-check on stderr so it is visible when regenerating but does
# not contaminate the suite on stdout. Longer than measured is tolerated and
# expected (see FIDELITY in the module docstring); SHORTER is not, because a
# night that clears the threshold sooner than the real one is a weaker
# silence test than the night it claims to stand for.
short = [(d, m, r) for d, m, r in checks if r < m]
if short:
print(
"FAIL: reconstruction is SHORTER above 5 than the measurement on "
+ ", ".join(f"{d} measured {m} rebuilt {r}" for d, m, r in short)
+ " — these nights are weaker silence tests than the real ones. "
"Refusing to vouch for the suite.",
file=sys.stderr,
)
return 1
long_ = [(d, m, r) for d, m, r in checks if r > m]
if long_:
print(
f"NOTE: {len(long_)} of {len(checks)} nights rebuild LONGER above 5 "
"than the published column (conservative direction): "
+ ", ".join(f"{d} {m}->{r}" for d, m, r in long_),
file=sys.stderr,
)
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,72 @@
# Negative controls for the promtool suites in this directory.
#
# A unit-test suite that passes proves the rules behave as asserted. It does not
# prove the assertions are worth anything: a suite whose `rule_files` path does
# not resolve, or whose traces are all zero, passes just as green. Both of those
# happened while this suite was being written, and neither was visible from the
# exit code.
#
# So scripts/ci/promtool-rules.sh runs a second phase. For each mutation below
# it damages ONE value the rules depend on, re-runs every suite, and requires the
# run to FAIL. A mutation the suite does not notice is a hole in the suite, and
# CI reports it as such.
#
# FORMAT — three `|`-separated fields, no `|` inside any of them:
#
# name | manifest path relative to the repo root | sed program
#
# The sed program is applied to the `spec:`-stripped copy in the mirrored tree,
# so its indentation is two spaces shallower than the manifest on disk. Anchor
# on `^[[:space:]]*` rather than a fixed indent. `#` in the first column is a
# comment; blank lines are ignored.
#
# The script also requires each sed program to CHANGE the file. A mutation that
# matches nothing is reported as a failure, not skipped — otherwise renaming a
# rule or rewording an expression silently turns its control into a no-op and
# the suite goes back to vouching for itself.
#
# `detected by` notes below are for the reader. The machine check is only "the
# suite must fail"; nothing parses them.
# The defect this whole exercise exists because of. The surge rule was written
# `for: 2m` and reviewed at that value. Six of Iris's seven measured nights
# accumulate 3-4 minutes above 64, so it fires on all six — two owner emails a
# night, 04:16 and 04:22 local, for a backlog that clears itself.
# Detected by: cases 1-7, the measured nights.
surge-for-2m|kubernetes/apps/monitoring-rules/prometheusrule-cnpg-backup.yaml|s/^\([[:space:]]*\)for: 8m$/\1for: 2m/
# The margin control, and the only reason case 8 is in the suite. At `for: 5m`
# the surge rule is still silent on all seven REAL nights (worst is 4m pending)
# and fires only on the adversarial envelope (5m pending). Without the envelope
# case, `for: 8m` could be cut to 5m with the suite staying green.
# Detected by: case 8, the adversarial envelope.
surge-for-5m|kubernetes/apps/monitoring-rules/prometheusrule-cnpg-backup.yaml|s/^\([[:space:]]*\)for: 8m$/\1for: 5m/
# The value REN-129 changed, in the direction it changed it from. At 30m the
# load-bearing rule does not page until 35 minutes into a stall that started at
# t=5m, so the assertion at t=20m sees nothing. This is the control that stops
# the 15m being quietly walked back.
# Detected by: cases 9, 10, 12, 13 — every case that expects a page.
backlog-for-30m|kubernetes/apps/monitoring-rules/prometheusrule-cnpg-backup.yaml|s/^\([[:space:]]*\)for: 15m$/\1for: 30m/
# Drop the aggregation, keeping the selector. `role` flips on failover; a
# changed label is a new series to Prometheus and restarts the `for` timer, so
# a backlog that persists across a promotion pages 15m after the promotion
# instead of 15m after the backlog. Scoped to `expr:` lines because
# `max by (namespace, pod)` also appears in this manifest's comments, where
# rewriting it would mutate the documentation and nothing else.
# Detected by: case 12, the role flip.
no-max-by|kubernetes/apps/monitoring-rules/prometheusrule-cnpg-backup.yaml|/^[[:space:]]*expr:/s/max by (namespace, pod) (\(cnpg_collector_pg_wal_archive_status{value="ready"}\))/\1/
# Drop the `value="ready"` selector. The same metric name also carries
# `value="done"`, a monotonic counter in the hundreds of thousands, so both
# rules would fire permanently on every CNPG instance in the cluster. The
# measured-night cases carry that counter series in their input specifically so
# this is detectable rather than a silent no-op.
# Detected by: cases 1-8 and 11 — every case with a `done` series.
no-value-selector|kubernetes/apps/monitoring-rules/prometheusrule-cnpg-backup.yaml|/^[[:space:]]*expr:/s/{value="ready"}//
# Loosen the comparison on the load-bearing rule. `> 5` is strict; `>= 5` pages
# on a backlog sitting at exactly five segments, which is 80 MiB and normal.
# Detected by: case 11, pinned at exactly 5.
backlog-ge-5|kubernetes/apps/monitoring-rules/prometheusrule-cnpg-backup.yaml|/^[[:space:]]*expr:/s/> 5$/>= 5/
@@ -360,6 +360,285 @@ spec:
limits:
memory: 256Mi
# --- Flux per-object readiness (REN-102) ---------------------------------
#
# WHY THIS IS HERE AND NOT ON THE FLUX CONTROLLERS. `gotk_reconcile_condition`
# and `gotk_suspend_status` are still declared by fluxcd/pkg/runtime, but no
# controller populates them at the tags pinned in
# clusters/homelab/flux-system/gotk-components.yaml, and an unpopulated
# GaugeVec exports no series. Verified after the REN-97 scrape landed: the
# PodMonitor now yields 2,273 `gotk_*` series and
# `count({__name__=~"gotk_reconcile_condition|gotk_suspend_status"})` is still
# empty. Upstream moved per-object readiness to kube-state-metrics
# custom-resource-state, which is what this block turns on.
#
# DO NOT ADD `collectors: []` OR `--custom-resource-state-only=true`.
# Upstream's reference config (fluxcd/flux2-monitoring-example,
# monitoring/controllers/kube-prometheus-stack/kube-state-metrics-config.yaml)
# opens with exactly those two lines. Copying them replaces every built-in
# collector with the Flux ones: measured 2026-09-26, that would take
# `count({job="kube-state-metrics"})` from 27,792 series to ~183 and blind
# every `Kube*` alert in the chart. Both keys are deliberately absent below.
#
# ROLLS THE kube-state-metrics POD. Enabling customResourceState adds an arg
# and a volume to the Deployment, so the pod is replaced once on apply. That
# is the whole blast radius of this block — KSM only watches, it writes
# nothing, and no Flux object or workload is touched. During the roll (a few
# seconds) `kube_*` has a gap; `KubeStateMetricsDown` and `TargetDown` cover
# it if it does not come back.
#
# NOTE FOR LATER EDITS: the subchart puts no config checksum on the
# Deployment's pod template, so editing `config` below changes the ConfigMap
# WITHOUT rolling the pod, and KSM keeps serving the old config until it
# restarts for another reason. Any future change to this config needs a
# deliberate pod restart to take effect.
#
# RBAC: list + watch, nothing else. `get` is NOT needed — KSM drives
# reflectors, never point-reads. Also note the chart adds
# `customresourcedefinitions: [list, watch]` on its own via
# rbac.customResourceState.createClusterRoleRules (default true); KSM needs it
# to resolve the GVKs below. Both land in the existing
# kube-prometheus-stack-kube-state-metrics ClusterRole rather than a second
# binding to maintain. No write verb, no wildcard, no cluster-admin.
rbac:
extraRules:
- apiGroups:
- source.toolkit.fluxcd.io
- kustomize.toolkit.fluxcd.io
- helm.toolkit.fluxcd.io
- image.toolkit.fluxcd.io
# Trimmed to the kinds this cluster actually has. Omitted from
# upstream's list, each for a checked reason:
# buckets, receivers — zero objects exist in this repo.
# helmcharts — created by source-controller at
# runtime, one per HelmRelease; a not-Ready HelmChart is always
# mirrored by the HelmRelease that owns it, so alerting on both
# is one failure reported twice.
# alerts, providers — notification.toolkit.fluxcd.io/v1beta3
# Alert and Provider have NO status.conditions in the CRD at all
# (checked in gotk-components.yaml), so there is no readiness to
# report; upstream omits `ready` for them for the same reason. A
# series carrying only `suspended` is not alertable.
resources:
- gitrepositories
- helmrepositories
- ocirepositories
- kustomizations
- helmreleases
- imagerepositories
- imagepolicies
- imageupdateautomations
verbs: ["list", "watch"]
customResourceState:
enabled: true
config:
spec:
resources:
# Label set is upstream's, minus the churning ones. Dropped
# deliberately: `revision` (status.artifact.revision /
# lastAppliedRevision — a git SHA, so every commit that reaches a
# Kustomization mints a NEW SERIES), and `url` / `endpoint`.
# Prometheus is at 84.5% of its 30GB retentionSize cap (REN-107);
# a label that changes several times a day across 33 Kustomizations
# is exactly what must not be added. Kept: `ready` and `suspended`
# (what the alerts read), `name` / `exported_namespace` (identity),
# `source_name` (the join key, see prometheusrule-flux.yaml), plus
# `image` and `tag` on the two image kinds, which are stable enough
# to ride the existing series.
#
# `exported_namespace` is upstream's own label name for this, not a
# Prometheus collision rename: the chart's KSM ServiceMonitor runs
# honorLabels, so `namespace` on a KSM series is already the
# OBJECT's namespace and nothing gets renamed. The rules use the
# same string because the config declares it.
#
# KSM adds `customresource_group`, `customresource_version` and
# `customresource_kind` to every custom-resource-state series by
# itself. The rules key off `customresource_kind`.
- groupVersionKind:
group: kustomize.toolkit.fluxcd.io
version: v1
kind: Kustomization
metricNamePrefix: gotk
errorLogV: 10
metrics:
- name: "resource_info"
help: "The current state of a Flux Kustomization resource."
each:
type: Info
info:
labelsFromPath:
name: [metadata, name]
labelsFromPath:
exported_namespace: [metadata, namespace]
ready: [status, conditions, "[type=Ready]", status]
suspended: [spec, suspend]
source_name: [spec, sourceRef, name]
- groupVersionKind:
group: helm.toolkit.fluxcd.io
version: v2
kind: HelmRelease
metricNamePrefix: gotk
errorLogV: 10
metrics:
- name: "resource_info"
help: "The current state of a Flux HelmRelease resource."
each:
type: Info
info:
labelsFromPath:
name: [metadata, name]
labelsFromPath:
exported_namespace: [metadata, namespace]
ready: [status, conditions, "[type=Ready]", status]
suspended: [spec, suspend]
- groupVersionKind:
group: source.toolkit.fluxcd.io
version: v1
kind: GitRepository
metricNamePrefix: gotk
errorLogV: 10
metrics:
- name: "resource_info"
help: "The current state of a Flux GitRepository resource."
each:
type: Info
info:
labelsFromPath:
name: [metadata, name]
labelsFromPath:
exported_namespace: [metadata, namespace]
ready: [status, conditions, "[type=Ready]", status]
suspended: [spec, suspend]
- groupVersionKind:
group: source.toolkit.fluxcd.io
version: v1
kind: HelmRepository
metricNamePrefix: gotk
errorLogV: 10
metrics:
- name: "resource_info"
help: "The current state of a Flux HelmRepository resource."
each:
type: Info
info:
labelsFromPath:
name: [metadata, name]
labelsFromPath:
exported_namespace: [metadata, namespace]
ready: [status, conditions, "[type=Ready]", status]
suspended: [spec, suspend]
- groupVersionKind:
group: source.toolkit.fluxcd.io
version: v1
kind: OCIRepository
metricNamePrefix: gotk
errorLogV: 10
metrics:
- name: "resource_info"
help: "The current state of a Flux OCIRepository resource."
each:
type: Info
info:
labelsFromPath:
name: [metadata, name]
labelsFromPath:
exported_namespace: [metadata, namespace]
ready: [status, conditions, "[type=Ready]", status]
suspended: [spec, suspend]
- groupVersionKind:
group: image.toolkit.fluxcd.io
version: v1
kind: ImageRepository
metricNamePrefix: gotk
errorLogV: 10
metrics:
- name: "resource_info"
help: "The current state of a Flux ImageRepository resource."
each:
type: Info
info:
labelsFromPath:
name: [metadata, name]
labelsFromPath:
exported_namespace: [metadata, namespace]
ready: [status, conditions, "[type=Ready]", status]
suspended: [spec, suspend]
image: [spec, image]
# The repository-side half of the Ready-but-frozen signal. A
# policy can hold Ready=True forever while its filter excludes
# every new release, so readiness cannot see it; what can is
# "the repository kept finding tags and the policy's answer
# never moved". .status.lastScanResult.tagCount is a required
# field on this CRD, so the series exists for every object.
- name: "image_repository_tag_count"
help: "Number of tags found by the ImageRepository's last scan."
each:
type: Gauge
gauge:
path: [status, lastScanResult]
valueFrom: [tagCount]
# `name` belongs HERE and not under `gauge`. KSM resolves
# `gauge.labelsFromPath` RELATIVE TO `gauge.path`, so
# [metadata, name] under there would be looked up inside
# status.lastScanResult and silently yield no label. The
# metric-level labelsFromPath resolves from the object root.
labelsFromPath:
name: [metadata, name]
exported_namespace: [metadata, namespace]
- groupVersionKind:
group: image.toolkit.fluxcd.io
version: v1
kind: ImagePolicy
metricNamePrefix: gotk
errorLogV: 10
metrics:
- name: "resource_info"
help: "The current state of a Flux ImagePolicy resource."
each:
type: Info
info:
labelsFromPath:
name: [metadata, name]
labelsFromPath:
exported_namespace: [metadata, namespace]
ready: [status, conditions, "[type=Ready]", status]
suspended: [spec, suspend]
# The resolved tag, as a label on the Info series that
# already exists — so the policy-side half of the frozen
# signal costs zero extra series at rest. It retires one
# series and starts another only when the tag actually
# changes: 191 automated bumps in 147 days across 34
# policies is ~1.3 new series/day, which ages out with
# retention.
tag: [status, latestRef, tag]
# The join key. `metadata.name` happens to equal
# `spec.imageRepositoryRef.name` for all 34 pairs in this
# repo today, but the REFERENCE is this field, and a policy
# named differently from its repository would silently drop
# out of a name-based join.
source_name: [spec, imageRepositoryRef, name]
- groupVersionKind:
group: image.toolkit.fluxcd.io
version: v1
kind: ImageUpdateAutomation
metricNamePrefix: gotk
errorLogV: 10
metrics:
- name: "resource_info"
help: "The current state of a Flux ImageUpdateAutomation resource."
each:
type: Info
info:
labelsFromPath:
name: [metadata, name]
labelsFromPath:
exported_namespace: [metadata, namespace]
ready: [status, conditions, "[type=Ready]", status]
suspended: [spec, suspend]
source_name: [spec, sourceRef, name]
# Drop the apiserver's high-cardinality latency histograms.
#
# Measured 2026-08-20: Prometheus held 298,236 active series, of which
+27 -2
View File
@@ -8,8 +8,33 @@ data:
ND_LOGLEVEL: "info"
ND_SCANNER_SCHEDULE: "0 * * * *"
ND_ENABLEINSIGHTSCOLLECTOR: "false"
# Identity from the navidrome-gate outpost; the CNP admits only the outpost
# and Traefik, and Traefik strips this header on the ungated routes.
# Identity from the navidrome-gate outpost. Traefik strips this header on
# the ungated /rest and /share routes.
#
# ND_EXTAUTH_TRUSTEDSOURCES BELOW IS NOT A NARROWING. fd10:244::/48 plus
# 10.244.0.0/16 is the entire cluster pod CIDR, so navidrome's own
# reverse-proxy trust check accepts X-Authentik-Username from ANY pod.
# The CiliumNetworkPolicy is the only real boundary, and it is wider than
# "the outpost and Traefik" — default-deny-ingress in
# kubernetes/infrastructure/network-policies/navidrome-ns.yaml uses
# `endpointSelector: {}` and admits, on every port:
# - the navidrome namespace, every port
# - traefik, every port
# - the navidrome-gate outpost, every port
# - the lidarr-gate outpost, every port - reaches navidrome too
# - host / remote-node, every port - the kubelet sources the HTTP probes
# from the host netns, so this one can never be removed while the pod
# has HTTP probes
# - blackbox-exporter on :4533 and :8686 only
# The whole-monitoring-namespace admit that used to be in this list was
# narrowed to blackbox-exporter on those two ports in REN-28. Rules are
# cited by what they admit, not by line number, so this list cannot go
# stale the next time that file is edited.
# The value stays at the pod CIDR ON PURPOSE - see REN-28. The address
# navidrome sees is the outpost's POD IP, which is not stable across
# reschedules, so a narrower literal here fails open-ended and silently
# the next time that pod moves. Tightening the CNP is the control that
# would actually do work.
ND_EXTAUTH_USERHEADER: "X-Authentik-Username"
ND_EXTAUTH_TRUSTEDSOURCES: "fd10:244::/48,10.244.0.0/16"
---
@@ -0,0 +1,97 @@
---
# Cluster-wide READ for the agents in the paperclip pod (REN-111, decided on
# REN-52 2026-09-25 by the owner, option B).
#
# WHAT THIS IS
#
# One ClusterRoleBinding. It binds the Kubernetes built-in `view` ClusterRole to
# the ServiceAccount that REN-36 created in `../paperclip/prometheus-read-rbac.yaml`.
# No new ServiceAccount, no new ClusterRole, no write verb anywhere. `view` is
# maintained upstream and holds get/list/watch only; revocation is deleting this
# one object.
#
# WHY IT IS A SEPARATE FILE
#
# `prometheus-read-rbac.yaml` is deliberately narrow and its header argues for
# staying that way. This grant is the opposite in character — cluster-wide,
# every namespace — so it is kept in its own file and its own kustomization
# entry. Reverting it is deleting this file and one line from
# `kustomization.yaml`; nothing in the REN-36 path is touched, and the token
# falls back to `get services/proxy` on two named Services.
#
# WHY THE OWNER WANTED IT
#
# REN-108 part B had to validate a CiliumNetworkPolicy allowlist whose selector
# was `app.kubernetes.io/name: prometheus-blackbox-exporter`, and could not:
# kube-state-metrics here does not export `kube_pod_labels`, and
# blackbox-exporter is not a scrape target so Prometheus has no
# `__meta_kubernetes_pod_label_*` for it either. A CNP selector that matches no
# pod allows nothing and reports no error, so the proposal shipped unverified.
# `list pods` closes that, and the same gap recurs on every netpol or
# scrape-selector change.
#
# WHAT IT COSTS — ACCEPTED ON REN-52, NOT DISCOVERED HERE
#
# * EVERY process in the paperclip pod can read the mounted token, so this is
# a grant to every agent, not to one. REN-36 was approved on the premise
# that "the token is only worth what this Role grants"; this file changes
# that arithmetic, and the owner priced it knowingly.
# * `view` reads ConfigMaps in EVERY namespace. This repo keeps application
# configuration in ConfigMaps. Chosen over Hermes' narrower alternative.
# * `view` grants `pods/log`, which surfaces anything an application prints,
# including credentials it prints by mistake.
#
# WHAT IT DOES NOT GRANT
#
# * Secrets. `view` excludes them. "Secrets, metadata only" was the original
# ask and is NOT expressible in Kubernetes RBAC — the authorizer has no
# field-level verb — so that part of the ask is dead. Do not re-add Secrets
# here to approximate it.
# * Any write verb, on anything.
# * ClusterRoles and RoleBindings: `view` excludes the whole
# `rbac.authorization.k8s.io` group, on purpose, so a holder cannot read the
# shape of its own cage or find a Role to escalate through.
#
# FLUX CRDs — COVERED BY AGGREGATION, NO EXTRA ClusterRole NEEDED
#
# REN-52 pre-approved adding a read-only ClusterRole for the Flux CRDs *if*
# aggregation did not already cover them. It does, so none is added:
# `flux-view-flux-system` carries `rbac.authorization.k8s.io/aggregate-to-view:
# "true"` and lists get/list/watch on `*` across all six API groups —
# `../../clusters/homelab/flux-system/gotk-components.yaml:232-254`. The
# aggregation controller merges those rules into `view` itself, so this one
# binding picks up kustomizations, helmreleases, gitrepositories, imagepolicies,
# imagerepositories and imageupdateautomations. Read from git, not from a live
# ClusterRole — `view` cannot read ClusterRoles, so it is unverifiable from
# inside the pod. The post-merge SelfSubjectAccessReview on REN-111 proves
# whether aggregation actually fired in the cluster; if `list kustomizations`
# comes back `allowed: false` there, the pre-approved ClusterRole is the fix.
#
# PRODUCTION
#
# Adding a ClusterRoleBinding mutates no workload, restarts no Pod and moves no
# traffic. RBAC is evaluated per request, so the grant takes effect on the
# already-mounted token with no remount and no rollout. That sequencing was the
# point of REN-52 putting this after REN-36: the paperclip Deployment restart
# was paid once, by REN-36, and is already paid.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: paperclip-cluster-view
labels:
app.kubernetes.io/name: paperclip
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
# Built-in, shipped by kube-controller-manager's default RBAC bootstrap. Not
# defined in this repo and must not be: it is auto-reconciled, so a local copy
# would be overwritten on every apiserver start.
name: view
subjects:
- kind: ServiceAccount
name: paperclip-prometheus-read
# A ClusterRoleBinding has no namespace of its own, so this is the only
# place the subject's namespace can be stated. Omit it and the binding
# resolves to no subject and fails closed, looking identical to a missing
# grant.
namespace: paperclip
+2
View File
@@ -4,6 +4,8 @@ kind: Ingress
metadata:
name: paperclip
namespace: paperclip
labels:
dns.renner.uno/public: "true"
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
spec:
@@ -8,6 +8,7 @@ resources:
- scheduled-backup.yaml
- pvc.yaml
- prometheus-read-rbac.yaml
- cluster-view-rbac.yaml
- paperclip.yaml
- gate-service.yaml
- ingress.yaml
@@ -23,10 +23,20 @@
# ONE VERB, NAMED SERVICES ONLY — ON PURPOSE
#
# Every process in the paperclip pod can read the mounted ServiceAccount token,
# so the token is only worth what this Role grants. It grants `get` on the two
# Services named below and nothing else. Widening it is a new card and a new
# review, not an edit here — the Alertmanager name was added that way (REN-95,
# for read-only alert-delivery checks; parent REN-94).
# so the token is worth the sum of everything bound to this ServiceAccount. This
# Role's own share of that is `get` on the two Services named below and nothing
# else. Widening it is a new card and a new review, not an edit here — the
# Alertmanager name was added that way (REN-95, for read-only alert-delivery
# checks; parent REN-94).
#
# THIS IS NO LONGER THE ONLY GRANT ON THIS ServiceAccount
#
# `cluster-view-rbac.yaml` in this directory binds the built-in `view`
# ClusterRole to the same ServiceAccount, cluster-wide (REN-111, owner-approved
# on REN-52). So reading this file alone now understates what the token can do:
# it also carries get/list/watch on most namespaced resources in every
# namespace, including ConfigMaps and `pods/log`. Still no Secrets and still no
# write verb. Read both files.
#
# `resourceNames` is matched verbatim by the authorizer, not as a selector:
# `svc`, `svc:9090` and `http:svc:9090` are three different strings. Only the
@@ -46,10 +56,14 @@ metadata:
# second grant, the name is a lie and the review should say so. Whether the
# token is mounted is decided on the pod (`paperclip.yaml`), not here.
#
# REN-95 stretched the name: it now also proxies to Alertmanager. Kept as-is
# deliberately — `serviceAccountName` is in the pod template, so renaming it
# would restart the paperclip Deployment and evict running agents. Read the name
# as "read-only kube-prometheus-stack access", and read the Role for the truth.
# REN-95 stretched the name: it now also proxies to Alertmanager. REN-111 broke
# it outright — this account now also holds cluster-wide `view`, which is not
# Prometheus and not read of one thing. Kept as-is deliberately, and REN-52
# ruled it stays: `serviceAccountName` is in the pod template, so renaming it
# would restart the paperclip Deployment and evict running agents, which is a
# larger production cost than a misleading name. Read the name as "the paperclip
# pod's read-only cluster identity", and read this file plus
# `cluster-view-rbac.yaml` for the truth.
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
+19 -5
View File
@@ -1,9 +1,23 @@
---
# Trust boundary: paperless-gate (the dedicated Authentik proxy outpost
# in the authentik namespace). Header-based remote-user auth means
# paperless trusts whatever fills X-Authentik-Username unconditionally,
# so any pod that can talk directly to paperless's HTTP listener can
# claim any username. Lock the ingress side to the gate outpost only.
# Intended trust boundary: paperless-gate (the dedicated Authentik proxy
# outpost in the authentik namespace). Header-based remote-user auth means
# paperless trusts whatever fills X-Authentik-Username unconditionally, so
# any pod that can talk directly to paperless's HTTP listener can claim any
# username.
#
# WHAT IS ENFORCED IS WIDER THAN THIS RULE. default-deny-ingress in
# kubernetes/infrastructure/network-policies/paperless-ns.yaml uses
# `endpointSelector: {}`, so it selects this pod as well, and Cilium unions
# the allow rules of every CNP selecting an endpoint (the same mechanism
# described below). The effective ingress set is this outpost PLUS, on
# every port:
# - the paperless namespace (paperless-ns.yaml:9-11)
# - the database namespace (paperless-ns.yaml:31-33)
# - the monitoring namespace (paperless-ns.yaml:34-36)
# - host / remote-node (paperless-ns.yaml:37-39)
# So any pod in database or monitoring can reach this HTTP listener
# directly and set X-Authentik-Username to any value. Do not read this
# rule as the whole boundary. Narrowing is tracked in REN-28.
#
# This is a CiliumNetworkPolicy (not a standard NetworkPolicy) so it
# composes additively with the cluster's baseline CNP
@@ -0,0 +1,473 @@
# VENDORED, DO NOT EDIT BY HAND.
#
# Source: stakater/IngressMonitorController at tag v2.2.13 — the tag the image in
# deployment.yaml is pinned to — commit 2deeba188fbd2860d0b3a7c3590a954a1a709924,
# charts/ingressmonitorcontroller/crds/endpointmonitor.stakater.com_endpointmonitors.yaml
# Everything below this header is byte-identical to that file: md5
# f753baa9c9684002ad01d309e98755ba over `apiVersion:` to EOF. The only edits are
# this header and the dropped leading `---`.
#
# An earlier revision of this header cited aaffd8a866aa09a77030979d938e766895bde5d4
# instead. That is a master merge commit dated 2026-09-25, four months after the
# v2.2.13 tag (2026-05-06). The file content was not affected — the chart CRD is
# unchanged between the tag and that commit — but a citation pointing at a moving
# branch cannot be used to check this file against the version actually deployed.
#
# Vendored rather than installed via HelmRelease because this Kustomization
# ships hand-written RBAC (see rbac.yaml) — the chart's own Role grants pods,
# services and `secrets: list`, none of which this controller uses. Pulling the
# chart for the CRD alone would drag those back in.
#
# To update: bump the image tag in deployment.yaml and re-copy this file from
# the matching chart tag. Nothing here is Renovate-managed, so the tag and this
# file move together, by hand, in one reviewable commit.
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.16.1
name: endpointmonitors.endpointmonitor.stakater.com
spec:
group: endpointmonitor.stakater.com
names:
kind: EndpointMonitor
listKind: EndpointMonitorList
plural: endpointmonitors
singular: endpointmonitor
scope: Namespaced
versions:
- name: v1alpha1
schema:
openAPIV3Schema:
description: EndpointMonitor is the Schema for the endpointmonitors API
properties:
apiVersion:
description: |-
APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
type: string
kind:
description: |-
Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
type: string
metadata:
type: object
spec:
description: EndpointMonitorSpec defines the desired state of EndpointMonitor
properties:
appInsightsConfig:
description: Configuration for AppInsights Monitor Provider
properties:
frequency:
description: 'Sets how often the test should run from each test
location. Possible values: `300,600,900` seconds'
type: integer
retryEnable:
description: 'If its `true`, falied test will be retry after a
short interval. Possible values: `true, false`'
type: boolean
statusCode:
description: Returned status code that is counted as a success
type: integer
type: object
forceHttps:
description: Force monitor endpoint to use HTTPS
type: boolean
gcloudConfig:
description: Configuration for Google Cloud Monitor Provider
properties:
projectId:
description: Google Cloud Project ID
type: string
type: object
grafanaConfig:
description: Configuration for Grafana Cloud Monitor Provider
properties:
alertSensitivity:
default: none
description: |-
The alertSensitivity value defaults to none if there are no alerts or can be set to low, medium,
or high to correspond to the check alert levels.
enum:
- none
- low
- medium
- high
type: string
frequency:
description: The frequency value specifies how often the check
runs in milliseconds
format: int64
type: integer
probes:
description: |-
Probes are the monitoring agents responsible for simulating user interactions with your web applications
or services. These agents periodically send requests to predefined URLs and record the responses,
checking for expected outcomes and measuring performance.
items:
type: string
type: array
tenantId:
format: int64
type: integer
type: object
healthEndpoint:
type: string
pingdomConfig:
description: Configuration for Pingdom Monitor Provider
properties:
alertContacts:
description: '`-` separated contact id''s (e.g. "1234567_8_9-9876543_2_1")'
type: string
alertIntegrations:
description: '`-` separated set list of integrations ids (e.g.
"91166-12168")'
type: string
basicAuthUser:
description: Required for basic-authentication
type: string
notifyWhenBackUp:
description: Set to "false" to disable recovery notifications
type: boolean
paused:
description: Set to "true" to pause checks
type: boolean
postDataEnvVar:
description: |-
Data that should be posted to the web page, for example submission data for a sign-up or login form.
The data needs to be formatted in the same way as a web browser would send it to the web server.
Because post data contains sensitive secret this field is only a reference to an environment variable.
type: string
requestHeaders:
description: Custom request headers
type: string
requestHeadersEnvVar:
description: |-
Custom request headers that should be read from an environment variable as it possibly contains sensitive data.
An example would be an API token.
type: string
resolution:
description: The pingdom check interval in minutes
type: integer
sendNotificationWhenDown:
description: How many failed check attempts before notifying
type: integer
shouldContain:
description: Set to text string that has to be present in the
HTML code of the page
type: string
sslDownDaysBefore:
description: |-
Consider down prior to certificate expiring
Select the number of days prior to your certificate expiry date that you want to consider the check down.
At this day your check will be considered down and if applicable a down alert will be sent.
type: integer
tags:
description: Comma separated set of tags to apply to check (e.g.
"testing,aws")
type: string
teamAlertContacts:
description: '`-` separated team id''s (e.g. "1234567_8_9-9876543_2_1")'
type: string
verifyCertificate:
description: |-
Monitor SSL/TLS certificate
Monitor the validity of your SSL/TLS certificate. With this enabled Uptime checks will be considered DOWN when
the certificate becomes invalid or expires.
SSL/TLS certificate monitoring is available for HTTP checks.
type: boolean
type: object
pingdomTransactionConfig:
description: Configuration for Pingdom Transaction Monitor Provider
properties:
alertContacts:
description: '`-` separated contact id''s (e.g. "1234567_8_9-9876543_2_1")'
type: string
alertIntegrations:
description: '`-` separated set list of integrations ids (e.g.
"91166-12168")'
type: string
custom_message:
description: Custom message that is part of the email and webhook
alerts
type: string
interval:
description: 'TMS test intervals in minutes. Allowed intervals:
5,10,20,60,720,1440. The interval you''re allowed to set may
vary depending on your current plan.'
enum:
- 5
- 10
- 20
- 60
- 720
- 1440
type: integer
paused:
description: 'Check status: active or inactive'
type: boolean
region:
description: 'Name of the region where the check is executed.
Supported regions: us-east, us-west, eu, au'
enum:
- us-east
- us-west
- eu
- au
type: string
send_notification_when_down:
description: Send notification when down X times
format: int64
type: integer
severity_level:
description: 'Check importance- how important are the alerts when
the check fails. Allowed values: low, high'
enum:
- low
- high
type: string
steps:
description: steps to be executed as part of the check
items:
description: PingdomStep respresents a step of the script to
run a transcaction check
properties:
args:
additionalProperties:
type: string
description: |-
contains the html element with assigned value
the key element is always lowercase for example {"url": "https://www.pingdom.com"}
see available values at https://pkg.go.dev/github.com/karlderkaefer/pingdom-golang-client@latest/pkg/pingdom/client/tmschecks#StepArg
type: object
function:
description: |-
contains the function that is executed as part of the step
commands: go_to, click, fill, check, uncheck, sleep, select_radio, basic_auth, submit, wait_for_element, wait_for_contains
validations: url, exists, not_exists, contains, not_contains, field_contains, field_not_contains, is_checked, is_not_checked, radio_selected, dropdown_selected, dropdown_not_selected
see updated list https://docs.pingdom.com/api/#section/TMS-Steps-Vocabulary/Script-transaction-checks
type: string
required:
- args
- function
type: object
type: array
tags:
description: List of tags for a check. The tag name may contain
the characters 'A-Z', 'a-z', '0-9', '_' and '-'. The maximum
length of a tag is 64 characters.
items:
type: string
type: array
teamAlertContacts:
description: '`-` separated team id''s (e.g. "1234567_8_9-9876543_2_1")'
type: string
required:
- steps
type: object
providers:
description: Comma separated list of providers
type: string
statusCakeConfig:
description: Configuration for StatusCake Monitor Provider
properties:
basicAuthSecret:
description: Basic Auth Secret Name
type: string
basicAuthUser:
description: Basic Auth User
type: string
checkRate:
default: 300
description: Set Check Rate for the monitor.
type: integer
confirmation:
description: Confirmation value ranges from (0,10)
maximum: 10
minimum: 0
type: integer
contactGroup:
description: Contact Group to be alerted.
type: string
enableSslAlert:
description: Enable SSL Alert
type: boolean
findString:
description: String to look for within the response. Considered
down if not found
type: string
followRedirect:
description: Enable ingress redirects
type: boolean
paused:
description: Pause the service
type: boolean
pingUrl:
description: Webhook for alerts
type: string
port:
description: TCP Port
type: integer
rawPostData:
description: RawPostData can be used to send parameters within
the URL. Changes the request from a GET to a POST
type: string
realBrowser:
description: Enable Real Browser
type: boolean
regions:
description: Comma separated list of Node Location IDs
type: string
statusCodes:
description: Comma separated list of HTTP codes to trigger error
on
type: string
testTags:
description: Comma separated list of tags
type: string
testType:
description: Set Test type - HTTP, TCP, PING, or Heartbeat
enum:
- HTTP
- TCP
- PING
- Heartbeat
type: string
timeout:
description: Timeout is used to set a user agent string.
maximum: 75
minimum: 5
type: integer
triggerRate:
description: Minutes to wait before sending an alert
type: integer
userAgent:
description: UserAgent is used to set a user agent string.
type: string
type: object
x-kubernetes-validations:
- message: checkRate must be between 30 and 172800 seconds for Heartbeat
monitors
rule: self.testType != 'Heartbeat' || (self.checkRate >= 30 && self.checkRate
<= 172800)
- message: 'checkRate for uptime monitors must be one of: 0, 30, 60,
300, 900, 1800, 3600, 86400'
rule: self.testType == 'Heartbeat' || self.checkRate in [0, 30,
60, 300, 900, 1800, 3600, 86400]
updownConfig:
description: Configuration for Updown Monitor Provider
properties:
enable:
description: Enable or disable checks
type: boolean
period:
description: The pingdom check interval in seconds
type: integer
publishPage:
description: Make status page public or not
type: boolean
requestHeaders:
description: Additional request headers for API calls
type: string
type: object
uptimeConfig:
description: Configuration for Uptime Monitor Provider
properties:
checkType:
description: The uptime check type that can be HTTP/DNS/ICMP etc.
type: string
contacts:
description: Add one or more contact groups separated by `,`
type: string
interval:
description: The uptime check interval in seconds
type: integer
locations:
description: Add different locations for the check
type: string
tags:
description: Add one or more tags for the check separated by `,`
type: string
type: object
uptimeRobotConfig:
description: Configuration for UptimeRobot Monitor Provider
properties:
alertContacts:
description: The uptimerobot alertContacts to be associated with
this monitor
type: string
customHTTPStatuses:
description: |-
Defines which http status codes are treated as up or down
For ex: 200:0_401:1_503:1 (to accept 200 as down and 401 and 503 as up)
type: string
interval:
description: The uptimerobot check interval in seconds
minimum: 60
type: integer
keywordExists:
description: Alert if value exist (yes) or doesn't exist (no)
(Only if monitor-type is keyword)
enum:
- "yes"
- "no"
type: string
keywordValue:
description: keyword to check on URL (e.g.'search' or '404') (Only
if monitor-type is keyword)
type: string
maintenanceWindows:
description: Specify maintenanceWindows i.e. once or recurring
“do-not-monitor periods”
type: string
monitorType:
description: The uptimerobot monitor type (http or keyword)
enum:
- http
- keyword
type: string
statusPages:
description: The uptimerobot public status page ID to add this
monitor to
type: string
type: object
url:
description: URL to monitor
type: string
urlFrom:
description: URL to monitor from either an ingress or route reference
properties:
ingressRef:
description: IngressURLSource selects an Ingress to populate the
URL with
properties:
name:
type: string
required:
- name
type: object
routeRef:
description: RouteURLSource selects a Route to populate the URL
with
properties:
name:
type: string
required:
- name
type: object
type: object
type: object
status:
description: EndpointMonitorStatus defines the observed state of EndpointMonitor
type: object
type: object
served: true
storage: true
subresources:
status: {}
@@ -0,0 +1,118 @@
---
# Stakater IngressMonitorController (REN-6).
#
# It ships with zero EndpointMonitor resources, which is the whole point of this
# first deployment: the reconciler is keyed on one custom resource per event
# (endpointmonitor_controller.go:62-107) and never enumerates the UptimeRobot
# account, so with no EndpointMonitors it reconciles nothing and makes no API
# call to UptimeRobot at all. The 9 monitors already in that account are not
# read, not updated and not deleted.
#
# Plain manifests, not the upstream HelmRelease: the chart's RBAC grants pods,
# services and `secrets: list`, and it also creates a Service and optional
# cert-manager objects for a metrics endpoint that is disabled below. The
# maintained thing we depend on is the upstream image, which is pinned here.
apiVersion: apps/v1
kind: Deployment
metadata:
name: ingressmonitorcontroller
namespace: uptime-monitor
labels:
app.kubernetes.io/name: ingressmonitorcontroller
annotations:
# The API key is read ONCE, at startup (pkg/config/config.go
# `LoadControllerConfig` runs before the manager starts), so a rotated
# Secret is not picked up until the pod restarts. Reloader — which this
# cluster already runs — does that restart. Same idiom as
# apps/external-dns/helmrelease.yaml.
secret.reloader.stakater.com/reload: uptime-robot-api-key
spec:
replicas: 1
strategy:
# Recreate, not RollingUpdate, and no --leader-elect. A rolling update would
# briefly run two reconcilers against the same UptimeRobot account; Recreate
# makes "at most one controller pod, ever" a property of the Deployment
# instead of something a Lease has to arbitrate. It costs a few seconds of
# controller downtime per upgrade, which for a monitor-syncing controller is
# nothing, and it removes the leases/events RBAC entirely.
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: ingressmonitorcontroller
template:
metadata:
labels:
app.kubernetes.io/name: ingressmonitorcontroller
spec:
serviceAccountName: ingressmonitorcontroller
automountServiceAccountToken: true
securityContext:
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
seccompProfile:
type: RuntimeDefault
containers:
- name: manager
# Pinned, and deliberately NOT under Flux image automation. rbac.yaml
# is derived from what this exact version calls; an automatic bump
# could add a call the Role does not cover, silently.
image: ghcr.io/stakater/ingressmonitorcontroller:v2.2.13
command: ["/manager"]
args:
- --health-probe-bind-address=:8081
# Metrics off. Enabling them means either TLS certs or
# `--metrics-secure=false`, and the authn/authz filter
# (cmd/main.go, `filters.WithAuthenticationAndAuthorization`) wants a
# ClusterRole for TokenReview/SubjectAccessReview — a cluster-scoped
# grant this namespace otherwise does not need. A ServiceMonitor for
# this controller is worth having, but it is its own change.
- --metrics-bind-address=0
env:
# Scopes the manager's cache to this namespace
# (cmd/main.go `buildDefaultNamespaces`), which is what makes the
# namespaced Role in rbac.yaml sufficient — there is no ClusterRole.
- name: WATCH_NAMESPACE
value: uptime-monitor
# The namespace the config Secret is looked up in. Read from the
# ServiceAccount token file when unset; set explicitly so the lookup
# is visible in the manifest.
- name: OPERATOR_NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
# IMC reads its whole configuration from key `config.yaml` of this
# Secret — providers, API key and enableMonitorDeletion together.
# ESO renders that key; see externalsecret.yaml for the contents and
# for why the config lives in git while the key does not.
- name: CONFIG_SECRET_NAME
value: uptime-robot-api-key
# Both probes are served by the manager on :8081 and are dialled by
# the kubelet, which Cilium identifies as the `host` entity — the only
# ingress the namespace policy admits
# (infrastructure/network-policies/uptime-monitor-ns.yaml). The port is
# not exposed as a containerPort because nothing else may reach it.
livenessProbe:
httpGet:
path: /healthz
port: 8081
initialDelaySeconds: 15
periodSeconds: 20
readinessProbe:
httpGet:
path: /readyz
port: 8081
initialDelaySeconds: 5
periodSeconds: 10
resources:
requests:
cpu: 10m
memory: 64Mi
limits:
memory: 128Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
terminationGracePeriodSeconds: 10
@@ -12,12 +12,45 @@
# refreshInterval 1h matches the un-rotated ExternalSecrets here (velero,
# wiki-*). The rotated ones use 10m so an automated roll propagates fast — if
# kubernetes/infrastructure/openbao/rotation/ ever gains an uptime-robot
# rotator, drop this to 10m and annotate the controller Deployment for
# stakater/reloader, which the cluster already runs.
# rotator, drop this to 10m. The controller Deployment already carries the
# stakater/reloader annotation, so a new value restarts the pod that has the
# old one cached.
#
# The `eso` role already reads this path: eso-read grants `homelab/data/*`
# (kubernetes/infrastructure/openbao/policies/eso-read.hcl:31). No OpenBao
# policy change is needed for this key.
#
# ---
# Why this Secret holds a rendered `config.yaml` rather than a bare key (REN-6):
# IngressMonitorController does not read an API key from an env var. It reads
# its ENTIRE configuration — provider list, API key, enableMonitorDeletion —
# from one Secret key named `config.yaml` (pkg/config/config.go,
# `IngressMonitorControllerSecretConfigKey`), at startup, through the manager's
# APIReader. So the choice is not "key or config"; it is which Secret the config
# lives in. Templating it here keeps every non-secret decision in git and in
# review, with only the key itself injected at sync time.
#
# The template replaces the Secret's data rather than merging, so `apiKey` is a
# template input and the rendered `config.yaml` is the single output key.
#
# Two values are load-bearing:
#
# apiURL — REQUIRED. `Setup()` assigns `monitor.url = p.ApiURL` with no
# default (pkg/monitors/uptimerobot/uptime-monitor.go:39-44); omit it and
# every request goes to a relative URL.
#
# enableMonitorDeletion — true, so that removing an EndpointMonitor removes
# its monitor, which is what the mission asks for. It is bounded: deletion is
# reachable only via `GetByName`, which filters `m.FriendlyName == name` —
# exact equality — against the name the controller itself computed from the
# default template `{{.Name}}-{{.Namespace}}`. Every name it can compute ends
# in `-uptime-monitor`; none of the 9 monitors already in the account does.
# Set it to false for a create/update-only kill switch.
#
# monitorNameTemplate is deliberately absent: an empty value makes
# GetNameTemplateFormat fall back to that same default (pkg/util/template-utils.go:9),
# and writing it out here would mean escaping Go template syntax inside a Go
# template for no gain.
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
@@ -30,8 +63,17 @@ spec:
name: openbao
target:
name: uptime-robot-api-key
template:
engineVersion: v2
data:
config.yaml: |
providers:
- name: UptimeRobot
apiKey: "{{ .apiKey }}"
apiURL: https://api.uptimerobot.com/v2/
enableMonitorDeletion: true
data:
- secretKey: api-key
- secretKey: apiKey
remoteRef:
key: infrastructure/uptime-robot
property: api_key
@@ -1,4 +1,11 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- crd-endpointmonitors.yaml
- externalsecret.yaml
- rbac.yaml
- deployment.yaml
# Last, and after the CRD it depends on. The monitors themselves — the only
# file in this directory that causes an UptimeRobot write. Everything above it
# is inert until an EndpointMonitor exists.
- pilot-endpointmonitors.yaml
@@ -0,0 +1,280 @@
---
# ============================================================================
# REN-7 CRITERION 3 PASSED. THIS COMMIT PUTS THE `audiobooks` ENDPOINTMONITOR
# BACK — IT IS THE DELIBERATE REVERT OF THE REMOVAL TEST, NOT A NEW FEATURE.
#
# The card requires proof in both directions, and both are now on record:
#
# CREATE 2026-09-26T03:26:27Z `Monitor Added: audiobooks.renner.uno-
# uptime-monitor`, UptimeRobot id 804092841, feed
# `psp.totalMonitors` 9 → 10.
# DELETE 2026-09-26T03:57:58Z object pruned by Flux after #1018 merged as
# 101c11f4; controller log `Removing monitor audiobooks.renner.uno-
# uptime-monitor from provider: UptimeRobot` then `Monitor Removed`;
# feed 10 → 9 with id 804092841 absent, confirmed independently.
#
# THE LOG STRING IS `Removing monitor … from provider` / `Monitor Removed`.
# An earlier revision of this header predicted `Deleting Monitor: …`, which
# appears nowhere in v2.2.13. Anyone grepping the predicted string finds
# nothing and wrongly concludes the delete failed. Grep `Monitor Removed`.
#
# Re-creating the object is idempotent by design: the friendly name binds
# object to monitor, not the id, so the new monitor carries a NEW id.
#
# PRECONDITION RE-CHECKED BEFORE THIS COMMIT WAS WRITTEN, and it is the one the
# no-finalizer caveat below demands: pod `ingressmonitorcontroller-5d577cc58-
# pgqwg` read from the Kubernetes API at 2026-09-26T08:01Z — `Ready=True`,
# `ContainersReady=True`, `restarts=0`, image v2.2.13, up since
# 2026-09-26T00:43:45Z, the same pod that served the whole create/delete
# window. That rule binds DELETES, so it does not gate this commit, but the
# reviewer should re-read it anyway before merging.
#
# EXPECT THIS CREATE TO BE REFUSED, AND THAT IS NOT A FAULT. The account cap is
# 10 monitors and it is full again (see the capacity section below). Restoring
# the object is what Zeus directed after the cap reading, on the reasoning that
# the retry loop then creates the monitor by itself the moment the tier is
# raised — no further PR needed. Until then this object logs one refused POST
# every 300s and nothing else: nothing pages, nothing degrades, and
# `audiobooks.renner.uno` stays covered by its 60s Gatus check
# (apps/gatus/configmap.yaml:163) throughout.
#
# REVERT: revert this commit. Flux prunes the object again and, because the
# monitor does not exist to begin with, nothing in UptimeRobot changes.
# ============================================================================
#
# REN-7 — the pilot batch. Two of the 14 monitors from the survey's §A0 page
# list, chosen because they are the two lowest-risk rows on it:
#
# * `www.renner.uno` — public by design, no Authentik gate in front,
# so the check reaches the application itself.
# * `audiobooks.renner.uno` — ungated `/healthcheck` endpoint, so likewise
# real app health rather than reachability.
#
# Both are already on the owner's approved page list (§A0 rows 2 and 6), so if
# the status page turns out to include new monitors automatically, these two
# appearing on it is authorised rather than a disclosure. That was a deliberate
# selection criterion, not a coincidence — see the status-page note below.
#
# Neither host had a monitor in the account when the pilot was chosen; today
# `www.renner.uno-uptime-monitor` (id 804092931) exists because this pilot
# created it. The nine pre-existing ones are
# `renner.uno`, `home.`, `immich.`, `auth.`, `docs.renner.uno` and the four
# third-party ones; creating an EndpointMonitor for any of those would produce
# a SECOND monitor beside the hand-made one rather than adopt it, because IMC
# only ever matches the name it computes itself. Picking two hosts with no
# existing monitor keeps the pilot free of that question, which belongs to the
# REN-8 rollout.
#
# ---
# WHY `spec.url` AND NOT `spec.urlFrom.ingressRef`
#
# REN-7 was written asking for `urlFrom.ingressRef`. That cannot work from this
# namespace, and the reason is structural rather than a permissions gap:
#
# * `IngressURLSource` has exactly one field, `name` — there is no namespace
# field on it (crd-endpointmonitors.yaml:443-459).
# * `discoverURLFromRefs` resolves that name against
# `ingressMonitor.Namespace` — the EndpointMonitor's OWN namespace
# (v2.2.13 pkg/kube/util/url.go:71 → :38-40).
#
# So an EndpointMonitor in `uptime-monitor` can only reference an Ingress in
# `uptime-monitor`, and there are none — every app Ingress lives in its app's
# namespace. `spec.url` short-circuits ref discovery entirely
# (pkg/kube/util/url.go:26-34), so no Ingress is read and the Role's
# `networking.k8s.io/ingresses` read grant stays unused here.
#
# The alternative — one EndpointMonitor per app namespace — was not taken: it
# would need a Role, a RoleBinding and a widened WATCH_NAMESPACE in every
# production application namespace, which is a far larger blast radius than the
# mission's "own namespace, own ServiceAccount, minimal RBAC" constraint allows.
# Writing the URL out here keeps all of this work inside `uptime-monitor`.
#
# The mission property that mattered is unaffected: these are still declarative
# opt-in objects in git, and deleting one still deletes its monitor.
#
# ---
# MONITOR NAMES
#
# `monitorNameTemplate` is left at the default `{{.Name}}-{{.Namespace}}`
# (pkg/util/template-utils.go:9), so these two objects produce UptimeRobot
# friendly names:
#
# www.renner.uno-uptime-monitor
# audiobooks.renner.uno-uptime-monitor
#
# The `-uptime-monitor` suffix is load-bearing and must not be templated away:
# the delete path matches `FriendlyName == <computed name>` exactly
# (pkg/monitors/uptimerobot/uptime-monitor.go:70), so the suffix is what keeps
# the controller structurally unable to touch the nine monitors already in the
# account (REN-34). The cost is that the public page shows the suffixed name;
# that is REN-9's to resolve and must not be "fixed" here by changing the
# template.
#
# ---
# NO `alertContacts` YET — AND THIS IS A GAP, NOT AN OVERSIGHT
#
# `processProviderConfig` ALWAYS appends `&alert_contacts=`, falling back to the
# provider-level value from config.yaml when the CR sets none
# (uptime-monitor.go:240-244). Neither is set today, so these two monitors are
# created with an EMPTY alert-contact list: UptimeRobot will record the outage
# and show it on the page, and will notify nobody.
#
# That is the safe direction to be wrong in for a pilot, but it means REN-7's
# "an alert reaches the owner's existing alert channel" is NOT satisfied by this
# file. Closing it needs the numeric alert-contact id from `getAlertContacts`,
# which needs the API key. Tracked separately; do not read a green pilot as
# proof the alert path works.
#
# ---
# NO `statusPages` — AND THE EXPERIMENT THAT JUSTIFIED LEAVING IT UNSET IS NOW
# ANSWERED: THE PAGE IS IN ALL-MONITORS MODE. KEEP THE FIELD UNSET.
#
# IMC only adds a monitor to a public status page when
# `uptimeRobotConfig.statusPages` is set; with the field absent
# `handleStatusPagesConfig` returns without calling the API at all
# (uptime-monitor.go:318-329). It was left unset to ask the page itself REN-9's
# question, and the pilot answered it in BOTH directions with no API key:
#
# 9 → 10 on create, monitor appeared on the feed unbidden
# 10 → 9 on prune, monitor disappeared from it unbidden
#
# So `uptime.renner.uno` includes every monitor in the account automatically and
# nothing needs adding to it. Caveat for REN-9, and it is the fragile part:
# all-monitors mode is a setting in the UptimeRobot dashboard, NOT in git. If
# anyone switches that page to a fixed list, this stops being true silently and
# no manifest, review or alert here would catch it.
#
# Baseline that comparison was made against, read keyless at 2026-09-26 from
# `https://uptime.renner.uno/api/getMonitorList/UgMXmP1iRV`: 9 monitors, ids
# 801710132/37/43/45/47/52/97 and 801714845/79. Parser notes for whoever reads
# this feed next: there is no top-level `monitors` key — the list is at `data`
# (and again at `psp.monitors`), the count is `psp.totalMonitors`, and an id is
# `monitorId`, not `id`.
#
# ---
# BLAST RADIUS
#
# Two new cluster objects in `uptime-monitor`, and two HTTP GETs every 300s from
# UptimeRobot's probers to two hosts that already serve the public internet.
# Nothing in an application namespace is read, written or restarted; no Ingress
# is edited; no DNS record changes. The 300s interval sits far inside the
# Traefik rate-limit middleware on the `websecure` routers, and the CrowdSec
# bouncer is detached today — but see the survey §C: if it is ever re-attached,
# UptimeRobot's source addresses must be allowlisted first.
#
# REVERT: delete this file and remove it from kustomization.yaml, or revert the
# merge commit. Flux prunes both objects, and because `enableMonitorDeletion` is
# true the controller then deletes both monitors from UptimeRobot. Reverting
# leaves the account exactly as the baseline above.
#
# ---
# ONE CAVEAT ON THAT REVERT, AND IT IS THE "A MONITOR THAT CANNOT BE REMOVED IS
# A LEAK" CASE: v2.2.13 USES NO FINALIZER.
#
# `handleDelete` runs from the reconcile path only when the Get returns
# NotFound (endpointmonitor_controller.go:80-87) — there is no finalizer
# anywhere in the source, so nothing holds the object in the API server until
# the monitor has actually been deleted. Deletion is therefore observed as an
# EVENT, and an event needs a running watcher:
#
# * controller running when the object is deleted → the monitor is deleted.
# This is the normal case and the one the pilot proves.
# * controller down when the object is deleted → the delete is never
# reconciled. On restart the informer lists what EXISTS, so a
# no-longer-existing object produces no request, and nothing else ever
# reconciles it: uptimerobot's `GetAll()` has no caller, so the account is
# never enumerated and never garbage-collected (REN-34). The monitor is
# then orphaned in UptimeRobot with no source in git.
#
# Recovering from that is manual and its blast radius is the account, so the
# operational rule is: do not delete an EndpointMonitor while the controller is
# unavailable. Check `kube_deployment_status_replicas_available{namespace=
# "uptime-monitor"}` first, and if a monitor is orphaned anyway, delete it by id
# through the API rather than hoping a later reconcile finds it.
#
# ---
# CAPACITY: THE ACCOUNT CAP IS EXACTLY 10 MONITORS, PROVEN BY EXPERIMENT AND
# WITHOUT AN API KEY. THIS SUPERSEDES THIS FILE'S EARLIER READING OF THE 403.
#
# An earlier revision of this header asserted the opposite — that "a duplicate
# URL, a monitor-count cap and a bad parameter would all arrive as 200 +
# `stat: fail`, not as 403", and therefore that the cap was EXCLUDED. That
# inference was wrong, and the correction is the useful finding here:
#
# UPTIMEROBOT RETURNS HTTP 403 WHEN THE ACCOUNT MONITOR LIMIT IS REACHED.
# It does not use the 200 + `stat: fail` validation path for this. Anyone
# reasoning about an IMC 403 should treat "account is full" as the FIRST
# hypothesis, not a discarded one.
#
# The proof is a natural experiment the removal test handed us for free, read
# from the controller log of the single pod that spans the whole window:
#
# 03:26:27 → 03:56:27 account at 10/10. `Creating Monitor:
# www.renner.uno-uptime-monitor` then `AddMonitor
# Request failed. Status Code: 403`, SEVEN consecutive
# 300s cycles. On cycles 2-7 that create is the only
# POST in the cycle, so it is not a burst/rate effect.
# 03:57:58 removal test deletes monitor 804092841 → account 9.
# 04:01:27 next ordinary reconcile, nothing else changed:
# `Monitor Added: www.renner.uno-uptime-monitor`,
# new id 804092931, feed back to 10.
#
# The only variable between the last 403 and the success is one free slot. A
# duplicate-URL or bad-parameter cause cannot explain a create that starts
# succeeding when an UNRELATED monitor is deleted. Capacity is settled on fact;
# the tier decision is REN-158's, and REN-8's §A0 page needs 18 monitors
# against this cap of 10, so no rollout batch may proceed until it is resolved.
#
# Why the object below stayed in git through those seven refusals: `Add()` is
# fire-and-forget (`handleCreate` returns nil regardless,
# endpointmonitor_created.go:12-31) and the failure is logged at INFO, not
# ERROR — so there is no error, no status and no alert, and the retrying object
# was the only evidence that the create was being refused at all. That property
# is worth remembering for REN-8: a rollout that exceeds the cap fails SILENTLY,
# monitor by monitor, with nothing to notice it but this one INFO line.
apiVersion: endpointmonitor.stakater.com/v1alpha1
kind: EndpointMonitor
metadata:
name: www.renner.uno
namespace: uptime-monitor
spec:
# `https://www.renner.uno` and not `/wp-admin` or `/wp-login.php`: those two
# paths are served by a SECOND Ingress that sits behind the Authentik outpost
# (apps/wordpress/ingress.yaml:50,57). `/` is the ungated one.
url: https://www.renner.uno/
uptimeRobotConfig:
# `http`, not `keyword`. The survey recommends a keyword check here
# eventually, and that is right — a WordPress instance can serve 200 with a
# broken page. It is deliberately not in the pilot: a keyword check needs a
# string chosen against the live rendered page, and getting it wrong
# produces a monitor that is red while the site is fine. Tune it in REN-8,
# once this file has proven the create/delete mechanism.
monitorType: http
# 300s is also IMC's own default (DefaultInterval, uptime-monitor.go:28) and
# matches the interval of the nine monitors already in the account. Written
# out rather than left implicit so the value is reviewable here.
interval: 300
---
# Restored by this commit. This object already proved criterion 3 end to end:
# created as id 804092841 at 03:26:27Z, removed as `Monitor Removed` at
# 03:57:58Z when it was pruned from git. See the header for that evidence.
#
# Its create will now be refused with HTTP 403 until the account tier is raised
# (REN-158), because `www.renner.uno` took the slot this object's deletion
# freed. That is expected and documented, not a regression to chase.
apiVersion: endpointmonitor.stakater.com/v1alpha1
kind: EndpointMonitor
metadata:
name: audiobooks.renner.uno
namespace: uptime-monitor
spec:
# The path belongs in `spec.url` and NOT in `spec.healthEndpoint`, because
# `healthEndpoint` is only consulted on the ref-discovery path and is
# explicitly ignored when `spec.url` is set (pkg/kube/util/url.go:31-33).
# `/healthcheck` is ungated, so the check reaches application health rather
# than mere reachability — it is the same endpoint the Gatus check uses
# (apps/gatus/configmap.yaml:163).
url: https://audiobooks.renner.uno/healthcheck
uptimeRobotConfig:
monitorType: http
interval: 300
+96
View File
@@ -0,0 +1,96 @@
---
# ServiceAccount + namespaced RBAC for IngressMonitorController (REN-6).
#
# There is NO ClusterRole here, on purpose. The Deployment sets
# WATCH_NAMESPACE=uptime-monitor, which makes the manager build a
# namespace-scoped cache (cmd/main.go `buildDefaultNamespaces`), so every read
# the controller performs is confined to this namespace. A ClusterRole would
# grant reach the process cannot use.
#
# The rules below were derived from what the pinned image actually calls, not
# from the chart's rules or the upstream kubebuilder markers. Deliberately NOT
# granted, and why:
#
# - `""/pods`, `""/services` — read from exactly one place,
# `tryGetHealthEndpointFromIngress` (pkg/kube/wrappers/ingress-wrapper.go:126),
# which is reached only from the `urlFrom.ingressRef` path and only when the
# EndpointMonitor sets no explicit `healthEndpoint`. It is a best-effort
# guess at a readiness-probe path. Omitted because that path cannot reach
# anything here: ingressRef resolves against the EndpointMonitor's own
# namespace (see the ingresses rule below) and nothing in `uptime-monitor`
# serves HTTP, so the grant would be inert.
# It does NOT fail gracefully, so grant it BEFORE an Ingress lands in this
# namespace, not after the first EndpointMonitor points at one. The function's
# own error branches do log-and-continue, but an RBAC denial never reaches
# them: the reconciler's client is `mgr.GetClient()` (cmd/main.go:168), the
# cache-backed client, so the first Service Get starts an informer and waits
# for it to sync on a `context.TODO()` that nothing cancels. A forbidden
# list/watch means that sync never completes, the Get never returns, and the
# single reconcile worker (`--max-concurrent-reconciles`, default 1,
# cmd/main.go:84) stops processing anything at all. `/readyz` is
# `healthz.Ping` (cmd/main.go:182) and keeps answering, so the pod stays
# Ready while reconciliation is wedged.
# - `route.openshift.io/routes` — OpenShift only; `kube.IsOpenshift` is false
# here, so the Route scheme is never even registered (cmd/main.go init()).
# - `endpointmonitors/finalizers` — IMC uses no finalizers. Deletion is driven
# by the reconciler's IsNotFound branch, not by a finalizer
# (endpointmonitor_controller.go:74-81); `grep -rn Finalizer internal/controller/`
# has no hits.
# - leases / events — no leader election (see the Recreate strategy comment in
# deployment.yaml) and the reconciler holds no EventRecorder.
#
# Re-check this list whenever the image tag in deployment.yaml moves: it is
# pinned and not Renovate-managed precisely so that never happens silently.
apiVersion: v1
kind: ServiceAccount
metadata:
name: ingressmonitorcontroller
namespace: uptime-monitor
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: ingressmonitorcontroller
namespace: uptime-monitor
rules:
# Its own custom resource — the only thing the reconciler is keyed on.
- apiGroups: ["endpointmonitor.stakater.com"]
resources: ["endpointmonitors"]
verbs: ["get", "list", "watch"]
# Not exercised by v2.2.13 (`grep -rn "Status()" internal/controller/` is
# empty), but granted: it is this CRD's own subresource, so it confers no
# reach outside the resource already listed above, and it is the one write an
# operator of this shape is expected to gain.
- apiGroups: ["endpointmonitor.stakater.com"]
resources: ["endpointmonitors/status"]
verbs: ["get", "update", "patch"]
# Read-only. Used to resolve an EndpointMonitor's URL from an Ingress via
# `spec.urlFrom.ingressRef`. Note that pkg/kube/util/url.go:71 resolves that
# ref against the EndpointMonitor's OWN namespace, so this grant can only ever
# reach Ingresses in `uptime-monitor` — it cannot read an app's Ingress.
- apiGroups: ["networking.k8s.io"]
resources: ["ingresses"]
verbs: ["get", "list", "watch"]
# The controller loads its entire configuration — including the UptimeRobot
# API key — from one Secret key at startup (pkg/config/config.go
# `LoadControllerConfig`). It reads it through the manager's APIReader, i.e. a
# direct GET by name and not a cached list, which is why `get` alone is enough
# and why resourceNames can pin it to the single Secret ESO renders.
- apiGroups: [""]
resources: ["secrets"]
resourceNames: ["uptime-robot-api-key"]
verbs: ["get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: ingressmonitorcontroller
namespace: uptime-monitor
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: ingressmonitorcontroller
subjects:
- kind: ServiceAccount
name: ingressmonitorcontroller
namespace: uptime-monitor
+120 -13
View File
@@ -1,7 +1,24 @@
# Automated uptime monitoring (REN-2): Stakater IngressMonitorController keeps
# UptimeRobot monitors in sync with `monitor.stakater.com/enabled` annotations on
# Ingress objects. This Kustomization starts with the API key only; the
# controller, its ServiceAccount and its RBAC land here in REN-6.
# UptimeRobot monitors in sync with `EndpointMonitor` custom resources. (Not
# Ingress annotations — `monitor.stakater.com/enabled` does nothing in IMC v2;
# there is no Ingress reconciler in the source, and docs/migration-guide.md
# calls the annotation mechanism obsolete. The opt-in property survives intact:
# creating an EndpointMonitor per host is more explicit than an annotation, and
# the default state is still nothing-monitored.) This Kustomization holds the
# API key, the controller Deployment, its ServiceAccount and its RBAC (REN-6),
# and since REN-7 the `EndpointMonitor` objects themselves.
#
# THE ENDPOINTMONITORS ARE THE ONLY OBJECTS HERE THAT CAUSE AN EXTERNAL WRITE,
# and `wait: true` cannot see whether that write succeeded. This is the same
# trap as the ExternalSecret one documented further down, one layer out:
# `EndpointMonitorStatus` is an empty struct in v2.2.13 and IMC never writes to
# it, so an EndpointMonitor has no `observedGeneration` and no `Reconciling` /
# `Stalled` condition — kstatus's generic rule returns Current for it
# immediately. A Ready Kustomization therefore proves the objects were APPLIED,
# and says nothing at all about whether UptimeRobot accepted the call. No
# `healthCheckExprs` rule can fix that, because there is no status field to
# read; the only evidence is the account itself or the controller's log. Do not
# let a green Kustomization stand in for either.
#
# A DEDICATED Kustomization rather than adding the ExternalSecret to
# ./kubernetes/infrastructure/post-crds, which is where the other
@@ -36,6 +53,22 @@
# holds this Kustomization NotReady, and it is safe to fail loudly here
# precisely because the blast radius is this Kustomization alone: nothing
# `dependsOn` it.
#
# That gate earns its keep now that the controller is here (REN-6) rather than
# only the key. IMC GETs its whole configuration from the Secret once, at
# startup, through the manager's APIReader, and never revalidates. Which failure
# it papers over depends on which one happens:
#
# * Secret absent (first sync never succeeded — `creationPolicy` is the default
# Owner, so no successful sync means no Secret at all): IMC panics in
# `LoadControllerConfig` and the pod CrashLoopBackOffs. That one is loud.
# * Secret present but stale, because a LATER refresh failed: the pod starts
# against the old value, reports Available, and the first sign of trouble is
# a rejected UptimeRobot call on some future reconcile. Nothing about
# Deployment health says the key is current.
#
# The second case is the live one — the Secret exists today — and the CEL rule
# below is what turns it into a NotReady Kustomization instead of a green one.
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
@@ -43,9 +76,10 @@ metadata:
namespace: flux-system
spec:
interval: 10m
# 2m, not the 1m most app Kustomizations use: the one object here is an
# ExternalSecret against a key a human may still be placing in OpenBao, and a
# retry every minute against a 404 is just noise in the ESO and Flux logs.
# 2m, not the 1m most app Kustomizations use: what stalls this Kustomization
# is an ExternalSecret against a key a human may still be placing in OpenBao,
# and a retry every minute against a 404 is just noise in the ESO and Flux
# logs.
retryInterval: 2m
timeout: 5m
sourceRef:
@@ -55,10 +89,10 @@ spec:
prune: true
wait: true
# No name/namespace qualifier, so this is evaluated against every
# ExternalSecret in this Kustomization's inventory. Today that is exactly one
# (apps/uptime-monitor/externalsecret.yaml); when the IMC Deployment joins in
# REN-6 it is still one, and any ExternalSecret added here later should be held
# to the same bar anyway.
# ExternalSecret in this Kustomization's inventory. That is exactly one
# (apps/uptime-monitor/externalsecret.yaml) — the IMC Deployment added in
# REN-6 brought no second one — and any ExternalSecret added here later should
# be held to the same bar anyway.
#
# `external-secrets.io/v1` is the version every one of the 54 ExternalSecret
# manifests in this repo is written against, so it is the GVK Flux records in
@@ -71,12 +105,85 @@ spec:
# Kustomization would report a CEL evaluation error rather than a clean
# in-progress.
#
# No `inProgress` expression: Flux treats "neither failed nor current" as
# in-progress, which is the behaviour wanted here.
# `failed` NAMES THE TWO REASONS ESO STOPS RETRYING ON, AND DELIBERATELY LEAVES
# OUT `SecretSyncedError` (REN-106). The first cut of this block fired on any
# `Ready=False` at all, and that path is undampened: the `flux-failures` Alert
# (infrastructure/flux-notifications/alerts.yaml) forwards error events from
# every Kustomization to Alertmanager, and a Flux health check has no `for:`
# window, so a single bad poll would have mailed the owner.
#
# Read off the ESO the cluster actually runs, v2.11.0 (the chart range
# `>=2.7.0 <3.0.0` in infrastructure/external-secrets/helmrelease.yaml resolves
# there today). On the Secret target path ESO sets `Ready=False` with exactly
# three reasons, and what separates them is whether it intends to try again
# (pkg/controllers/externalsecret/externalsecret_controller.go):
# SecretSyncedError — provider read, template or update failed. Almost every
# site returns the error, so controller-runtime requeues
# with backoff. This is the reason an OpenBao blip, a
# token renewal AND a not-yet-populated key all produce —
# the transient case and the REN-100 case share one
# string, so the string cannot tell them apart.
# SecretOwnedByOther — the target Secret is owned by something else.
# SecretImmutable — the target Secret is immutable and needs an update.
# The last two return `nil`, commented in ESO's own source as "cant be fixed by
# retrying": ESO has given up, nothing is in flight, so failing at once is
# correct and cannot be a flap. Narrowing to `reason == 'SecretSyncedError'`
# instead — REN-106's first suggestion — would have inverted that, keeping the
# one flap-prone reason and dropping the two that cannot flap.
#
# Two details that make `failed` an allowlist of reasons rather than the
# complement of `SecretSyncedError`: one `SecretSyncedError` site
# (`ErrSecretSetCtrlRef`) also returns `nil`, so that string is not a reliable
# "still trying" signal either way; and the generic-target path (`spec.target`
# pointing at something other than a Secret) has its own reason set,
# `ResourceSyncedError`, which is not used here — this ExternalSecret targets a
# Secret — and is left to the in-progress path if it ever is.
#
# A `SecretSyncedError` is dampened, not dropped: it matches neither
# expression, so Flux reports in-progress and keeps polling until `timeout: 5m`
# above expires and the Kustomization fails. That timeout is the `for:` window
# this path otherwise has none of — a blip that clears inside it never reaches
# the owner, a sync that is genuinely stuck still does, about five minutes
# later.
#
# EVERY SHAPE AN ExternalSecret STATUS CAN TAKE GETS A VERDICT FROM THE TWO
# EXPRESSIONS BELOW. There are three, not two, and the third one — no `Ready`
# condition at all — is the one a reader is most likely to miss:
#
# 1. no conditions yet (applied, first reconcile unfinished): the `has()`
# guard makes both expressions false -> in-progress, bounded by
# `timeout: 5m`.
# 2. `Ready=True`: `current` -> Current. Note this includes reasons
# `SecretDeleted` and `SecretMissing`, which ESO reports as Ready=True
# (markAsDone) even though no target Secret exists. Reaching them needs
# `deletionPolicy: Delete`/`Merge` or `creationPolicy: Merge`; this
# ExternalSecret takes the defaults (Retain/Owner), where a vanished
# provider key is a `SecretSyncedError` instead. Anyone who later sets
# either policy here reopens REN-100 through this expression.
# 3. `Ready=False`: `failed` for the two non-retrying reasons above,
# in-progress for the rest.
#
# No `inProgress` expression: only `apiVersion` and `current` are required by
# the CRD (flux-system/gotk-components.yaml, kustomize-controller v1.9.5), and
# Flux evaluates inProgress -> failed -> current and returns in-progress when
# nothing matches (fluxcd/pkg runtime/cel, StatusEvaluator.Evaluate). An
# `inProgress` naming shapes 1 and 3 would therefore be a no-op on all three.
#
# "conditions present but no `Ready` condition" is NOT a fourth shape (REN-106).
# On a live object ESO only ever writes conditions of type `Ready`; the
# `Deleted` type is never persisted — it is built on a throwaway object in the
# `IsNotFound` branch of Reconcile purely to retire that object's metric series
# ("this does not actually set the condition on the ExternalSecret, because it
# does not exist"). So `externalsecret_status_condition{condition="Deleted"}`
# outlives the object it names, until the ESO pod restarts.
# `ai/wiki-index-basic-auth` is that: retired from git in 419cbed3, target
# Secret gone from the namespace, sync counter frozen, still a series in an ESO
# pod older than the prune. Any alert built on this metric (REN-103) has to
# exclude that shape; this health check never sees it.
healthCheckExprs:
- apiVersion: external-secrets.io/v1
kind: ExternalSecret
failed: "has(status.conditions) && status.conditions.exists(c, c.type == 'Ready' && c.status == 'False')"
failed: "has(status.conditions) && status.conditions.exists(c, c.type == 'Ready' && c.status == 'False' && (c.reason == 'SecretOwnedByOther' || c.reason == 'SecretImmutable'))"
current: "has(status.conditions) && status.conditions.exists(c, c.type == 'Ready' && c.status == 'True')"
dependsOn:
# ESO itself, and the `uptime-monitor` namespace (declared centrally in
@@ -72,10 +72,29 @@ spec:
# alertable beforehand.
#
# That reasoning is about the metrics, which this line provides. The
# ServiceMonitor is only the scrape wiring and is declared standalone in
# apps/monitoring-rules instead — the same treatment velero/helmrelease
# already gives its own, and for the same reason: the chart's template is
# gated on monitoring.coreos.com/v1 existing at install time.
# scrape wiring — both the ServiceMonitor and the metrics Service — is
# declared standalone in apps/monitoring-rules/cilium-servicemonitor.yaml
# instead. For the ServiceMonitor that is the same treatment
# velero/helmrelease already gives its own, and for the same reason: the
# chart's template is gated on monitoring.coreos.com/v1 existing at
# install time.
#
# The Service is there for a different reason, and it is the one that
# bit us (REN-147). templates/cilium-agent/service.yaml renders only when
# `prometheus.enabled` AND (`serviceMonitor.enabled` OR `metricsService`).
# With serviceMonitor.enabled false and metricsService at its default,
# no Service was rendered, so the standalone ServiceMonitor selected
# nothing and cilium_* had zero series for as long as this file has
# looked like this. Turning either flag on here is the chart-native fix
# and would render exactly that one object — but it is a values change,
# so helm-controller would upgrade the CNI release and three-way-merge
# against live objects, and the reloader annotation above turns any
# resulting cilium-config change into a rolling restart of every agent.
# Hence the Service is applied by the apps Kustomization, which cannot.
#
# If you ever do set `serviceMonitor.enabled` or `metricsService` here,
# delete the matching object from cilium-servicemonitor.yaml in the same
# change.
#
# Here it is worse than for velero. Cilium's chart does not skip the
# template, it aborts rendering — "Service Monitor requires
@@ -134,3 +134,56 @@ unaffected.
- Image findings still surface in the security-scan agent's report
(Skipped / "Flux image-automation owns this") so they remain visible
even when an image isn't yet enrolled in automation here.
## What watches this
Worth stating explicitly, because for a while nothing did and each
plausible reporter deferred to another: the update-audit agent skips
`$imagepolicy`-marked images (`ci/agents/update-audit/prompt.md`), the
security scan records them as "Flux image-automation owns this" (above),
and both are correct to — an enrolled image is supposed to be this
directory's problem.
What exists today:
- `flux-notifications/alerts.yaml` emails error-severity events from
every image kind. Immediate, but edge-triggered: one mail at the
moment of transition, then silence. Nothing says "still broken twelve
hours later" and nothing answers "is anything not-Ready right now".
- `apps/monitoring-rules/flux-podmonitor.yaml` scrapes the controllers,
so reconcile latency and controller-level error counts are queryable.
Readiness is **not** on that endpoint: at the controller versions
pinned in `clusters/homelab/flux-system/gotk-components.yaml`,
`gotk_reconcile_condition` and `gotk_suspend_status` are declared by
the shared runtime library and recorded by nothing, so they export no
series however the scrape is written.
- `apps/monitoring/helmrelease-prometheus.yaml` therefore turns on
kube-state-metrics `customResourceState` over eight Flux kinds, which
is where per-object readiness now comes from: `gotk_resource_info`
with `ready` and `suspended` labels. For ImagePolicy it also carries
the resolved `tag`, and each ImageRepository gets
`gotk_image_repository_tag_count`.
- `apps/monitoring-rules/prometheusrule-flux.yaml` builds the standing
alerts on those series. `FluxImageAutomationNotReady` fires after an
ImageRepository, ImagePolicy or ImageUpdateAutomation has been
not-Ready for 2h (two scan intervals for the 31 of 34 repositories at
`interval: 1h`); `FluxResourceNotReady` covers every other Flux kind
at 1h; `FluxImagePolicyMetricsMissing` is the dead man's switch that
says so if the metrics themselves stop. This is the "still broken two
hours later" signal the edge-triggered mail above cannot give.
What still does **not** exist: an *alert* for a policy that is perfectly
Ready while its own filter has frozen it — `tika` if upstream ships a
3.x. The series for it are now recorded
(`flux:image_policy_tag_frozen:14d` and the two rules under it), but no
threshold is attached yet, because none is derivable from the history
available today. Measured 2026-09-26 against the 191 automated bump
commits and the upstream registries: a flat duration cannot work (gatus
went 127 days between bumps while healthy), a 10% tag-count growth
comparator fires on three variant-churning repositories and misses
`tika` at 3.0%, relaxing it to one new tag adds `velero-plugin-for-aws`,
whose pin is deliberate, and `.status.lastScanResult.latestTags` is
sorted alphabetically rather than newest-first so it cannot substitute.
The recording rules exist to accumulate the per-repository `tagCount`
history that a defensible threshold has to be derived from; the alert is
tracked separately.
@@ -245,14 +245,14 @@ kind: Namespace
metadata:
name: paperclip
---
# IngressMonitorController, which syncs UptimeRobot monitors from Ingress
# annotations (REN-2). Declared here rather than in the workload Kustomization
# IngressMonitorController, which syncs UptimeRobot monitors from
# `EndpointMonitor` custom resources (REN-2; not Ingress annotations — those are
# obsolete in IMC v2). Declared here rather than in the workload Kustomization
# for the reason spelled out above `woodpecker`: the `infrastructure` KS creates
# namespaces, and `infrastructure-crds` (post-crds, which includes
# ../network-policies/) runs after it — so declaring it in the workload KS would
# invert that order the moment this namespace gains a CiliumNetworkPolicy, which
# it will when the controller arrives and needs scoped egress to the UptimeRobot
# API. It holds nothing but the API key Secret until then.
# invert that order the moment this namespace gains a CiliumNetworkPolicy. It
# has one now: ../network-policies/uptime-monitor-ns.yaml.
#
# No pod-security labels: the controller needs no hostPath, hostNetwork or
# privilege, so the cluster's default `baseline` enforcement is enough.
@@ -29,4 +29,5 @@ resources:
- wordpress-ns.yaml
- ai-ns.yaml
- navidrome-ns.yaml
- uptime-monitor-ns.yaml
- allow-cilium-health.yaml
@@ -22,9 +22,28 @@ spec:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": authentik
goauthentik.io/outpost-name: lidarr-gate
# blackbox-exporter only, on the two app listeners — NOT the whole
# monitoring namespace, which this used to admit on every port (REN-28).
# Both apps take identity from X-Authentik-Username unconditionally, so
# every pod that can open a TCP connection to one of these listeners can
# claim any username; a namespace-wide admit put every current and future
# monitoring workload inside that boundary.
#
# NOTE: Cilium matches the POD port after service translation. The probes
# in monitoring-rules/probes-blackbox.yaml target :80 on the Services,
# which resolve to containerPort 4533 (navidrome/deployment.yaml) and 8686
# (navidrome/lidarr.yaml). `port: "80"` here would read clean in review and
# break both probes.
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": monitoring
app.kubernetes.io/name: prometheus-blackbox-exporter
toPorts:
- ports:
- port: "4533"
protocol: TCP
- port: "8686"
protocol: TCP
- fromEntities:
- host
- remote-node
@@ -18,6 +18,16 @@ spec:
# the trust boundary off the gate outpost — see
# kubernetes/apps/paperless/network-policy.yaml for the
# gate-specific ingress rule.
#
# Note: the database and monitoring allows below DO broaden the trust
# boundary in the way this note warns about, because endpointSelector
# is {} and therefore covers the paperless pod too. They are kept
# because both are load-bearing — CNPG reaches the postgres cluster
# from `database`, and the blackbox probe of
# http://paperless.paperless.svc.cluster.local:80/accounts/login/
# (kubernetes/apps/monitoring-rules/probes-blackbox.yaml:250) runs
# from `monitoring`.
# Scoping them to specific pods and ports is tracked in REN-28.
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": database
@@ -0,0 +1,54 @@
---
# Baseline policy for the `uptime-monitor` namespace (REN-6).
#
# Copied from netbird-service-sync-ns.yaml, which is the closest existing
# neighbour in this directory: a controller that serves nothing and only calls
# out — kube-apiserver plus one external HTTPS API. Same shape here.
#
# No Traefik ingress rule, because nothing in this namespace is behind an
# Ingress. (For anyone reaching for a wiki note: the selector to admit Traefik
# lives in `ingress` with label `app.kubernetes.io/name: traefik`, as in
# paperclip-ns.yaml. An `ingress-nginx` selector matches nothing in this cluster
# and would admit nothing, silently.)
#
# Deliberately no OpenBao egress: the controller never talks to OpenBao. ESO, in
# the external-secrets namespace, resolves the key and writes the Secret; the
# controller only reads that Secret through the API server.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: default-deny
namespace: uptime-monitor
spec:
endpointSelector: {}
ingress:
# Kubelet liveness/readiness probes on :8081 arrive with the `host`
# identity. This is the only ingress the namespace admits — without it the
# controller pod fails its probes and CrashLoopBackOffs, with nothing in its
# own logs to say why.
- fromEntities:
- host
- remote-node
egress:
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": kube-system
k8s-app: kube-dns
toPorts:
- ports:
- port: "53"
protocol: UDP
- port: "53"
protocol: TCP
# EndpointMonitor watch, Ingress reads, and the startup GET of the config
# Secret.
- toEntities:
- kube-apiserver
# api.uptimerobot.com. Not reached at all until an EndpointMonitor exists:
# the controller makes no API call at startup.
- toEntities:
- world
toPorts:
- ports:
- port: "443"
protocol: TCP
+306
View File
@@ -0,0 +1,306 @@
#!/bin/sh
# Machine-check the PrometheusRule manifests in this repo: parse every rule with
# `promtool check rules`, run the committed unit suites with `promtool test
# rules`, then damage the rules on purpose and require the suites to notice.
#
# Run: sh scripts/ci/promtool-rules.sh [repo-root]
# Needs: promtool on PATH (3.x — see the version note below), sh, find, sed, awk.
# Offline: no cluster, no registry, no secrets, no network.
# Tests: scripts/ci/test-promtool-rules.sh (runs the guards against fixture
# trees with a stub promtool; no real promtool needed).
#
# WHY THE MIRROR. promtool reads Prometheus rule files, which are bare `groups:`
# documents. A PrometheusRule is a Kubernetes object carrying the same thing
# under `spec:`, so promtool cannot read one directly. This script copies every
# PrometheusRule into a temporary tree at the SAME relative path with `spec:`
# stripped and the body dedented, and runs promtool there. Nothing is written
# back into the checkout.
#
# WHY THE MUTATION PHASE. Two failure modes bit while the first suite was being
# written, and neither changed promtool's exit code:
#
# * a `rule_files:` entry that did not resolve. promtool prints
# "WARNING: no file match pattern ...", loads ZERO rules, and then every
# `exp_alerts: []` assertion passes against an empty rule set. Ninety
# silence assertions, green, testing nothing.
# * traces generated against the wrong clock window, so every input series was
# flat zero. Same outcome: green, testing nothing.
#
# A suite that passes tells you the rules match the assertions. It does not tell
# you the assertions reach the rules. The two guards in step 4 and step 5 cover
# the first failure mode by name; the mutation phase in step 6 is the backstop
# for the general case, and is the only part of this that would have caught the
# second.
#
# PROMTOOL VERSION. Must match the Prometheus the cluster actually runs, which is
# whatever kube-prometheus-stack >= 72.0.0 ships — 3.x. Alert `for` evaluation is
# version-sensitive, so a 2.x promtool would be checking a different evaluator
# than production and the margins asserted in the suites would be claims about
# the wrong program. The exact pin lives on the `image:` line in
# .woodpecker/ci-manifests-test.yml; this script asserts only the major version,
# so a patch bump there needs no change here.
set -eu
ROOT="${1:-.}"
cd "$ROOT"
# Floors against silent discovery failure. Both are LOOSE on purpose. A floor
# tight enough to catch the loss of one file sits inside normal churn, so it
# fails spuriously — and the fix a hurried person reaches for is lowering the
# floor, which is how a guard becomes decoration. These catch total failure and
# gross partial loss, which is the failure that actually happens: a `find`
# expression the image's BusyBox does not support, or a moved directory.
#
# Same reasoning, and the same asymmetry, as the floors in
# .woodpecker/ci-manifests-test.yml. 17 PrometheusRules and 1 suite exist today.
RULE_FLOOR="${PROMTOOL_RULE_FLOOR:-12}"
SUITE_FLOOR="${PROMTOOL_SUITE_FLOOR:-1}"
MUTATION_FLOOR="${PROMTOOL_MUTATION_FLOOR:-1}"
TESTS_DIR="${PROMTOOL_TESTS_DIR:-kubernetes/apps/monitoring-rules/tests}"
MUTATIONS="${PROMTOOL_MUTATIONS:-$TESTS_DIR/mutations.txt}"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT INT TERM
MIRROR="$WORK/mirror" # stripped manifests + suites, at repo-relative paths
MUTANT="$WORK/mutant" # a damaged copy of the above, one mutation at a time
mkdir -p "$MIRROR"
die() { echo "FAIL: $*" >&2; exit 1; }
command -v promtool >/dev/null 2>&1 || die "promtool is not on PATH."
# `promtool --version` writes to stderr on some builds, hence the 2>&1.
PROMTOOL_VERSION="$(promtool --version 2>&1 \
| sed -n 's/^promtool, version \([0-9][0-9.]*\).*/\1/p' | head -1)"
[ -n "$PROMTOOL_VERSION" ] || die "cannot parse 'promtool --version' output."
case "$PROMTOOL_VERSION" in
3.*) : ;;
*) die "promtool $PROMTOOL_VERSION is not 3.x. The cluster runs Prometheus 3
(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. Fix the image in .woodpecker/ci-manifests-test.yml." ;;
esac
echo "promtool $PROMTOOL_VERSION"
# ---------------------------------------------------------------------------
# 1. Discover PrometheusRule manifests.
# ---------------------------------------------------------------------------
# NOT `find -printf` and NOT `grep -r --include`: this runs on the BusyBox find
# and grep in prom/prometheus, which reject both. `find -print0 | xargs -0 grep`
# is the portable shape, and unlike a command substitution it does not run into
# ARG_MAX on a tree this wide. Anchoring `^kind:` at column 0 keeps the match to
# the top-level field and away from anything nested or quoted in a comment.
find kubernetes -name '*.yaml' -type f -print0 2>/dev/null \
| xargs -0 grep -l '^kind: PrometheusRule' 2>/dev/null \
| sort > "$WORK/rules" || true
rule_count=$(grep -c . "$WORK/rules" || true)
if [ "$rule_count" -lt "$RULE_FLOOR" ]; then
echo "FAIL: found only $rule_count PrometheusRule manifests; floor is $RULE_FLOOR." >&2
echo " If the tree legitimately shrank, lower PROMTOOL_RULE_FLOOR in" >&2
echo " .woodpecker/ci-manifests-test.yml — do not delete the guard." >&2
echo " Otherwise discovery is broken, and a check that found nothing" >&2
echo " must not report success." >&2
exit 1
fi
echo "discovered $rule_count PrometheusRule manifests"
# ---------------------------------------------------------------------------
# 2. Mirror each manifest as a bare rule file, and parse it.
# ---------------------------------------------------------------------------
# The awk strips the `spec:` line, dedents its block by two, and stops at the
# first line back at column 0 — so a trailing top-level key (`status:`) is
# dropped rather than emitted at the wrong depth. Blank lines pass through
# unchanged; they carry no indentation to dedent.
fail=0
while IFS= read -r f; do
[ -n "$f" ] || continue
mkdir -p "$MIRROR/$(dirname "$f")"
awk '
!ins && /^spec:[ \t]*$/ { ins = 1; next }
ins {
if ($0 == "") { print ""; next }
if (substr($0, 1, 2) == " ") { print substr($0, 3); next }
exit
}
' "$f" > "$MIRROR/$f"
# `promtool check rules` reports "SUCCESS: 0 rules found" on an empty document
# and exits 0, so a strip that produced nothing would pass. Require the result
# to actually be a rule file.
first="$(sed -n '/^[[:space:]]*$/d; p' "$MIRROR/$f" | head -1)"
if [ "$first" != "groups:" ]; then
echo "FAIL $f" >&2
echo " stripping spec: yielded [$first], expected [groups:]." >&2
echo " This manifest's rules are NOT being checked. Either spec: is" >&2
echo " not a top-level key here, or its body is not a groups: list." >&2
fail=1
continue
fi
if out=$(promtool check rules "$MIRROR/$f" 2>&1); then
echo "ok $f — $(echo "$out" | sed -n 's/.*\(SUCCESS: .*\)/\1/p' | head -1)"
else
echo "FAIL $f" >&2
echo "$out" | sed 's/^/ /' >&2
fail=1
fi
done < "$WORK/rules"
[ "$fail" -eq 0 ] || exit 1
# ---------------------------------------------------------------------------
# 3. Discover the committed unit suites and copy them into the mirror.
# ---------------------------------------------------------------------------
find "$TESTS_DIR" -name '*_test.yaml' -type f 2>/dev/null | sort > "$WORK/suites" || true
suite_count=$(grep -c . "$WORK/suites" || true)
if [ "$suite_count" -lt "$SUITE_FLOOR" ]; then
echo "FAIL: found only $suite_count *_test.yaml suites under $TESTS_DIR;" >&2
echo " floor is $SUITE_FLOOR. Discovery is broken, or the suites were" >&2
echo " deleted. Either way this step must not report success." >&2
exit 1
fi
echo "discovered $suite_count unit suite(s)"
while IFS= read -r t; do
[ -n "$t" ] || continue
mkdir -p "$MIRROR/$(dirname "$t")"
cp "$t" "$MIRROR/$t"
done < "$WORK/suites"
# ---------------------------------------------------------------------------
# 4. Resolve every rule_files entry BEFORE running anything.
# ---------------------------------------------------------------------------
# The guard for the first failure mode at the top of this file. promtool joins
# dirname(suite) onto each `rule_files` entry and, when the result matches
# nothing, prints a WARNING and carries on with zero rules loaded — so the suite
# passes having asserted nothing. There is no promtool flag that makes it fatal,
# so resolve the paths here and refuse.
#
# The extraction is deliberately narrow: `rule_files:` at column 0, then the
# ` - path` lines under it, stopping at the next column-0 key. The flow form
# (`rule_files: [a, b]`) is not parsed — so it is rejected by name below rather
# than skipped in silence, which would reopen the hole this closes.
while IFS= read -r t; do
[ -n "$t" ] || continue
if grep -q '^rule_files:[[:space:]]*[^[:space:]]' "$t"; then
die "$t writes rule_files inline. Use the block form (rule_files: followed
by ' - path' lines): this script does not resolve the inline form, and
an unresolved path there would be checked by nobody."
fi
awk '
/^rule_files:[[:space:]]*$/ { inrf = 1; next }
inrf && /^[[:space:]]*-[[:space:]]*/ {
sub(/^[[:space:]]*-[[:space:]]*/, "")
sub(/[[:space:]]*(#.*)?$/, "")
gsub(/^["'"'"']|["'"'"']$/, "")
print
next
}
inrf && /^[^[:space:]]/ { inrf = 0 }
' "$t" > "$WORK/rf"
n=$(grep -c . "$WORK/rf" || true)
[ "$n" -gt 0 ] || die "$t declares no rule_files. It would load zero rules and
pass every exp_alerts: [] assertion against nothing."
while IFS= read -r rf; do
[ -n "$rf" ] || continue
case "$rf" in
/*) resolved="$rf" ;;
*) resolved="$MIRROR/$(dirname "$t")/$rf" ;;
esac
[ -f "$resolved" ] || die "$t references rule_files entry [$rf], which does
not resolve. promtool would treat that as a WARNING, load zero rules and
pass. Entries are relative to the SUITE's own directory, not the repo
root: from $TESTS_DIR, a manifest in the parent is '../name.yaml'."
done < "$WORK/rf"
echo "ok $t — $n rule_files entry/entries resolved"
done < "$WORK/suites"
# ---------------------------------------------------------------------------
# 5. Run the suites.
# ---------------------------------------------------------------------------
# run_suites <dir> -> 0 if every suite passed, 1 otherwise. Output goes to
# $WORK/out so the mutation phase can stay quiet when a mutation is correctly
# caught, and the caller can print it when the result is not what it expected.
run_suites() {
_dir="$1"
_rc=0
: > "$WORK/out"
while IFS= read -r _t; do
[ -n "$_t" ] || continue
if ! (cd "$_dir" && promtool test rules "$_t") >> "$WORK/out" 2>&1; then
_rc=1
fi
done < "$WORK/suites"
# Belt and braces on the same trap step 4 covers by path: if a future promtool
# reaches a missing rule file by a route this script does not model, the
# WARNING is still the tell. A warning here is a failure.
if grep -q 'WARNING: no file match pattern' "$WORK/out"; then
echo " (promtool warned about an unresolved rule file — zero rules loaded)" \
>> "$WORK/out"
_rc=1
fi
return $_rc
}
if run_suites "$MIRROR"; then
sed 's/^/ /' "$WORK/out"
echo "ok all $suite_count suite(s) pass against the rules as committed"
else
echo "FAIL: the committed rules do not satisfy their own unit suites." >&2
sed 's/^/ /' "$WORK/out" >&2
exit 1
fi
# ---------------------------------------------------------------------------
# 6. Mutation phase: break the rules on purpose; the suites must notice.
# ---------------------------------------------------------------------------
[ -f "$MUTATIONS" ] || die "$MUTATIONS is missing. Without it the suites vouch
for themselves — see WHY THE MUTATION PHASE at the top of this script."
mutation_count=0
while IFS='|' read -r name target program; do
case "${name:-}" in ''|\#*) continue ;; esac
[ -n "${target:-}" ] && [ -n "${program:-}" ] \
|| die "$MUTATIONS: malformed entry [$name]; expected name|path|sed-program."
[ -f "$MIRROR/$target" ] \
|| die "$MUTATIONS: mutation [$name] targets $target, which is not one of
the $rule_count discovered PrometheusRule manifests."
rm -rf "$MUTANT"
cp -R "$MIRROR" "$MUTANT"
sed "$program" "$MIRROR/$target" > "$MUTANT/$target"
# A sed program that matches nothing leaves the tree undamaged, the suites
# pass, and the mutation reads as undetected — so it would already fail below.
# It is checked separately because the two cases need different repairs: "the
# suite got weaker" means fix the suite, "the rule was renamed or reworded"
# means fix the sed program.
if cmp -s "$MIRROR/$target" "$MUTANT/$target"; then
die "mutation [$name] changed nothing in $target. Its sed program no longer
matches — the rule was probably renamed or reworded. Update the program
in $MUTATIONS; do not delete the control."
fi
if run_suites "$MUTANT"; then
echo "FAIL: mutation [$name] was NOT detected — the suites still pass with" >&2
echo " $target damaged. Whatever this mutation breaks is uncovered." >&2
echo " Diff applied to the stripped manifest:" >&2
diff "$MIRROR/$target" "$MUTANT/$target" | sed 's/^/ /' >&2 || true
exit 1
fi
echo "ok mutation [$name] detected"
mutation_count=$((mutation_count + 1))
done < "$MUTATIONS"
if [ "$mutation_count" -lt "$MUTATION_FLOOR" ]; then
echo "FAIL: ran only $mutation_count mutations; floor is $MUTATION_FLOOR." >&2
echo " An empty mutation phase leaves the suites vouching for" >&2
echo " themselves, which is the thing it exists to prevent." >&2
exit 1
fi
echo "PASS $rule_count manifests parsed, $suite_count suite(s) green," \
"$mutation_count mutation(s) caught"
+264
View File
@@ -0,0 +1,264 @@
#!/bin/sh
# Offline self-test for the guards in promtool-rules.sh.
#
# Run: sh scripts/ci/test-promtool-rules.sh
#
# No real promtool, no network, no cluster — a stub `promtool` on PATH plus small
# fixture trees. That is deliberate: what is being tested here is not whether
# promtool evaluates PromQL correctly (it does, and ci-manifests-test.yml checks
# the real rules with the real binary) but whether promtool-rules.sh REFUSES TO
# REPORT SUCCESS when it has actually checked nothing. Those are the cases a
# green pipeline cannot distinguish from real coverage, so they need their own
# test, and stubbing promtool is what lets this run on the ci-claude image next
# to the other script tests.
#
# Every case below is a failure mode that occurred or was one edit away:
#
# empty-discovery the floor guard the issue asked for
# no-suites same, for the test files
# unresolved-rule-file the one that actually shipped — promtool WARNs, loads
# zero rules, and passes every silence assertion
# warning-only the same hole reached by a route the path check cannot
# see; caught by scanning promtool's output
# inline-rule-files a shape the resolver cannot parse, rejected by name
# rather than skipped in silence
# empty-rule-files a suite that declares no rules at all
# unstrippable-spec a manifest the spec: strip does not turn into a rule
# file, which promtool would report as "0 rules found"
# mutation-no-op a control whose sed no longer matches anything
# mutation-undetected a control the suite does not catch
# happy-path all of the above satisfied: must PASS
set -eu
HERE="$(cd "$(dirname "$0")" && pwd)"
SUT="$HERE/promtool-rules.sh"
[ -f "$SUT" ] || { echo "FAIL: $SUT not found"; exit 1; }
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT INT TERM
fails=0
# --- the stub -------------------------------------------------------------
# `check rules` always succeeds. `test rules` fails if the tree it is run from
# contains the sentinel a mutation introduces, which is what makes the mutation
# loop genuinely exercised rather than mocked: the baseline tree has no
# sentinel and passes, a mutated tree has one and fails.
#
# STUB_VERSION what to report for --version
# STUB_ALWAYS_PASS `test rules` always exits 0, even when mutated
# STUB_WARN `test rules` emits promtool's unresolved-rule-file warning
# and still exits 0
mkdir -p "$TMP/bin"
cat > "$TMP/bin/promtool" <<'STUB'
#!/bin/sh
case "${1:-}" in
--version) echo "promtool, version ${STUB_VERSION:-3.15.0} (branch: stub)"; exit 0 ;;
esac
case "${1:-} ${2:-}" in
"check rules") echo " SUCCESS: 1 rules found"; exit 0 ;;
"test rules")
[ -n "${STUB_WARN:-}" ] && echo " WARNING: no file match pattern nowhere.yaml"
[ -n "${STUB_ALWAYS_PASS:-}" ] && { echo " SUCCESS"; exit 0; }
if grep -rq 'MUTATED-SENTINEL' . 2>/dev/null; then
echo " FAILED: sentinel present"; exit 1
fi
echo " SUCCESS"; exit 0 ;;
esac
echo "stub: unhandled [$*]" >&2; exit 2
STUB
chmod +x "$TMP/bin/promtool"
# --- fixtures -------------------------------------------------------------
# A whole fixture repo per case, so no case can leak into another. `rules` is
# the count of PrometheusRule manifests to emit; the suite references the first
# one via `../`, the same relative shape the real suites use.
mkfixture() { # <dir> [n-manifests]
d="$1"; n="${2:-1}"
mkdir -p "$d/kubernetes/apps/monitoring-rules/tests"
i=1
while [ "$i" -le "$n" ]; do
cat > "$d/kubernetes/apps/monitoring-rules/prometheusrule-fix$i.yaml" <<YAML
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: fix$i
spec:
groups:
- name: fix$i.rules
rules:
- alert: Fixture$i
expr: up == 0
for: 8m
YAML
i=$((i + 1))
done
cat > "$d/kubernetes/apps/monitoring-rules/tests/fixture_test.yaml" <<'YAML'
rule_files:
- ../prometheusrule-fix1.yaml
tests:
- interval: 1m
input_series:
- series: 'up'
values: '1x10'
alert_rule_test:
- eval_time: 10m
alertname: Fixture1
exp_alerts: []
YAML
# A control whose sed inserts the stub's sentinel, so the baseline passes and
# the mutant fails — the shape a real control has.
printf 'sentinel|kubernetes/apps/monitoring-rules/prometheusrule-fix1.yaml|s/for: 8m/for: 8m # MUTATED-SENTINEL/\n' \
> "$d/mutations.txt"
}
# run <fixture-dir> [env assignments...] -> exit status of the SUT, output in
# $TMP/out. Trailing assignments are appended AFTER the defaults, and `env`
# applies them in order, so a case can override any default.
run() {
d="$1"; shift
set +e
( cd "$d" && env PATH="$TMP/bin:$PATH" \
PROMTOOL_RULE_FLOOR=1 \
PROMTOOL_MUTATIONS=mutations.txt \
"$@" sh "$SUT" . ) > "$TMP/out" 2>&1
rc=$?
set -e
return $rc
}
# expect_fail <name> <fixture> <substring the output must contain> [env...]
#
# The substring is not decoration. A test that only asserts "exited non-zero"
# passes when the script fails for an unrelated reason — a typo'd fixture, a
# floor left set by an earlier case — and that is how a guard test rots into a
# test of nothing, which is the same disease this whole script exists to treat.
expect_fail() {
name="$1"; d="$2"; want="$3"; shift 3
if run "$d" "$@"; then
printf 'FAIL %s: expected non-zero exit, got 0\n' "$name"
sed 's/^/ /' "$TMP/out"
fails=$((fails + 1))
elif ! grep -qF "$want" "$TMP/out"; then
printf 'FAIL %s: rejected, but not for the expected reason\n' "$name"
printf ' wanted output containing: %s\n' "$want"
sed 's/^/ /' "$TMP/out"
fails=$((fails + 1))
else
printf 'ok %s\n' "$name"
fi
}
expect_pass() { # <name> <fixture> [env...]
name="$1"; d="$2"; shift 2
if run "$d" "$@"; then
printf 'ok %s\n' "$name"
else
printf 'FAIL %s: expected exit 0\n' "$name"
sed 's/^/ /' "$TMP/out"
fails=$((fails + 1))
fi
}
# --- happy path, first: if this does not pass, nothing below means anything --
mkfixture "$TMP/happy" 1
expect_pass "happy-path" "$TMP/happy"
# A green run must actually say it ran the mutation. Guards against the whole
# phase being skipped while the script still exits 0.
if ! grep -q 'mutation \[sentinel\] detected' "$TMP/out"; then
echo "FAIL happy-path did not report the mutation as detected"
sed 's/^/ /' "$TMP/out"
fails=$((fails + 1))
else
echo "ok happy-path reports the mutation as detected"
fi
# --- the floors -----------------------------------------------------------
mkdir -p "$TMP/empty/kubernetes"
: > "$TMP/empty/mutations.txt"
expect_fail "empty-discovery (0 manifests)" "$TMP/empty" \
"found only 0 PrometheusRule manifests"
mkfixture "$TMP/nosuites" 1
rm "$TMP/nosuites/kubernetes/apps/monitoring-rules/tests/fixture_test.yaml"
expect_fail "no-suites (0 test files)" "$TMP/nosuites" \
"found only 0 *_test.yaml suites"
mkfixture "$TMP/floor" 1
expect_fail "rule floor not met (1 < 5)" "$TMP/floor" \
"found only 1 PrometheusRule manifests; floor is 5" PROMTOOL_RULE_FLOOR=5
# --- the hole that shipped ------------------------------------------------
mkfixture "$TMP/unresolved" 1
sed 's|\.\./prometheusrule-fix1\.yaml|kubernetes/apps/monitoring-rules/prometheusrule-fix1.yaml|' \
"$TMP/unresolved/kubernetes/apps/monitoring-rules/tests/fixture_test.yaml" > "$TMP/u.tmp"
mv "$TMP/u.tmp" "$TMP/unresolved/kubernetes/apps/monitoring-rules/tests/fixture_test.yaml"
# The expected substring must sit on ONE line: die() messages are wrapped in the
# source, so a phrase spanning the wrap will not match.
expect_fail "unresolved-rule-file (repo-root path, not suite-relative)" "$TMP/unresolved" \
"references rule_files entry"
mkfixture "$TMP/warn" 1
expect_fail "warning-only (promtool WARNs but exits 0)" "$TMP/warn" \
"do not satisfy their own unit suites" STUB_WARN=1
mkfixture "$TMP/inline" 1
printf 'rule_files: [../prometheusrule-fix1.yaml]\ntests: []\n' \
> "$TMP/inline/kubernetes/apps/monitoring-rules/tests/fixture_test.yaml"
expect_fail "inline-rule-files" "$TMP/inline" "writes rule_files inline"
mkfixture "$TMP/norf" 1
printf 'tests: []\n' > "$TMP/norf/kubernetes/apps/monitoring-rules/tests/fixture_test.yaml"
expect_fail "empty-rule-files" "$TMP/norf" "declares no rule_files"
# --- a manifest the strip cannot turn into a rule file --------------------
mkfixture "$TMP/badspec" 2
cat > "$TMP/badspec/kubernetes/apps/monitoring-rules/prometheusrule-fix2.yaml" <<'YAML'
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: fix2
# No top-level spec: at all. promtool check rules on the empty strip would
# report "SUCCESS: 0 rules found" and exit 0.
YAML
expect_fail "unstrippable-spec (no groups: after strip)" "$TMP/badspec" \
"expected [groups:]"
# --- the mutation phase's own guards -------------------------------------
mkfixture "$TMP/noop" 1
printf 'noop|kubernetes/apps/monitoring-rules/prometheusrule-fix1.yaml|s/for: 99m/for: 1m/\n' \
> "$TMP/noop/mutations.txt"
expect_fail "mutation-no-op (sed matches nothing)" "$TMP/noop" "changed nothing in"
mkfixture "$TMP/undetected" 1
expect_fail "mutation-undetected (suite passes while mutated)" "$TMP/undetected" \
"was NOT detected" STUB_ALWAYS_PASS=1
mkfixture "$TMP/nomut" 1
printf '# only a comment, no mutations\n' > "$TMP/nomut/mutations.txt"
expect_fail "mutation floor not met (0 mutations)" "$TMP/nomut" "ran only 0 mutations"
mkfixture "$TMP/mutmissing" 1
rm "$TMP/mutmissing/mutations.txt"
expect_fail "mutations file missing" "$TMP/mutmissing" "mutations.txt is missing"
mkfixture "$TMP/mutbadtarget" 1
printf 'bad|kubernetes/apps/monitoring-rules/does-not-exist.yaml|s/a/b/\n' \
> "$TMP/mutbadtarget/mutations.txt"
expect_fail "mutation targets an undiscovered manifest" "$TMP/mutbadtarget" \
"which is not one of"
# --- the version pin ------------------------------------------------------
mkfixture "$TMP/v2" 1
expect_fail "promtool 2.x" "$TMP/v2" "is not 3.x" STUB_VERSION=2.55.1
mkfixture "$TMP/vjunk" 1
expect_fail "unparseable promtool version" "$TMP/vjunk" \
"cannot parse" STUB_VERSION=not-a-version
if [ "$fails" -eq 0 ]; then
echo "PASS all promtool-rules.sh guard tests"
exit 0
fi
echo "FAIL $fails promtool-rules.sh guard test(s)"
exit 1