Compare commits
75
Commits
@@ -17,30 +17,44 @@
|
||||
# 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"
|
||||
- "scripts/ci/yaml-citations.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 six 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 six 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 +272,72 @@ 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. REN-152 retired that rule; its `for` margin is now
|
||||
# guarded by mutations.txt:backlog-for-12m.
|
||||
#
|
||||
# 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
|
||||
|
||||
# Comments cite other YAML blocks by anchor name, never by line number
|
||||
# (REN-183; the exact syntax is in CONTRIBUTING.md, not repeated here because
|
||||
# an example written in this comment would itself be checked). Line numbers drift on any edit
|
||||
# above them and nothing noticed: three drift fixes were merged by hand
|
||||
# before this step existed. This fails on a new line-number citation and on
|
||||
# a citation whose file or anchor marker does not exist.
|
||||
# Existence only — it never judges whether the anchored block still says
|
||||
# what the citing comment claims, so ordinary edits cannot turn it red.
|
||||
#
|
||||
# It scans every YAML file in the checkout, but this pipeline only runs on
|
||||
# the paths in `when:` above, so an edit confined to ansible/ or esphome/ is
|
||||
# checked on the next PR that does touch kubernetes/ or .woodpecker/.
|
||||
# Self-test: scripts/ci/test-yaml-citations.sh, in ci-scripts-test.yml.
|
||||
- name: yaml-citations
|
||||
depends_on: [] # see note above `steps:`
|
||||
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-09-08
|
||||
commands:
|
||||
- sh scripts/ci/yaml-citations.sh
|
||||
|
||||
@@ -8,15 +8,45 @@
|
||||
# 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. All tests here 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 every step 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
|
||||
|
||||
- name: yaml-citations-guard-tests
|
||||
depends_on: []
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.280
|
||||
commands:
|
||||
- sh scripts/ci/test-yaml-citations.sh
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Contributing
|
||||
|
||||
## Citing other YAML from a comment
|
||||
|
||||
- Mark the block you cite with a comment line `# anchor: <name>` (kebab-case, unique in that file). If the block is inside a `|` block scalar, put the marker just above the key, never inside the scalar.
|
||||
- Cite it as `path.yaml#<name>`, with the path relative to the citing file's directory or to the repo root. Never cite `path.yaml:<line>`, because line numbers go stale on any edit above them.
|
||||
- `scripts/ci/yaml-citations.sh` enforces this in `ci-manifests-test.yml`: it fails on line-number citations and on citations whose file or anchor does not exist.
|
||||
@@ -0,0 +1,48 @@
|
||||
# homelab
|
||||
|
||||
GitOps source for the Renner homelab cluster. Flux applies `master` to
|
||||
production, so every merge is a production change. Changes go in by pull
|
||||
request only; see [CONTRIBUTING.md](CONTRIBUTING.md) for YAML conventions.
|
||||
|
||||
## Change rules that protect Home Assistant
|
||||
|
||||
Owner decision (REN-240, REN-243). Home Assistant runs the house, so it
|
||||
gets two rules on top of normal review.
|
||||
|
||||
### 1. Disruptive work runs only 02:00–05:00 Europe/Berlin
|
||||
|
||||
Planned work that can restart Home Assistant or disrupt the cluster is
|
||||
merged and applied only between **02:00 and 05:00 Europe/Berlin**. That
|
||||
covers:
|
||||
|
||||
- node drain, node reboot, Talos upgrade
|
||||
- CNI (Cilium, Multus) and cluster-wide network policy
|
||||
- storage (Rook/Ceph, CSI, snapshot controller, CNPG operator)
|
||||
- Flux core (`kubernetes/clusters/*/flux-system/`, Flux controllers)
|
||||
- Authentik
|
||||
- OpenBao, including secret rotation that restarts consumers
|
||||
- anything in `kubernetes/apps/home-assistant/`
|
||||
|
||||
A PR in this list states in its description that it waits for the window.
|
||||
Flux applies on merge, so "wait for the window" means **merge** in the
|
||||
window, not just apply. If unsure whether a change is disruptive, treat it
|
||||
as disruptive.
|
||||
|
||||
Not covered: Home Assistant image bumps by Flux image automation. They stay
|
||||
fully automatic by owner decision. If one breaks Home Assistant, roll it
|
||||
back with [RUNBOOK-home-assistant-image-rollback.md](kubernetes/RUNBOOK-home-assistant-image-rollback.md).
|
||||
An incident rollback is not planned work; the owner decides its timing.
|
||||
|
||||
### 2. HA-impact review before merge
|
||||
|
||||
Every PR that touches `kubernetes/apps/home-assistant/`, or makes a
|
||||
cluster-wide disruptive change from the list above, needs an **HA-impact
|
||||
review** before merge: a named reviewer states in the PR what Home
|
||||
Assistant will notice (restart, lost integration, lost history, none) and
|
||||
approves.
|
||||
|
||||
- Reviewer: Hestia, the Home Assistant maintainer.
|
||||
- Until Hestia exists: the owner does the HA-impact review.
|
||||
|
||||
This is in addition to the normal review and merge by Argus. Argus does
|
||||
not merge such a PR without the HA-impact approval.
|
||||
@@ -0,0 +1,71 @@
|
||||
# Runbook — roll back a Home Assistant image bump
|
||||
|
||||
Use when an automatic Home Assistant update (a
|
||||
`chore(image-automation): bump ghcr.io/home-assistant/home-assistant:<new>`
|
||||
commit on `master`) breaks Home Assistant. The owner approves the rollback
|
||||
and its timing. Atlas writes the PR; Argus merges it.
|
||||
|
||||
## Why a plain `git revert` is not enough
|
||||
|
||||
Image automation (`kubernetes/infrastructure/image-automation/`) re-scans
|
||||
the registry every hour and rewrites the tag every 30 minutes to the
|
||||
highest release matching the `home-assistant` ImagePolicy. A plain revert
|
||||
of the bump commit would be bumped forward again within about an hour.
|
||||
|
||||
So the rollback commit does two things at once: it reverts the tag **and**
|
||||
drops the `# {"$imagepolicy": ...}` marker from that image line. Without
|
||||
the marker the Setters strategy has nothing to rewrite, so the old tag
|
||||
stays pinned. It is still one commit, and reverting that commit restores
|
||||
automation.
|
||||
|
||||
## Before you roll back
|
||||
|
||||
1. Find the bump commit:
|
||||
```sh
|
||||
git log --oneline -5 origin/master -- kubernetes/apps/home-assistant/deployment.yaml
|
||||
```
|
||||
2. Check the release notes of the broken version for a **database schema**
|
||||
change. Home Assistant migrates the recorder schema (CNPG cluster
|
||||
`ha-pg`) on start and cannot downgrade it. An older version started on a
|
||||
newer schema runs without recorder history until the schema matches
|
||||
again. If the bump crossed a schema change, tell the owner before going
|
||||
on: the choice is roll back without history, or restore the recorder
|
||||
database from backup as a separate step.
|
||||
|
||||
## Roll back (one commit, one PR)
|
||||
|
||||
```sh
|
||||
git worktree add -b atlas/ha-rollback-<old> "$WT" origin/master && cd "$WT"
|
||||
git revert --no-commit <bump-sha>
|
||||
# In kubernetes/apps/home-assistant/deployment.yaml, delete the trailing
|
||||
# # {"$imagepolicy": "flux-system:home-assistant"}
|
||||
# from the home-assistant image line. Leave every other line alone.
|
||||
git diff --cached --stat # expect: 1 file, 1 line changed
|
||||
git commit -m "fix(home-assistant): pin <old>, pause image automation (bump to <new> broke HA)"
|
||||
git push -u origin HEAD
|
||||
```
|
||||
|
||||
The PR description states: broken version, pinned version, symptom, schema
|
||||
check result, and "revert this commit to resume automatic updates".
|
||||
HA-impact review and merge follow [README.md](../README.md#2-ha-impact-review-before-merge).
|
||||
|
||||
On merge Flux applies the Deployment (strategy `Recreate`): Home Assistant
|
||||
restarts once, about one to two minutes of downtime.
|
||||
|
||||
## Verify
|
||||
|
||||
```sh
|
||||
kubectl -n home-assistant get deploy home-assistant \
|
||||
-o jsonpath='{.spec.template.spec.containers[?(@.name=="home-assistant")].image}'
|
||||
kubectl -n home-assistant rollout status deploy/home-assistant
|
||||
```
|
||||
|
||||
Read-only commands only (see REN-240 protection rule 1). One hour after
|
||||
merge, confirm no new `chore(image-automation)` commit touched
|
||||
`deployment.yaml`.
|
||||
|
||||
## Resume automatic updates
|
||||
|
||||
When a fixed Home Assistant release is out, revert the rollback commit. The
|
||||
marker comes back, and image automation bumps to the newest release on its
|
||||
next pass. That bump is itself a Home Assistant restart.
|
||||
@@ -40,12 +40,13 @@ metadata:
|
||||
# fixed in place because the sentence lives inside the `config.yaml: |` block
|
||||
# scalar below: any edit to it changes ConfigMap DATA, and
|
||||
# `configmap.reloader.stakater.com/reload: gatus-config`
|
||||
# (gatus/deployment.yaml:13) would then roll the gatus Deployment — a
|
||||
# (deployment.yaml#reloader) would then roll the gatus Deployment — a
|
||||
# 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:34-36 ALREADY admits
|
||||
# the whole monitoring namespace on every port, and monitoring is where
|
||||
# * ../../infrastructure/network-policies/paperless-ns.yaml#monitoring-admit
|
||||
# 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.
|
||||
# * The in-cluster paperless probe therefore exists, added with no change
|
||||
@@ -60,6 +61,7 @@ metadata:
|
||||
# The Web Service 11 entry below stays a reachability-only check on purpose.
|
||||
# The app-health half of the pair is the blackbox probe named above.
|
||||
data:
|
||||
# anchor: gatus-endpoints
|
||||
config.yaml: |
|
||||
storage:
|
||||
type: sqlite
|
||||
|
||||
@@ -6,6 +6,7 @@ metadata:
|
||||
labels:
|
||||
app: gatus
|
||||
annotations:
|
||||
# anchor: reloader
|
||||
# Gatus reads its config once at startup and never re-reads the file, so
|
||||
# without this a ConfigMap change reconciles cleanly and then does nothing:
|
||||
# git and the running checks drift apart silently, with no failed resource
|
||||
|
||||
@@ -95,7 +95,7 @@ spec:
|
||||
memory: 32Mi
|
||||
containers:
|
||||
- name: home-assistant
|
||||
image: ghcr.io/home-assistant/home-assistant:2026.9.3 # {"$imagepolicy": "flux-system:home-assistant"}
|
||||
image: ghcr.io/home-assistant/home-assistant:2026.9.4 # {"$imagepolicy": "flux-system:home-assistant"}
|
||||
env:
|
||||
- name: TZ
|
||||
value: Europe/Berlin
|
||||
|
||||
@@ -31,17 +31,70 @@ metadata:
|
||||
namespace: home-assistant
|
||||
spec:
|
||||
instances: 2
|
||||
# switchover, not CNPG's default `restart`: any change that rolls the pods
|
||||
# (walStorage below did) then promotes the already-rolled replica instead of
|
||||
# restarting the primary in place, so the recorder loses seconds of writes
|
||||
# rather than stopDelay plus a pg_wal move plus startup (REN-176).
|
||||
primaryUpdateMethod: switchover
|
||||
smartShutdownTimeout: 10
|
||||
stopDelay: 60
|
||||
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.
|
||||
#
|
||||
# Since REN-176 WAL lives on walStorage below, so the stall headroom above
|
||||
# now belongs to that volume. 32Gi stays because a PVC cannot shrink.
|
||||
size: 32Gi
|
||||
# WAL on its own volume (ha-pg-N-wal), approved by the owner on REN-176.
|
||||
#
|
||||
# PERMANENT. The CNPG webhook rejects removing walStorage once set ("walStorage
|
||||
# cannot be disabled once configured"), so reverting this block fails
|
||||
# admission and leaves the home-assistant Kustomization stuck. Undoing it
|
||||
# means a new Cluster recovered from ha-pg-store. Adding it on 1.30.1 needs
|
||||
# no re-clone: the operator creates the PVC, rolls each pod, and the instance
|
||||
# manager moves pg_wal onto it before Postgres starts.
|
||||
#
|
||||
# This does NOT keep the recorder up when WAL fills: a full WAL disk still
|
||||
# stops Postgres. What it does is keep WAL writable if PGDATA fills (a purge
|
||||
# that stops running), and make WAL-volume usage a direct archiver-backlog
|
||||
# signal.
|
||||
#
|
||||
# Sized so stall survival is no worse than the shared 32Gi gives today.
|
||||
# Today: ~18.6 GiB free on PGDATA / 4.66 GiB/day of WAL = ~4.0 days.
|
||||
# 1.86 GiB normal pg_wal footprint (max_wal_size 2GB)
|
||||
# + 18.64 GiB four days of full archiver outage at 4.66 GiB/day
|
||||
# (the nightly 56-119 segment burst, REN-136, is inside that rate)
|
||||
# = 20.5 GiB -> 21Gi: (21 - 1.86) / 4.66 = ~4.1 days. 18Gi would give ~3.5,
|
||||
# worse than today. RBD is thin, so the ceiling costs nothing until a stall
|
||||
# fills it. The webhook requires min_wal_size < max_wal_size < this size.
|
||||
walStorage:
|
||||
storageClass: ceph-block
|
||||
size: 21Gi
|
||||
plugins:
|
||||
- name: barman-cloud.cloudnative-pg.io
|
||||
isWALArchiver: true
|
||||
|
||||
@@ -24,7 +24,7 @@ spec:
|
||||
# unchanged, so Kubernetes has no reason to replace anything, and
|
||||
# this image is deliberately NOT enrolled in Flux image automation
|
||||
# — see kubernetes/infrastructure/image-automation/README.md, which
|
||||
# lists ghcr.io/konairius/ical-proxy as having "only main, latest,
|
||||
# lists gitea.renner.uno/konairius/ical-proxy as having "only main, latest,
|
||||
# and main-<sha> commit builds; no release tags", so there is no
|
||||
# ImagePolicy watching it. The pods therefore sat on a 21-day-old
|
||||
# image while :latest moved underneath them, and the only way to
|
||||
@@ -35,15 +35,23 @@ spec:
|
||||
# what is RUNNING recoverable from this repo rather than "whatever
|
||||
# :latest meant the last time a pod happened to restart".
|
||||
#
|
||||
# main-99a912b is the merge of konairius/ical-proxy#12, which adds
|
||||
# the summary_match/summary_replace parameters this deployment
|
||||
# needs to strip "| Abfuhrkalender - Landkreis Amberg-Sulzbach"
|
||||
# from every waste collection.
|
||||
# The image comes from the Gitea package registry, built by
|
||||
# Woodpecker (.woodpecker.yml in konairius/ical-proxy) on every
|
||||
# push to main. It is amd64-only and pulls without credentials, so
|
||||
# no imagePullSecret. main-4783abe adds two hardening changes on
|
||||
# top of main-db90a59: an SSRF guard (upstreams and redirect hops
|
||||
# resolving to private/loopback/link-local addresses are refused;
|
||||
# public redirects are still followed, up to 5) and a cap of 20
|
||||
# concurrent /proxy upstream fetches (over the cap: 429 +
|
||||
# Retry-After). main-73b5d97 then raises the server WriteTimeout
|
||||
# from 10s to 40s (the 30s upstream fetch timeout + 10s headroom),
|
||||
# so a slow-but-legitimate upstream is no longer cut off before
|
||||
# /proxy can answer. The summary_match/summary_replace parameters
|
||||
# this deployment needs are unchanged.
|
||||
#
|
||||
# Bumping this is a manual, reviewed edit. The tag format is
|
||||
# main-<short-sha> (docker/metadata-action type=sha with the branch
|
||||
# as prefix).
|
||||
image: ghcr.io/konairius/ical-proxy:main-99a912b
|
||||
# main-<short-sha>, the first 7 characters of the main commit.
|
||||
image: gitea.renner.uno/konairius/ical-proxy:main-73b5d97
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
name: http
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -15,9 +15,20 @@
|
||||
# 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.
|
||||
# * `gotk_reconcile_duration_seconds` per kind/name/namespace — reconcile
|
||||
# latency and, by its absence, an object that has stopped being reconciled
|
||||
# at all. RecordDuration IS called, but by FIVE of the six controllers, not
|
||||
# all six: measured post-merge, 170 series from kustomize (33), source (43),
|
||||
# image-reflector (68), image-automation (1) and helm (25), and ZERO from
|
||||
# notification-controller. That is not a gap waiting to fill in — its three
|
||||
# reconcilers are registered but never walked (controller_runtime_reconcile_
|
||||
# total for controller=alert/provider/receiver is 0 over 56 h of uptime with
|
||||
# 2 Alerts and 1 Provider live), so the family is absent from that pod's
|
||||
# /metrics entirely and five is the permanent value. DO NOT write a rule of
|
||||
# the form `count(... gotk_reconcile_duration_seconds ...) < 6`, or an
|
||||
# absent() per pod: it fires forever on notification-controller. Liveness for
|
||||
# all six pods is `up{job="monitoring/flux-controllers"}` (below); that pod's
|
||||
# reconcile health is `controller_runtime_reconcile_errors_total`. REN-127.
|
||||
# * 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
|
||||
|
||||
@@ -21,6 +21,9 @@ resources:
|
||||
# alerted if that stopped — HetznerBackupEndpointDown only probes the S3
|
||||
# endpoint, which proves reachability, not that anything was written.
|
||||
- prometheusrule-cnpg-backup.yaml
|
||||
# Home Assistant, its recorder DB, Mosquitto and Zigbee2MQTT (REN-244). The
|
||||
# only signal for them before was the generic 15m KubePodNotReady warning.
|
||||
- prometheusrule-home-assistant.yaml
|
||||
# DNS publication. Nothing scraped external-dns, so it deleted six LAN
|
||||
# records on 2026-09-12 and stayed silent for five days.
|
||||
- prometheusrule-external-dns.yaml
|
||||
@@ -30,6 +33,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,9 +58,22 @@ resources:
|
||||
# infrastructure/network-policies/external-secrets-ns.yaml in the same change,
|
||||
# or it scrapes nothing. See the file.
|
||||
- external-secrets-podmonitor.yaml
|
||||
# The alert built on what that PodMonitor collects. Split from it on purpose:
|
||||
# the `for:` window had to be chosen against real series, and Prometheus held
|
||||
# none for this metric until the scrape landed.
|
||||
- prometheusrule-external-secrets.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
|
||||
|
||||
@@ -93,10 +93,11 @@ spec:
|
||||
# are the first targets here that leave the monitoring namespace.
|
||||
#
|
||||
# Both sit entirely behind an Authentik outpost — navidrome.renner.uno
|
||||
# on ak-outpost-navidrome-gate (navidrome/ingress.yaml:21),
|
||||
# lidarr.renner.uno on ak-outpost-lidarr-gate
|
||||
# (navidrome/lidarr.yaml:218) — so an EXTERNAL probe of either name
|
||||
# measures the gate, not the app, and stays green while the app behind
|
||||
# on ak-outpost-navidrome-gate
|
||||
# (../navidrome/ingress.yaml#navidrome-gate), lidarr.renner.uno on
|
||||
# ak-outpost-lidarr-gate (../navidrome/lidarr.yaml#lidarr-gate) — so an
|
||||
# EXTERNAL probe of either name measures the gate, not the app, and
|
||||
# stays green while the app behind
|
||||
# a live gate is dead. That external signal is still wanted, and REN-2
|
||||
# adds it from UptimeRobot; these are the other half of the pair.
|
||||
#
|
||||
@@ -104,12 +105,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
|
||||
@@ -121,7 +125,7 @@ spec:
|
||||
# infrastructure/network-policies/monitoring-ns.yaml
|
||||
# (default-deny-ingress) and monitoring-alloy-syslog.yaml
|
||||
# (allow-syslog-world-ingress). blackbox-exporter runs in that same
|
||||
# namespace (apps/monitoring/helmrelease-blackbox-exporter.yaml:5).
|
||||
# namespace (../monitoring/helmrelease-blackbox-exporter.yaml#namespace).
|
||||
# Re-verified at 2b71dda1 across all 635 yaml files in the repo — 73
|
||||
# Cilium objects, 38 carrying an egress or egressDeny block, none of
|
||||
# them in the monitoring namespace, and the single
|
||||
@@ -139,9 +143,10 @@ spec:
|
||||
# gatus checks, so status.renner.uno is unaffected and no workload is
|
||||
# restarted. What breaks is the signal, and it breaks misleadingly.
|
||||
# All three targets start failing BlackboxProbeFailing
|
||||
# (prometheusrule-blackbox.yaml:34 — severity warning, for: 10m); none
|
||||
# of the three is in that rule's exclusion list. BlackboxProbesAbsent
|
||||
# does NOT fire, because the other 17 targets keep producing
|
||||
# (prometheusrule-blackbox.yaml#blackbox-probe-failing — severity
|
||||
# warning, for: 10m); none of the three is in that rule's exclusion
|
||||
# list. BlackboxProbesAbsent does NOT fire, because the other 17
|
||||
# targets keep producing
|
||||
# probe_success — so it reads as three apps going down, not as a
|
||||
# policy change. WHO NOTICES: warning routes to the default `email`
|
||||
# receiver, admin@renner.uno (helmrelease-prometheus.yaml alertmanager
|
||||
@@ -153,16 +158,17 @@ spec:
|
||||
# `flux reconcile kustomization monitoring-rules --with-source`.
|
||||
#
|
||||
# /ping on both: unauthenticated, and already the pods' own kubelet
|
||||
# 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
|
||||
# probe path (../navidrome/deployment.yaml#kubelet-probes,
|
||||
# ../navidrome/lidarr.yaml#kubelet-probes). For lidarr that it is
|
||||
# ungated is proven independently of the gate — its reconcile script
|
||||
# (../navidrome/lidarr.yaml#reconcile-script) curls /ping in a
|
||||
# loop BEFORE it has read the API key out of config.xml.
|
||||
#
|
||||
# STATUS CODE, unproven for all three of the new targets: module
|
||||
# http_2xx follows redirects and passes only on a FINAL 2xx, while
|
||||
# every piece of repo evidence behind these paths establishes only
|
||||
# `< 400` — a kubelet httpGet accepts 200-399, and lidarr.yaml:29's
|
||||
# `curl -f` without -L exits 0 on a 302. That evidence proves the
|
||||
# `< 400` — a kubelet httpGet accepts 200-399, and that reconcile
|
||||
# script's `curl -f` without -L exits 0 on a 302. That evidence proves the
|
||||
# paths are UNGATED, not that they answer 200. Check
|
||||
# probe_http_status_code on the first scrapes; a target that settles
|
||||
# on a 3xx gets REVERTED, not widened to accept 3xx.
|
||||
@@ -204,10 +210,12 @@ spec:
|
||||
# BLAST RADIUS paragraph above.
|
||||
- http://navidrome.navidrome.svc.cluster.local:80/ping
|
||||
- http://lidarr.navidrome.svc.cluster.local:80/ping
|
||||
# anchor: paperless-app-health
|
||||
# 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 —
|
||||
# through the paperless-gate outpost EVERY path, /api/ included,
|
||||
# gatus already documents in its Web Service 11 comment
|
||||
# (../gatus/configmap.yaml#gatus-endpoints) — 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
|
||||
# external signal is still wanted, and REN-2 adds it from UptimeRobot;
|
||||
@@ -215,10 +223,12 @@ spec:
|
||||
#
|
||||
# That gatus comment says the in-cluster probe "needs a
|
||||
# CiliumNetworkPolicy first". That sentence is simply WRONG, not
|
||||
# merely inapplicable: paperless-ns.yaml:34-36 already admits the
|
||||
# whole monitoring namespace, which is where blackbox-exporter runs,
|
||||
# merely inapplicable:
|
||||
# ../../infrastructure/network-policies/paperless-ns.yaml#monitoring-admit
|
||||
# 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:22-26
|
||||
# endpoint (the mechanism ../paperless/network-policy.yaml#cnp-union
|
||||
# relies on). The EGRESS paragraph in the block above applies here
|
||||
# identically. Nothing in the paperless namespace is edited by this
|
||||
# change.
|
||||
@@ -226,19 +236,22 @@ spec:
|
||||
# The stale sentence is deliberately left in place in
|
||||
# apps/gatus/configmap.yaml: it sits INSIDE the `config.yaml: |`
|
||||
# block scalar, so editing it changes ConfigMap data and
|
||||
# `configmap.reloader.stakater.com/reload` (gatus/deployment.yaml:13)
|
||||
# rolls the gatus Deployment. The correction is written at the top of
|
||||
# `configmap.reloader.stakater.com/reload`
|
||||
# (../gatus/deployment.yaml#reloader) rolls the gatus Deployment. The
|
||||
# correction is written at the top of
|
||||
# that file instead, outside `data:`, where it costs no restart.
|
||||
#
|
||||
# /accounts/login/ because paperless-ngx has no unauthenticated health
|
||||
# endpoint at all. It is the app's own established "the webserver
|
||||
# answers" signal — the same path its startup, liveness and readiness
|
||||
# probes use (paperless.yaml:252-272) — and it renders a Django view
|
||||
# probes use (../paperless/paperless.yaml#http-probes) — and it
|
||||
# renders a Django view
|
||||
# rather than a static file. It does NOT cover the consumer/celery
|
||||
# workers: see process-liveness-vs-protocol-liveness.
|
||||
#
|
||||
# The cluster-local Host header is accepted only because
|
||||
# PAPERLESS_ALLOWED_HOSTS is "*" (paperless.yaml:27). Narrow that and
|
||||
# PAPERLESS_ALLOWED_HOSTS is "*"
|
||||
# (../paperless/paperless.yaml#allowed-hosts). Narrow that and
|
||||
# Django answers 400 here and this probe goes permanently red.
|
||||
#
|
||||
# This is the target most likely to fail the STATUS CODE check in the
|
||||
@@ -332,10 +345,12 @@ spec:
|
||||
# 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
|
||||
# (prometheusrule-blackbox.yaml#blackbox-probe-failing and
|
||||
# prometheusrule-blackbox.yaml#blackbox-probes-absent) are both scoped to
|
||||
# job="blackbox-http", so a distinct job is outside both and stays silent — and
|
||||
# the hand-maintained instance!~ exclusion regex in the first of them, 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,
|
||||
@@ -374,12 +389,14 @@ spec:
|
||||
# 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).
|
||||
# (../paperless/cluster.yaml#pprl-pg) and is the name paperless itself
|
||||
# dials (../paperless/paperless.yaml#db-host).
|
||||
- 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.
|
||||
# ../paperless/redis.yaml#service-port,
|
||||
# ../paperless/tika.yaml#service-port,
|
||||
# ../paperless/gotenberg.yaml#service-port.
|
||||
- redis.paperless.svc.cluster.local:6379
|
||||
- tika.paperless.svc.cluster.local:9998
|
||||
- gotenberg.paperless.svc.cluster.local:3000
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
# 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#monitoring-admit) —
|
||||
# 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
|
||||
# (../paperless/paperless.yaml#bridge-tls-proxy), 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
|
||||
@@ -31,6 +31,7 @@ spec:
|
||||
# target means. A target whose loss deserves a critical page gets its
|
||||
# own rule — and lands in the list above. That is what keeps the
|
||||
# exclusion list from being arbitrary.
|
||||
# anchor: blackbox-probe-failing
|
||||
- alert: BlackboxProbeFailing
|
||||
expr: probe_success{job="blackbox-http", instance!~"https://fsn1\\.your-objectstorage\\.com|https://netbird\\.renner\\.uno|http://proxy-[12]\\.renner\\.uno:9000/health|https://ci\\.renner\\.uno|https://openbao\\.renner\\.uno/v1/sys/health.*"} == 0
|
||||
for: 10m
|
||||
@@ -46,6 +47,7 @@ spec:
|
||||
# The check that the checks are running. Every rule above reads
|
||||
# probe_success; if the series stop being produced at all, none of them
|
||||
# can fire and the silence looks exactly like health.
|
||||
# anchor: blackbox-probes-absent
|
||||
- alert: BlackboxProbesAbsent
|
||||
expr: absent(probe_success{job="blackbox-http"})
|
||||
for: 15m
|
||||
|
||||
@@ -108,6 +108,68 @@ spec:
|
||||
summary: "Ceph replicated pool {{ $labels.pool_id }} is running unreplicated"
|
||||
description: "Replicated pool {{ $labels.pool_id }} has a replication factor below 1.5, meaning it is down to a single copy and one OSD loss destroys data. No replicated pool is meant to be unreplicated — it also blocks OSD maintenance for as long as it lasts, since `ceph osd ok-to-stop` refuses while its PGs would go offline. Erasure-coded pools are out of scope for this rule and are covered by POOL_NO_REDUNDANCY via CephHealthWarn (REN-88). Check with `kubectl -n rook-ceph exec deploy/rook-ceph-tools -- ceph osd pool ls detail`."
|
||||
|
||||
- alert: CephPoolLostRedundancyBlind
|
||||
# Canary for the rule directly above. Keep the two adjacent.
|
||||
#
|
||||
# CephPoolLostRedundancy is `critical` and it fails CLOSED and
|
||||
# SILENTLY. Its expression joins three metrics on one label and
|
||||
# filters on one specific label VALUE, and if any of those stops
|
||||
# matching the expression returns nothing, which is indistinguishable
|
||||
# from "every pool is fine". Health stays ok, no email is sent, and
|
||||
# nothing anywhere reports that a critical storage alert has gone
|
||||
# dark. Measured at REN-88 review time: `type` carried exactly two
|
||||
# values, `replicated` and `erasure`, across 15 series — so the
|
||||
# dependency is real, and a Rook or Ceph upgrade that renames the
|
||||
# label, renames the value, or drops it in a relabel rule is enough.
|
||||
#
|
||||
# This expression is CephPoolLostRedundancy's with both thresholds
|
||||
# removed, wrapped in absent(): "can that rule produce any series at
|
||||
# all". It goes to 1 if either metric disappears, if `pool_id` is
|
||||
# dropped from either side of the join, if the `type` label is
|
||||
# renamed, or if `replicated` stops being its value.
|
||||
#
|
||||
# The one deliberate difference is that `ceph_pool_stored > 1e6` is
|
||||
# NOT mirrored here. That floor is about data volume, not structure —
|
||||
# replicated pools that are all legitimately tiny is a quiet state,
|
||||
# not a broken dependency, and copying it in would make this fire for
|
||||
# a benign reason.
|
||||
#
|
||||
# ONE CANARY PER FRAGILE JOIN, NOT ONE PER RULE GROUP. Judged on
|
||||
# drift: a group-level canary has to enumerate every metric the group
|
||||
# reads, and adding a rule to the group does not update it. Six months
|
||||
# on it silently under-covers while LOOKING comprehensive, which is the
|
||||
# same failure shape as a discovery step that passes having checked
|
||||
# nothing. This one's scope is exactly one rule sitting six lines
|
||||
# above it, so "is it still true" is answerable by reading the two
|
||||
# blocks together. It can still drift — someone edits the expr above
|
||||
# and not this — but that drift shows up in the same diff hunk, and
|
||||
# when it does bite it FIRES rather than going quiet. For a guard,
|
||||
# loud and wrong beats silent and wrong.
|
||||
#
|
||||
# 1h, not the 30m above. The exporter moved through 17 distinct mgr
|
||||
# instances in 30d, and every one of those failovers makes all ceph_*
|
||||
# metrics absent for as long as it takes the new mgr to be scraped;
|
||||
# anything under ~15m would page on routine failover, and noise is
|
||||
# what REN-88 was about. A long `for` costs nothing in detection value
|
||||
# because the failure this catches is a schema change — permanent, not
|
||||
# transient — and a blindness that self-heals inside an hour is not
|
||||
# worth an email. Transient exporter loss is already covered by the
|
||||
# chart's own TargetDown.
|
||||
#
|
||||
# `warning`, not `critical`: an alert stopped being able to fire.
|
||||
# Storage is not broken.
|
||||
expr: |
|
||||
absent(
|
||||
ceph_pool_bytes_used / ceph_pool_stored
|
||||
and on(pool_id) ceph_pool_metadata{type="replicated"} == 1
|
||||
)
|
||||
for: 1h
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "CephPoolLostRedundancy cannot fire — its metrics no longer join"
|
||||
description: "The CephPoolLostRedundancy alert has been unable to produce any series for an hour, so a `critical` storage alert is silently dark. It joins ceph_pool_bytes_used / ceph_pool_stored against ceph_pool_metadata{type=\"replicated\"} on `pool_id`; one of those metrics, the `pool_id` label, the `type` label, or the `replicated` value has stopped matching — most likely a Rook or Ceph upgrade, or a relabel rule. Check what the exporter emits now with `kubectl -n rook-ceph exec deploy/rook-ceph-tools -- ceph osd pool ls detail` and by querying `ceph_pool_metadata` directly, then fix the join in kubernetes/apps/monitoring-rules/prometheusrule-ceph.yaml — do not delete this canary (REN-93)."
|
||||
|
||||
- alert: CephHealthError
|
||||
# HEALTH_ERR means data availability or redundancy is at risk.
|
||||
expr: ceph_health_status == 2
|
||||
@@ -137,3 +199,116 @@ spec:
|
||||
annotations:
|
||||
summary: "{{ $labels.instance }} /var below 30% free — Ceph mon will warn"
|
||||
description: "/var on {{ $labels.instance }} is under 30% free, which is Ceph's mon_data_avail_warn threshold, so the mon on this node will report MON_DISK_LOW. Find the actual consumer with `talosctl -n <node> usage /var/lib --depth 1` and then `/var/lib/containerd --depth 1` — note that overlayfs snapshots belong to running containers and are not reclaimable by image GC."
|
||||
|
||||
- alert: CephOSDNearfullApproaching
|
||||
# Warns BEFORE Ceph's own nearfull check, which is the incident and
|
||||
# not a warning. mon_osd_nearfull_ratio is 0.85 and is not
|
||||
# overridden here.
|
||||
#
|
||||
# There is no rebalance to hide behind: failureDomain is host with
|
||||
# size 3 on exactly three OSDs, so every OSD holds a copy of every
|
||||
# replicated object and all three cross 0.85 within seconds of each
|
||||
# other. Measured 2026-09-26: 81.86 / 81.87 / 81.83%.
|
||||
#
|
||||
# 0.75 is 0.85 minus the largest bulk-tier burst on record. Measured
|
||||
# at 30s resolution over the 14d to 2026-09-26: the fullest OSD went
|
||||
# 71.63% -> 81.87% between 05:18 and 05:43Z (+10.24 pp, ~275 GiB raw)
|
||||
# and reclaimed within the hour. At a SUSTAINED 75%, one more burst of
|
||||
# that size reaches nearfull, which is the point at which the level
|
||||
# itself is the finding.
|
||||
#
|
||||
# `for: 30m` is the shortest wait that outlives a self-reclaiming
|
||||
# burst. That same burst held >= 75% for 19.0 min, so 30m clears it by
|
||||
# 11 min; a permanent step holds it indefinitely. Do NOT shorten this
|
||||
# to 15m — the 09-26 burst then pages, with the cluster reclaiming on
|
||||
# its own while someone reads the mail.
|
||||
#
|
||||
# Nor should it be lengthened to 1h. The fastest observed SUSTAINED
|
||||
# climb was +11.44 pp in one hour (2026-09-25 08:02-09:02Z, a real
|
||||
# ingest that never reclaimed). From 75% that reaches 0.85 in ~53 min,
|
||||
# so a 1h pending window would fire after nearfull, not before it.
|
||||
# 30m fires at ~80.7% in that worst case — about 22 min of lead — and
|
||||
# at 2.24 pp/h (the worst 6h-sustained rate) it fires at ~76.1%, about
|
||||
# 4h of lead. The 30m/1h choice is the whole trade: a burst reclaims
|
||||
# in ~20 min and a step does not, so ~30m is the earliest honest
|
||||
# verdict. REN-162 is measuring the burst envelope; if a burst is ever
|
||||
# seen holding >= 75% for more than ~25 min, this `for` moves out, not
|
||||
# the threshold.
|
||||
#
|
||||
# Replay over the 14d to 2026-09-26 08:00Z: the worst 30m-sustained
|
||||
# floor on any OSD was 71.632% (09-26 04:31Z), so this rule fires ZERO
|
||||
# times on the observed normal shape, including the 09-26 excursion to
|
||||
# 81.87% and the 09-25 step from 59% to 71%. Margin 3.37 pp.
|
||||
#
|
||||
# `max by (ceph_daemon)` is not cosmetic. rook-ceph-exporter pods are
|
||||
# replaced often — 32 distinct series for three OSDs in 14 days — and
|
||||
# the raw expression carries `pod` and `instance`, so every restart
|
||||
# would change the alert identity and reset a pending window.
|
||||
expr: |
|
||||
max by (ceph_daemon) (
|
||||
ceph_osd_stat_bytes_used / ceph_osd_stat_bytes
|
||||
) >= 0.75
|
||||
for: 30m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "Ceph {{ $labels.ceph_daemon }} sustained above 75% — one bulk burst from nearfull"
|
||||
description: "{{ $labels.ceph_daemon }} has been at or above 75% utilisation for 30 minutes ({{ $value | humanizePercentage }} now). That is 0.85 nearfull minus the largest bulk-tier burst measured here (+10.24 pp in 25 minutes), so a single repeat of that burst trips mon_osd_nearfull_ratio — and with failureDomain host, size 3 and three OSDs, it trips on all three at once with no rebalance available. This is sustained, not a spike: a burst clears in about 20 minutes and never holds this for 30. Find what grew with `kubectl -n rook-ceph exec deploy/rook-ceph-tools -- ceph df` and `ceph osd df tree`; the bulk tier (cephfs-bulk, pool 15) is the usual mover."
|
||||
|
||||
- alert: CephReplicapoolHeadroomLow
|
||||
# The `replicapool` counterpart to the OSD rule above, in the units a
|
||||
# reader can act on: GiB that still fit.
|
||||
#
|
||||
# These two are NOT independent signals and must not be read as
|
||||
# corroboration. `ceph_pool_max_avail` for a size-3 pool on three
|
||||
# equal OSDs is exactly (full_ratio - fullest_OSD_utilisation) x
|
||||
# osd_size; verified against live data, the identity holds at 0.95 to
|
||||
# five decimals. So this rule is the OSD series rescaled — a second
|
||||
# TIER, deliberately set to trip after the OSD rule, not a second
|
||||
# opinion. Ceph's own nearfull line of 0.85 sits at 89.4 GiB here.
|
||||
#
|
||||
# 160 GiB is what must still fit, measured:
|
||||
# 89 GiB max_avail at the 0.85 nearfull line, i.e. the part of
|
||||
# max_avail that is not ours to spend
|
||||
# 38 GiB ha-pg's unconsumed allocation after the approved 20Gi ->
|
||||
# 32Gi expansion: 2 instances x (31.33 GiB provisioned -
|
||||
# 12.4 GiB used) as of 2026-09-26
|
||||
# 35 GiB replicapool churn allowance — the worst observed single
|
||||
# day (+14.3 GiB, 09-23) plus eleven days at the 14d mean
|
||||
# of 1.9 GiB/day
|
||||
# = 162 GiB, rounded DOWN to 160 so the rounding costs quiet, not
|
||||
# headroom.
|
||||
#
|
||||
# Selecting on the pool NAME through ceph_pool_metadata, not on a
|
||||
# hardcoded pool_id: ids are assignable and replicapool is only id 1
|
||||
# by history. `on(pool_id)` is the whole matcher for the same reason
|
||||
# given on CephPoolLostRedundancy above — ceph_pool_metadata carries
|
||||
# labels ceph_pool_max_avail does not, so default matching never
|
||||
# joins.
|
||||
#
|
||||
# `max by (pool_id)` twice over: it collapses the churning mgr `pod`
|
||||
# and `instance` labels (8 series in 14 days), and during an mgr
|
||||
# failover it takes the live value rather than a stale low one.
|
||||
#
|
||||
# `for: 30m` also covers a real exporter artefact: ceph_pool_max_avail
|
||||
# reads exactly 0 for a single 30s sample after an mgr restart, twice
|
||||
# in the 14d window (e.g. 09-23 12:27Z), on every mgr series at once.
|
||||
# A `for` shorter than a few minutes would page on that alone. Note
|
||||
# the glitch is one-directional, so it cannot cancel a genuine breach
|
||||
# — 0 is still below the floor.
|
||||
#
|
||||
# Replay over the 14d to 2026-09-26 08:00Z: the lowest 30m-sustained
|
||||
# ceiling was 208.97 GiB (09-26 04:31Z), so ZERO fires on the observed
|
||||
# normal shape. The 09-26 burst took max_avail to 117.4 GiB for about
|
||||
# 10 minutes and did not come close to holding it.
|
||||
expr: |
|
||||
max by (pool_id) (
|
||||
ceph_pool_max_avail
|
||||
and on(pool_id) ceph_pool_metadata{name="replicapool"}
|
||||
) < 160 * 1024^3
|
||||
for: 30m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "replicapool usable headroom below 160 GiB"
|
||||
description: "replicapool has had under 160 GiB usable for 30 minutes ({{ $value | humanize1024 }}B now). 160 GiB is the 89 GiB that max_avail still holds above Ceph's 0.85 nearfull line, plus the 38 GiB ha-pg has not yet consumed of its 32Gi expansion, plus 35 GiB of observed replicapool churn — below this, what is already committed no longer fits before nearfull. This is the same series as CephOSDNearfullApproaching rescaled (max_avail = (0.95 - fullest OSD) x OSD size), so it is a second tier and not independent confirmation. Check `kubectl -n rook-ceph exec deploy/rook-ceph-tools -- ceph df`; growth here is usually the bulk tier on the shared OSDs, not replicapool itself."
|
||||
|
||||
@@ -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,79 @@ 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
|
||||
# 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
|
||||
# Threshold: >5 segments sustained for 20m. A segment is 16 MiB, so 5
|
||||
# is roughly 80 MiB of un-archived WAL — well below anything that
|
||||
# threatens the volume, but far above idle noise.
|
||||
#
|
||||
# WHAT THIS RULE KEYS ON: persistence, not depth. The failure that
|
||||
# costs something is an archiver that has *stopped*, and a stopped
|
||||
# archiver can sit at 60 segments indefinitely without ever reaching
|
||||
# some larger number. A depth threshold tuned above the nightly
|
||||
# envelope below would miss exactly that case, and would need raising
|
||||
# again every time the recorder grows. So there is one rule here, and
|
||||
# the tuning knob is `for` (REN-152).
|
||||
#
|
||||
# THE NIGHTLY BURST, which this rule must stay silent through.
|
||||
# ha-pg-1 queues WAL every night on the same clock:
|
||||
#
|
||||
# flat 0 until 02:12Z; fill from 02:13Z; peak 02:14-02:15Z;
|
||||
# drained by 02:26Z at the worst. 02:12Z is 04:12 local, which is
|
||||
# when Home Assistant's recorder purge runs.
|
||||
#
|
||||
# It is a write burst, not IO contention. Drain is a flat
|
||||
# 13-15 segments/min every night — including nights when Kopia's
|
||||
# repository-maintenance batch was saturating the RBD devices, and
|
||||
# nights when it did not start until after the burst had already
|
||||
# drained. That batch drifts across 02:03-02:24Z while the peak stays
|
||||
# pinned to 02:15Z, and a drifting cause cannot produce an effect
|
||||
# fixed to the clock. What varies is the fill (19-40 segments/min),
|
||||
# so the height of the backlog is set by how much WAL is produced
|
||||
# against an archiver with a flat ~14 segments/min ceiling.
|
||||
#
|
||||
# WHY 20m. Measured against Prometheus' full 27-night retention
|
||||
# (2026-08-30..2026-09-26), at the real 30s scrape resolution, across
|
||||
# all 12 CNPG instances. Only ha-pg-1 ever exceeds 5 at all; every
|
||||
# other instance peaks at 2. Longest contiguous excursion above 5 on
|
||||
# any night: 13.0 minutes, on 2026-09-11, at a peak of 141 segments.
|
||||
# Firing evaluations over those 27 nights: 0 at 20m, and also 0 at
|
||||
# 15m — but 15m leaves only 2 minutes of margin against the worst
|
||||
# night, and the nightly peak climbed from 116 to 141 inside the
|
||||
# measured month as the recorder grew. 20m leaves 7 minutes and does
|
||||
# not have to be re-derived every time the database gets bigger.
|
||||
#
|
||||
# The 5 minutes 20m costs in detection latency buy nothing back: a
|
||||
# real stall lasts hours, and at 32Gi (REN-141) the volume holds
|
||||
# roughly four nights of stalled WAL before free space is in danger.
|
||||
#
|
||||
# `for` was 30m before REN-129. Outside the nightly windows the
|
||||
# metric is a hard 0 on all 12 instances, so there was never a noise
|
||||
# floor that needed 30m to wait out — the extra 10m only delayed the
|
||||
# page.
|
||||
#
|
||||
# WHEN IT FIRES, the fix is drain rate or headroom, never a higher
|
||||
# threshold here. ObjectStore `wal.maxParallel`
|
||||
# (kubernetes/apps/home-assistant/ha-pg-objectstore.yaml) is unset,
|
||||
# which the barman-cloud plugin reads as one WAL segment at a time;
|
||||
# that is where the ~14 segments/min ceiling comes from. Headroom is
|
||||
# `spec.storage.size` in ha-pg-cluster.yaml. Both are REN-135.
|
||||
#
|
||||
# `max by (namespace, pod)` is not cosmetic. The raw series carries
|
||||
# `role`, which flips on failover; a changed label is a new series,
|
||||
# and a new series restarts the `for` timer. Aggregating `role` away
|
||||
# means a backlog that persists across a failover still pages 20m
|
||||
# after the backlog started rather than 20m after the failover.
|
||||
expr: max by (namespace, pod) (cnpg_collector_pg_wal_archive_status{value="ready"}) > 5
|
||||
for: 20m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "CNPG WAL archiving is falling behind on {{ $labels.namespace }}/{{ $labels.pod }}"
|
||||
description: "{{ $value }} WAL segments have been waiting to archive for over 30 minutes. Point-in-time recovery is broken for anything after the last archived segment, and pg_wal will grow until the PVC fills. Check the barman-cloud sidecar logs and the ObjectStore credentials: `kubectl -n {{ $labels.namespace }} logs {{ $labels.pod }} -c plugin-barman-cloud`."
|
||||
description: "{{ $value }} WAL segments have been waiting to archive for over 20 minutes. NOTE ON TIMING: a nightly write burst on ha-pg-1 at 02:14Z (04:14 local) is normal and drains itself within about 13 minutes, and this rule is tuned to stay silent through it — so if this fired, the backlog did NOT clear and the archiver is stuck. Point-in-time recovery is broken for anything after the last archived segment, and pg_wal will grow until the PVC fills. Check the barman-cloud sidecar logs and the ObjectStore credentials: `kubectl -n {{ $labels.namespace }} logs {{ $labels.pod }} -c plugin-barman-cloud`."
|
||||
|
||||
- alert: CNPGBackupFailed
|
||||
# cnpg_collector_last_failed_backup_timestamp is a unix timestamp, 0
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
# Alert when an ExternalSecret stops syncing.
|
||||
#
|
||||
# This is the second half of REN-103. The first half (#994) added the PodMonitor
|
||||
# and the `monitoring` ingress grant that make the series exist at all; before
|
||||
# that, `externalsecret_status_condition` held 0 series and a key that silently
|
||||
# stopped refreshing was invisible to Prometheus, to Alertmanager and to anyone
|
||||
# looking at a dashboard. The alert is split out because its `for:` window had to
|
||||
# be chosen against real data rather than guessed.
|
||||
#
|
||||
# `exported_namespace`, NOT `namespace`. The metric carries its own `namespace`
|
||||
# label (the ExternalSecret's), and the PodMonitor attaches a target label of the
|
||||
# same name (the controller pod's, always `external-secrets`). Prometheus resolves
|
||||
# that collision by renaming the metric's label to `exported_namespace`, so on
|
||||
# every one of the ~105 live series `namespace="external-secrets"`. Grouping by
|
||||
# `namespace` would therefore still fire correctly and still be useless: every
|
||||
# failing ExternalSecret in the cluster would be reported as living in
|
||||
# `external-secrets`. ESO's own shipped Grafana dashboard does group by
|
||||
# `namespace` — that is right for the chart's ServiceMonitor, which scrapes ESO
|
||||
# through its own Service and injects no conflicting target label, and wrong here.
|
||||
# This is a general trap for the PodMonitor-instead-of-ServiceMonitor trade this
|
||||
# repo keeps making: any metric carrying its own `namespace`, `pod` or `container`
|
||||
# label gets it shadowed.
|
||||
#
|
||||
# `honorLabels: true` on the PodMonitor endpoint would keep the clean label name
|
||||
# instead, and is deliberately not used: it drops the target labels, so nothing
|
||||
# records which controller replica reported, and it would make this the only
|
||||
# monitor in the directory doing so.
|
||||
#
|
||||
# A consequence worth knowing rather than fixing: aggregating by
|
||||
# `exported_namespace` leaves these alerts with no `namespace` label at all, and
|
||||
# Alertmanager's route groups on ['alertname', 'cluster', 'namespace']. So every
|
||||
# failing ExternalSecret arrives in ONE email listing all of them, each named by
|
||||
# its own annotation, instead of one email per namespace. For a fleet this size
|
||||
# that is the better shape.
|
||||
#
|
||||
# The obvious worry about that shape — that a SECOND ExternalSecret failing while
|
||||
# the first alert is already firing gets swallowed by the open group — does not
|
||||
# hold, and the setting that decides it is `group_interval: 5m` on the root route
|
||||
# in apps/monitoring/helmrelease-prometheus.yaml. `group_interval` governs
|
||||
# notification of a group whose alert SET has changed, which is a different thing
|
||||
# from `repeat_interval: 12h`, which governs re-sending an unchanged group. A
|
||||
# second failure therefore produces a fresh email naming both within 5 minutes.
|
||||
# This rule routes to the root `email` receiver: it is `severity: warning` and
|
||||
# carries no `source = "flux"`, so it matches none of the four child routes.
|
||||
#
|
||||
# The inhibit rules are also checked and do not touch these alerts. All three key
|
||||
# on `equal: ['namespace', 'alertname']` (or `['namespace']`), and no critical or
|
||||
# warning alert in the cluster shares this `alertname`. In particular
|
||||
# OpenBaoQuorumLost does NOT inhibit this rule — different alertname — so an
|
||||
# OpenBao outage can surface both. That is the intended order rather than a
|
||||
# problem: OpenBaoQuorumLost is critical at 2m and arrives first, well before
|
||||
# this rule's 15m.
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
kind: PrometheusRule
|
||||
metadata:
|
||||
name: external-secrets
|
||||
namespace: monitoring
|
||||
labels:
|
||||
# kube-prometheus-stack's ruleSelector matches on this label; without it the
|
||||
# rule is created but never loaded into Prometheus.
|
||||
release: kube-prometheus-stack
|
||||
spec:
|
||||
groups:
|
||||
- name: external-secrets.rules
|
||||
rules:
|
||||
# Measured against the live Prometheus before this rule was proposed for
|
||||
# merge, through the apiserver services/proxy read path:
|
||||
#
|
||||
# count(externalsecret_status_condition) = 105
|
||||
# count(count by (exported_namespace,name) (...)) = 53
|
||||
# count(count by (exported_namespace,name) (...{condition="Ready"})) = 52
|
||||
# count(count by (exported_namespace) (...)) = 20
|
||||
# count(...{condition="Ready",status="False"}) = 52
|
||||
# the ExternalSecretSyncFailing expression below = EMPTY
|
||||
#
|
||||
# So 105 = 52 ExternalSecrets x 2 Ready polarities + 1 Deleted tombstone,
|
||||
# across 20 namespaces, and THIS RULE ARRIVES SILENT: it matches nothing
|
||||
# in the cluster as it stands, which is what makes it safe to load. The
|
||||
# 52 on the line below is a count, not an estimate.
|
||||
#
|
||||
# `== 1` IS LOAD-BEARING, not defensive. ESO writes BOTH polarities of the
|
||||
# Ready condition on every reconcile — the true one at 1 and the opposite
|
||||
# at 0 (esmetrics.UpdateExternalSecretCondition) — so
|
||||
# `{condition="Ready",status="False"}` is present with value 0 for every
|
||||
# healthy ExternalSecret. A bare selector would match all 52 of them.
|
||||
#
|
||||
# `max`, not the `sum` the vendor dashboard uses. Both give 1 today,
|
||||
# because ESO runs a single controller replica. During a Deployment
|
||||
# rollout Prometheus can briefly scrape the old and the new pod at once,
|
||||
# and `sum(...) == 1` would then evaluate to 2 and silently STOP matching
|
||||
# — the alert would resolve itself in the middle of a failure. `max` is 0
|
||||
# or 1 whatever the replica count.
|
||||
#
|
||||
# The gauge, not `externalsecret_sync_calls_error`. The counter was the
|
||||
# other candidate, on the theory that a counter cannot flap the way a
|
||||
# condition gauge might. Reading the controller says the opposite on both
|
||||
# halves: the counter increments on strictly MORE paths than the condition
|
||||
# does — a failed Get of the ExternalSecret itself, a failed Get or label
|
||||
# Patch of the target Secret, an unresolvable SecretStore — and none of
|
||||
# those call markAsFailed(), so none of them set Ready=False. A cache blip
|
||||
# or an apiserver hiccup moves the counter and nothing else. It is the
|
||||
# noisier signal, not the quieter one. And the gauge does
|
||||
# not flap, because it is set to 1 only by markAsFailed() and cleared by
|
||||
# the next successful reconcile. There is no unset state in between.
|
||||
#
|
||||
# The live numbers say the same thing, measured after #994 landed:
|
||||
# `externalsecret_sync_calls_error` has exactly one series in the whole
|
||||
# cluster, `ai/wiki-anthropic`, at 8 errors over 47 calls — and that
|
||||
# ExternalSecret is Ready=True and has never been otherwise in the
|
||||
# observation window. So on today's data the counter carries 8 events that
|
||||
# correspond to no sync failure at all, and the gauge carries none.
|
||||
#
|
||||
# That observation window is now long enough to mean something, and it is
|
||||
# the answer to the only open question this rule was held back for — how
|
||||
# much the gauge flaps in ordinary operation. Measured 2026-09-26T08:02Z,
|
||||
# over the 464 consecutive scrapes since #994 started collecting at
|
||||
# 00:18Z:
|
||||
#
|
||||
# min_over_time(up{job="monitoring/external-secrets"}[8h]) = 1
|
||||
# sum(increase(externalsecret_sync_calls_total[8h])) = 959
|
||||
# count(increase(externalsecret_sync_calls_total[8h]) > 0) = 52
|
||||
# count(max_over_time(
|
||||
# externalsecret_status_condition{
|
||||
# condition="Ready",status="False"}[8h]) == 1) = EMPTY
|
||||
# sum(increase(externalsecret_sync_calls_error[8h])) = 0
|
||||
#
|
||||
# Every one of the 52 ExternalSecrets reconciled at least once in that
|
||||
# window — 959 syncs, so the 1h and 10m refreshInterval cycles both turned
|
||||
# over repeatedly — and across all of them the Ready gauge never reached 1
|
||||
# in a single scrape. Zero flap amplitude, against real reconcile traffic
|
||||
# rather than against silence. The counter was flat over the same window
|
||||
# too, which is why 8 stays 8 above. `for: 15m` is therefore not damping
|
||||
# anything measurable; it is derived from the failure modes below, and the
|
||||
# data says no shorter window would have been noisy either.
|
||||
#
|
||||
# The 2026-09-20 `ai/wiki-embed-basic-auth` incident is evidence FOR this
|
||||
# rule, not against it, though it reads the other way at first. That key
|
||||
# sat in ESO retry backoff through ~10 failures and NEEDED A MANUAL
|
||||
# force-sync (the incident is written up in
|
||||
# clusters/homelab/uptime-monitor.yaml, as the in-repo proof for REN-100).
|
||||
# Because it never self-healed, Ready=False was continuous from the first
|
||||
# failure until a human intervened — hours, not minutes. `for: 15m` catches
|
||||
# that with a wide margin. It was a stuck condition, not a flap; the flap
|
||||
# reading would only hold if the retries had eventually succeeded on their
|
||||
# own, and they did not.
|
||||
- alert: ExternalSecretSyncFailing
|
||||
expr: >-
|
||||
max by (exported_namespace, name) (
|
||||
externalsecret_status_condition{condition="Ready",status="False"}
|
||||
) == 1
|
||||
# 15m, and the reason it can be this relaxed is that a failed sync is
|
||||
# not an outage: consumers keep using the last Secret ESO wrote, so what
|
||||
# degrades is freshness, not availability. Nothing here needs catching
|
||||
# inside a minute.
|
||||
#
|
||||
# It needs to be at least this long because the provider-error path
|
||||
# returns the error to controller-runtime, which retries with
|
||||
# exponential backoff rather than waiting out `refreshInterval`. A
|
||||
# momentary OpenBao blip is therefore cleared within seconds and never
|
||||
# reaches 15m, while a rolling restart of the 3-replica OpenBao
|
||||
# StatefulSet is ridden out entirely — and that outage has its own
|
||||
# alert (OpenBaoQuorumLost, critical at 2m), so nothing is hidden by
|
||||
# waiting. 15m of continuous Ready=False means repeated failure.
|
||||
for: 15m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "ExternalSecret {{ $labels.exported_namespace }}/{{ $labels.name }} is not syncing"
|
||||
description: >-
|
||||
ESO has been unable to sync this ExternalSecret for 15 minutes. The
|
||||
Secret it manages still holds its last successfully synced value, so
|
||||
consumers keep working — what breaks is that a rotation at the
|
||||
provider no longer propagates, which surfaces much later as a
|
||||
credential that stopped being accepted. Get the reason with
|
||||
`kubectl -n {{ $labels.exported_namespace }} get externalsecret
|
||||
{{ $labels.name }} -o jsonpath='{.status.conditions}'`; the common
|
||||
causes are a key that was renamed or removed in OpenBao, a
|
||||
ClusterSecretStore that cannot authenticate, and a target Secret
|
||||
owned by something else.
|
||||
|
||||
# Dead-man's-switch, mirroring ExternalDNSMetricsMissing and
|
||||
# OpenBaoMetricsMissing. The alert above can only fire on series that
|
||||
# exist, so deleting the PodMonitor, reverting the `monitoring` ingress
|
||||
# grant in infrastructure/network-policies/external-secrets-ns.yaml, or
|
||||
# bumping the ESO chart onto a metrics port that no longer matches would
|
||||
# all disarm it silently — which is the exact failure mode REN-103 exists
|
||||
# to end. TargetDown does not cover this: it reports a target that exists
|
||||
# and is failing, not the absence of a target.
|
||||
- alert: ExternalSecretMetricsMissing
|
||||
expr: absent(externalsecret_status_condition)
|
||||
for: 15m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "No ExternalSecret metrics are reaching Prometheus"
|
||||
description: >-
|
||||
`externalsecret_status_condition` has no series. Either the ESO
|
||||
controller is gone or its scrape stopped being collected, and
|
||||
ExternalSecretSyncFailing is blind for as long as this is true.
|
||||
Check the PodMonitor `monitoring/external-secrets` still selects the
|
||||
controller pods, and that external-secrets-ns.yaml still admits
|
||||
`monitoring` on TCP/8080.
|
||||
|
||||
# NOT covered here, deliberately, so nobody assumes it is:
|
||||
#
|
||||
# * `condition="Deleted"`. In chart 2.11.0 the only writer of that
|
||||
# condition is the NotFound branch of Reconcile, which fabricates
|
||||
# `Deleted=False` purely to make UpdateExternalSecretCondition drop
|
||||
# the object's Ready series. It is a tombstone for an ExternalSecret
|
||||
# that no longer exists, not a health state, and `Deleted=True` is
|
||||
# never emitted at all — a rule on it would be dead code. The one such
|
||||
# series in the cluster today (`ai/wiki-index-basic-auth`) is leftover
|
||||
# process state for an ExternalSecret that moved namespace; it clears
|
||||
# when the controller next restarts.
|
||||
# * An ExternalSecret that exists in the cluster but was never
|
||||
# reconciled emits no series at all, so nothing above can see it. That
|
||||
# needs object-existence data — kube-state-metrics custom resource
|
||||
# state — rather than ESO's own metrics, and is not in this change.
|
||||
# * A sync that fails and then succeeds on its own inside 15m. This is a
|
||||
# choice, not an oversight: the retry left the Secret holding a freshly
|
||||
# synced value, so nothing degraded and there is nothing for a human to
|
||||
# do. Alerting on it would mean paging for a self-healed event. The case
|
||||
# that would justify revisiting is the same failure recurring for days
|
||||
# without any single occurrence lasting 15m, which would show up as a
|
||||
# climbing `externalsecret_sync_calls_error` on an ExternalSecret that
|
||||
# is Ready=True. Nothing in the cluster shows that shape today, and the
|
||||
# rule for it needs a measured recurrence rate rather than a guess.
|
||||
@@ -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,119 @@
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
kind: PrometheusRule
|
||||
metadata:
|
||||
name: home-assistant
|
||||
namespace: monitoring
|
||||
labels:
|
||||
# kube-prometheus-stack's Prometheus resource selects on this label;
|
||||
# without it the rule is created but never loaded into Prometheus.
|
||||
release: kube-prometheus-stack
|
||||
spec:
|
||||
groups:
|
||||
- name: home-assistant.rules
|
||||
rules:
|
||||
# The four services the house stops working without (REN-244). Before
|
||||
# this group the only signal for any of them was the cluster-wide
|
||||
# KubePodNotReady: 15m, severity warning, and not named per service.
|
||||
#
|
||||
# SHAPE. One alert per service, with `or` joining the ways it can be
|
||||
# down, so one outage is one alert and not one per symptom. All four are
|
||||
# `for: 5m`, severity critical. Alertmanager has one receiver today
|
||||
# (email to the owner, apps/monitoring/helmrelease-prometheus.yaml), so
|
||||
# "critical" does not change where these go today. It does keep them
|
||||
# out of reach of the inhibit rules, which only silence warning/info.
|
||||
#
|
||||
# 5m IS SHORTER THAN A SLOW HA UPGRADE. Home Assistant can take up to
|
||||
# 10 min to come back after an automatic image bump. REN-244 asks for
|
||||
# detection in 5 min on purpose; a HomeAssistantDown that resolves on
|
||||
# its own a few minutes later, right after an upgrade, is that case.
|
||||
#
|
||||
# Tests: tests/home-assistant_test.yaml.
|
||||
|
||||
- alert: HomeAssistantDown
|
||||
# `absent` covers the target vanishing entirely (pod gone, Service
|
||||
# has no endpoints during a Recreate rollout); `== 0` covers a target
|
||||
# that exists but does not answer /api/prometheus.
|
||||
#
|
||||
# `max by (job, namespace)` gives both branches the same label set.
|
||||
# Without it the alert changes identity when a pod that was gone
|
||||
# comes back failing, and the new identity restarts the `for` timer.
|
||||
expr: |
|
||||
max by (job, namespace) (up{job="home-assistant", namespace="home-assistant"}) == 0
|
||||
or absent(up{job="home-assistant", namespace="home-assistant"})
|
||||
for: 5m
|
||||
labels:
|
||||
severity: critical
|
||||
annotations:
|
||||
summary: "Home Assistant has been down for 5 minutes"
|
||||
description: "The home-assistant scrape target in namespace home-assistant is down or missing. A restart after an automatic image upgrade can take up to 10 minutes, so if an upgrade just rolled out, give it until then before acting. Rollback: kubernetes/RUNBOOK-home-assistant-image-rollback.md."
|
||||
|
||||
- alert: HomeAssistantRecorderDBUnhealthy
|
||||
# ha-pg, the recorder's CNPG cluster. Fires when no instance is the
|
||||
# primary, or when fewer than all instances have Postgres up.
|
||||
#
|
||||
# cnpg_collector_up, not `up`: the exporter lives in the instance
|
||||
# manager, which keeps serving /metrics while Postgres itself is down.
|
||||
# `up` would read 1 through exactly the outage this rule is for.
|
||||
#
|
||||
# `role` comes from podTargetLabels on the cnpg-ha-pg PodMonitor
|
||||
# (cnpg-podmonitors.yaml). It flips on failover, so the instance count
|
||||
# is taken across roles. Both branches come out as {} (`absent` of a
|
||||
# comparison carries no labels), so it is one alert whichever of them
|
||||
# is true, and a failover does not start a second one.
|
||||
#
|
||||
# THE 2 IS `spec.instances` in
|
||||
# kubernetes/apps/home-assistant/ha-pg-cluster.yaml. Change both
|
||||
# together, or this pages forever (more instances is fine; fewer is
|
||||
# not).
|
||||
expr: |
|
||||
count(cnpg_collector_up{job="home-assistant/cnpg-ha-pg"} == 1) < 2
|
||||
or absent(cnpg_collector_up{job="home-assistant/cnpg-ha-pg", role="primary"} == 1)
|
||||
for: 5m
|
||||
labels:
|
||||
severity: critical
|
||||
namespace: home-assistant
|
||||
annotations:
|
||||
summary: "Recorder database ha-pg has no primary or is missing an instance"
|
||||
description: "In CNPG cluster home-assistant/ha-pg either no instance reports role=primary with Postgres up, or fewer than 2 instances have Postgres up. Without a primary the Home Assistant recorder cannot write history. Check `kubectl -n home-assistant get cluster ha-pg` and the pods' logs."
|
||||
|
||||
- alert: MosquittoDown
|
||||
# Pod readiness only: there is no broker exporter, and no mosquitto_*
|
||||
# or mqtt_* series in Prometheus.
|
||||
#
|
||||
# The pod regex is the Deployment's name shape
|
||||
# (mosquitto-<replicaset hash>-<suffix>) and not `mosquitto-.*` on
|
||||
# purpose: mosquitto-bootstrap.yaml runs six Jobs named
|
||||
# mosquitto-*-credentials-bootstrap, and a bootstrap pod that is
|
||||
# briefly Ready must not hide a broker that is down.
|
||||
expr: |
|
||||
absent(kube_pod_status_ready{namespace="home-assistant", pod=~"mosquitto-[a-z0-9]{6,10}-[a-z0-9]{5}", condition="true"} == 1)
|
||||
for: 5m
|
||||
labels:
|
||||
severity: critical
|
||||
# `absent` of a comparison carries no labels; set it for routing.
|
||||
namespace: home-assistant
|
||||
annotations:
|
||||
summary: "Mosquitto (MQTT broker) has not been ready for 5 minutes"
|
||||
description: "No mosquitto pod in namespace home-assistant is Ready. Zigbee2MQTT, Shelly and every other MQTT device lose their link to Home Assistant while this lasts, so Zigbee2MQTTDownOrCoordinatorOffline may fire alongside it for the same cause."
|
||||
|
||||
- alert: Zigbee2MQTTDownOrCoordinatorOffline
|
||||
# Two ways Zigbee stops working: the pod is not Ready, or it runs but
|
||||
# has lost the coordinator / MQTT bridge. The second is read from
|
||||
# Home Assistant's own bridge sensor, so it disappears when HA is down;
|
||||
# HomeAssistantDown covers that case and this rule stays silent
|
||||
# instead of paging twice.
|
||||
#
|
||||
# Both branches come out as {} (`absent` of a comparison carries no
|
||||
# labels; the sensor branch is reduced with `max`), for the same
|
||||
# reason as in HomeAssistantDown: pod-not-ready turning into
|
||||
# bridge-offline is one outage and keeps one `for` timer.
|
||||
expr: |
|
||||
absent(kube_pod_status_ready{namespace="home-assistant", pod=~"zigbee2mqtt-[a-z0-9]{6,10}-[a-z0-9]{5}", condition="true"} == 1)
|
||||
or max(homeassistant_binary_sensor_state{entity="binary_sensor.zigbee2mqtt_bridge_connection_state"}) == 0
|
||||
for: 5m
|
||||
labels:
|
||||
severity: critical
|
||||
namespace: home-assistant
|
||||
annotations:
|
||||
summary: "Zigbee2MQTT is down or its coordinator/bridge is offline"
|
||||
description: "Either the zigbee2mqtt pod in namespace home-assistant is not Ready, or Home Assistant's binary_sensor.zigbee2mqtt_bridge_connection_state reads off. If MosquittoDown is firing too, fix the broker first; the bridge sensor goes off whenever MQTT is gone."
|
||||
@@ -0,0 +1,127 @@
|
||||
# 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. [REN-152](/REN/issues/REN-152) then
|
||||
retired the depth rule in favour of one persistence rule (`> 5` for `20m`).
|
||||
|
||||
## 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. |
|
||||
| `external-secrets_test.yaml` | Hand-written suite for `prometheusrule-external-secrets.yaml` (REN-103). |
|
||||
| `home-assistant_test.yaml` | Hand-written suite for `prometheusrule-home-assistant.yaml` (REN-245). |
|
||||
| `ceph_test.yaml` | Hand-written suite for the `CephPoolLostRedundancyBlind` canary in `prometheusrule-ceph.yaml` (REN-93). |
|
||||
| `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". `backlog-for-12m` is
|
||||
silent on all seven reconstructed nights and fires only on the raw 2026-09-11
|
||||
worst-night case, so deleting that case makes `for: 20m` reducible to `12m`
|
||||
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 5 does not fire
|
||||
`for: 5m`; it fires `for: 4m`. The `>5 pend` column in `--report` is the number
|
||||
to compare a `for` against — not the sample count next to it.
|
||||
|
||||
## 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,176 @@
|
||||
# promtool test rules for the CephPoolLostRedundancyBlind canary in
|
||||
# prometheusrule-ceph.yaml (REN-93).
|
||||
#
|
||||
# Run by the `promtool` step of .woodpecker/ci-manifests-test.yml through
|
||||
# scripts/ci/promtool-rules.sh, which strips `spec:` from the manifest into a
|
||||
# mirrored tree — hence `../` below, resolved from this file's directory. See
|
||||
# README.md here. The negative controls for this suite are the `ceph-*` entries
|
||||
# in mutations.txt.
|
||||
#
|
||||
# WHAT IT IS FOR. `promtool check rules` proves the canary parses. It cannot
|
||||
# prove the canary fires when the join breaks, or stays quiet when the join is
|
||||
# fine — every scenario below is well-formed PromQL in both the passing and the
|
||||
# failing case; only the synthetic data changes, and only evaluation sees it.
|
||||
#
|
||||
# `exp_annotations` must match the RENDERED annotations exactly, so the strings
|
||||
# below are the rule's with `{{ $labels.pool_id }}` substituted. Tedious to keep
|
||||
# in sync, and that is the point: an annotation edited into nonsense fails here
|
||||
# instead of in an email.
|
||||
rule_files:
|
||||
- ../prometheusrule-ceph.yaml
|
||||
evaluation_interval: 1m
|
||||
tests:
|
||||
# A. Healthy replicated pool, metadata intact. Neither alert may fire.
|
||||
# used/stored = 3.0, so well clear of the 1.5 threshold, and the join
|
||||
# resolves — so the canary has nothing to report either. This is the
|
||||
# scenario that fails if someone inverts a comparison.
|
||||
- interval: 1m
|
||||
input_series:
|
||||
- series: 'ceph_pool_bytes_used{pool_id="1"}'
|
||||
values: '6000000+0x200'
|
||||
- series: 'ceph_pool_stored{pool_id="1"}'
|
||||
values: '2000000+0x200'
|
||||
- series: 'ceph_pool_metadata{pool_id="1",type="replicated",name="rbd"}'
|
||||
values: '1+0x200'
|
||||
alert_rule_test:
|
||||
- eval_time: 120m
|
||||
alertname: CephPoolLostRedundancyBlind
|
||||
exp_alerts: []
|
||||
- eval_time: 120m
|
||||
alertname: CephPoolLostRedundancy
|
||||
exp_alerts: []
|
||||
|
||||
# B. Real redundancy loss, metadata intact: used/stored = 1.0.
|
||||
# The critical alert must fire AND the canary must stay quiet. The second
|
||||
# half is the load-bearing one — a canary that also fires on a real
|
||||
# redundancy loss would mask the alert it exists to protect, and
|
||||
# `absent()` over an expression that IS producing series is the only thing
|
||||
# stopping that. Also pins the `for: 30m`: 120m is well past it.
|
||||
- interval: 1m
|
||||
input_series:
|
||||
- series: 'ceph_pool_bytes_used{pool_id="1"}'
|
||||
values: '2000000+0x200'
|
||||
- series: 'ceph_pool_stored{pool_id="1"}'
|
||||
values: '2000000+0x200'
|
||||
- series: 'ceph_pool_metadata{pool_id="1",type="replicated",name="rbd"}'
|
||||
values: '1+0x200'
|
||||
alert_rule_test:
|
||||
- eval_time: 120m
|
||||
alertname: CephPoolLostRedundancy
|
||||
exp_alerts:
|
||||
- exp_labels:
|
||||
severity: critical
|
||||
pool_id: "1"
|
||||
exp_annotations:
|
||||
summary: "Ceph replicated pool 1 is running unreplicated"
|
||||
description: "Replicated pool 1 has a replication factor below 1.5, meaning it is down to a single copy and one OSD loss destroys data. No replicated pool is meant to be unreplicated — it also blocks OSD maintenance for as long as it lasts, since `ceph osd ok-to-stop` refuses while its PGs would go offline. Erasure-coded pools are out of scope for this rule and are covered by POOL_NO_REDUNDANCY via CephHealthWarn (REN-88). Check with `kubectl -n rook-ceph exec deploy/rook-ceph-tools -- ceph osd pool ls detail`."
|
||||
- eval_time: 120m
|
||||
alertname: CephPoolLostRedundancyBlind
|
||||
exp_alerts: []
|
||||
|
||||
# C, D and E are the same failure seen three ways: the pool is genuinely at
|
||||
# replication 1.0, and CephPoolLostRedundancy produces NOTHING. Each asserts
|
||||
# `exp_alerts: []` for the critical alert — that empty list is the silent
|
||||
# failure being surfaced, not an absence of interest — and asserts the canary
|
||||
# fires instead. Remove the canary from the rule file and all three fail.
|
||||
#
|
||||
# C. `type` renamed to `pool_type` by a Rook or Ceph upgrade.
|
||||
- interval: 1m
|
||||
input_series:
|
||||
- series: 'ceph_pool_bytes_used{pool_id="1"}'
|
||||
values: '2000000+0x200'
|
||||
- series: 'ceph_pool_stored{pool_id="1"}'
|
||||
values: '2000000+0x200'
|
||||
- series: 'ceph_pool_metadata{pool_id="1",pool_type="replicated",name="rbd"}'
|
||||
values: '1+0x200'
|
||||
alert_rule_test:
|
||||
- eval_time: 120m
|
||||
alertname: CephPoolLostRedundancy
|
||||
exp_alerts: []
|
||||
- eval_time: 120m
|
||||
alertname: CephPoolLostRedundancyBlind
|
||||
exp_alerts:
|
||||
- exp_labels:
|
||||
severity: warning
|
||||
exp_annotations:
|
||||
summary: "CephPoolLostRedundancy cannot fire — its metrics no longer join"
|
||||
description: "The CephPoolLostRedundancy alert has been unable to produce any series for an hour, so a `critical` storage alert is silently dark. It joins ceph_pool_bytes_used / ceph_pool_stored against ceph_pool_metadata{type=\"replicated\"} on `pool_id`; one of those metrics, the `pool_id` label, the `type` label, or the `replicated` value has stopped matching — most likely a Rook or Ceph upgrade, or a relabel rule. Check what the exporter emits now with `kubectl -n rook-ceph exec deploy/rook-ceph-tools -- ceph osd pool ls detail` and by querying `ceph_pool_metadata` directly, then fix the join in kubernetes/apps/monitoring-rules/prometheusrule-ceph.yaml — do not delete this canary (REN-93)."
|
||||
|
||||
# D. Label VALUE changed rather than the label name: `replicated` ->
|
||||
# `replica`. Every label the rule names still exists, which is why a check
|
||||
# on label presence alone would not catch this.
|
||||
- interval: 1m
|
||||
input_series:
|
||||
- series: 'ceph_pool_bytes_used{pool_id="1"}'
|
||||
values: '2000000+0x200'
|
||||
- series: 'ceph_pool_stored{pool_id="1"}'
|
||||
values: '2000000+0x200'
|
||||
- series: 'ceph_pool_metadata{pool_id="1",type="replica",name="rbd"}'
|
||||
values: '1+0x200'
|
||||
alert_rule_test:
|
||||
- eval_time: 120m
|
||||
alertname: CephPoolLostRedundancy
|
||||
exp_alerts: []
|
||||
- eval_time: 120m
|
||||
alertname: CephPoolLostRedundancyBlind
|
||||
exp_alerts:
|
||||
- exp_labels:
|
||||
severity: warning
|
||||
exp_annotations:
|
||||
summary: "CephPoolLostRedundancy cannot fire — its metrics no longer join"
|
||||
description: "The CephPoolLostRedundancy alert has been unable to produce any series for an hour, so a `critical` storage alert is silently dark. It joins ceph_pool_bytes_used / ceph_pool_stored against ceph_pool_metadata{type=\"replicated\"} on `pool_id`; one of those metrics, the `pool_id` label, the `type` label, or the `replicated` value has stopped matching — most likely a Rook or Ceph upgrade, or a relabel rule. Check what the exporter emits now with `kubectl -n rook-ceph exec deploy/rook-ceph-tools -- ceph osd pool ls detail` and by querying `ceph_pool_metadata` directly, then fix the join in kubernetes/apps/monitoring-rules/prometheusrule-ceph.yaml — do not delete this canary (REN-93)."
|
||||
|
||||
# E. `pool_id` renamed to `pool` on the METADATA side only. Both metrics
|
||||
# exist, both label values are unchanged, and `type="replicated"` still
|
||||
# matches — only the `on(pool_id)` join key is gone from one side, so the
|
||||
# join matches nothing. The subtlest of the three, and the one a human
|
||||
# reading the expression is least likely to spot.
|
||||
- interval: 1m
|
||||
input_series:
|
||||
- series: 'ceph_pool_bytes_used{pool_id="1"}'
|
||||
values: '2000000+0x200'
|
||||
- series: 'ceph_pool_stored{pool_id="1"}'
|
||||
values: '2000000+0x200'
|
||||
- series: 'ceph_pool_metadata{pool="1",type="replicated",name="rbd"}'
|
||||
values: '1+0x200'
|
||||
alert_rule_test:
|
||||
- eval_time: 120m
|
||||
alertname: CephPoolLostRedundancy
|
||||
exp_alerts: []
|
||||
- eval_time: 120m
|
||||
alertname: CephPoolLostRedundancyBlind
|
||||
exp_alerts:
|
||||
- exp_labels:
|
||||
severity: warning
|
||||
exp_annotations:
|
||||
summary: "CephPoolLostRedundancy cannot fire — its metrics no longer join"
|
||||
description: "The CephPoolLostRedundancy alert has been unable to produce any series for an hour, so a `critical` storage alert is silently dark. It joins ceph_pool_bytes_used / ceph_pool_stored against ceph_pool_metadata{type=\"replicated\"} on `pool_id`; one of those metrics, the `pool_id` label, the `type` label, or the `replicated` value has stopped matching — most likely a Rook or Ceph upgrade, or a relabel rule. Check what the exporter emits now with `kubectl -n rook-ceph exec deploy/rook-ceph-tools -- ceph osd pool ls detail` and by querying `ceph_pool_metadata` directly, then fix the join in kubernetes/apps/monitoring-rules/prometheusrule-ceph.yaml — do not delete this canary (REN-93)."
|
||||
|
||||
# F. mgr FAILOVER, which is the noise case the canary's `for: 1h` exists for.
|
||||
# All ceph_* metrics stop at t=30m and never return; with the 5m lookback
|
||||
# they read as present until t=35m, so the canary goes pending at ~t=36m.
|
||||
# It must NOT be firing at 75m (a failover-length gap — the exporter moved
|
||||
# through 17 mgr instances in 30d) and MUST be firing by 95m. Shorten the
|
||||
# `for:` and the 75m assertion fails; lengthen it and the 95m one does.
|
||||
# Both eval_times were checked against promtool 3.15.0 rather than derived,
|
||||
# because the exact ActiveAt depends on lookback and evaluation alignment.
|
||||
- interval: 1m
|
||||
input_series:
|
||||
- series: 'ceph_pool_bytes_used{pool_id="1"}'
|
||||
values: '6000000+0x30'
|
||||
- series: 'ceph_pool_stored{pool_id="1"}'
|
||||
values: '2000000+0x30'
|
||||
- series: 'ceph_pool_metadata{pool_id="1",type="replicated",name="rbd"}'
|
||||
values: '1+0x30'
|
||||
alert_rule_test:
|
||||
- eval_time: 75m
|
||||
alertname: CephPoolLostRedundancyBlind
|
||||
exp_alerts: []
|
||||
- eval_time: 95m
|
||||
alertname: CephPoolLostRedundancyBlind
|
||||
exp_alerts:
|
||||
- exp_labels:
|
||||
severity: warning
|
||||
exp_annotations:
|
||||
summary: "CephPoolLostRedundancy cannot fire — its metrics no longer join"
|
||||
description: "The CephPoolLostRedundancy alert has been unable to produce any series for an hour, so a `critical` storage alert is silently dark. It joins ceph_pool_bytes_used / ceph_pool_stored against ceph_pool_metadata{type=\"replicated\"} on `pool_id`; one of those metrics, the `pool_id` label, the `type` label, or the `replicated` value has stopped matching — most likely a Rook or Ceph upgrade, or a relabel rule. Check what the exporter emits now with `kubectl -n rook-ceph exec deploy/rook-ceph-tools -- ceph osd pool ls detail` and by querying `ceph_pool_metadata` directly, then fix the join in kubernetes/apps/monitoring-rules/prometheusrule-ceph.yaml — do not delete this canary (REN-93)."
|
||||
@@ -0,0 +1,431 @@
|
||||
# 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, the
|
||||
# worst night in Prometheus' retention as raw samples (REN-152), an
|
||||
# adversarial combination of the extremes of those numbers, or a failure
|
||||
# this cluster has not had but the rule exists 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. The rule must stay silent through
|
||||
# every one of them. The retired depth rule (`> 64`) shipped for review
|
||||
# at `for: 2m` and fired on 6 of these 7 nights; REN-152 removed it.
|
||||
# ---------------------------------------------------------------------
|
||||
# 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: 20m).
|
||||
- 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:22Z, backlog 14
|
||||
- eval_time: 14m
|
||||
alertname: CNPGWalArchiveBacklog
|
||||
exp_alerts: []
|
||||
# 02:24Z, backlog 0
|
||||
- eval_time: 16m
|
||||
alertname: CNPGWalArchiveBacklog
|
||||
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: 20m).
|
||||
- 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:21Z, backlog 14
|
||||
- eval_time: 13m
|
||||
alertname: CNPGWalArchiveBacklog
|
||||
exp_alerts: []
|
||||
# 02:23Z, backlog 0
|
||||
- eval_time: 15m
|
||||
alertname: CNPGWalArchiveBacklog
|
||||
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: 20m).
|
||||
- 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:21Z, backlog 15
|
||||
- eval_time: 13m
|
||||
alertname: CNPGWalArchiveBacklog
|
||||
exp_alerts: []
|
||||
# 02:23Z, backlog 0
|
||||
- eval_time: 15m
|
||||
alertname: CNPGWalArchiveBacklog
|
||||
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: 20m).
|
||||
- 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:22Z, backlog 15
|
||||
- eval_time: 14m
|
||||
alertname: CNPGWalArchiveBacklog
|
||||
exp_alerts: []
|
||||
# 02:24Z, backlog 0
|
||||
- eval_time: 16m
|
||||
alertname: CNPGWalArchiveBacklog
|
||||
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: 20m).
|
||||
- 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:17Z, backlog 14
|
||||
- eval_time: 9m
|
||||
alertname: CNPGWalArchiveBacklog
|
||||
exp_alerts: []
|
||||
# 02:19Z, backlog 0
|
||||
- eval_time: 11m
|
||||
alertname: CNPGWalArchiveBacklog
|
||||
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: 20m).
|
||||
- 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:21Z, backlog 13
|
||||
- eval_time: 13m
|
||||
alertname: CNPGWalArchiveBacklog
|
||||
exp_alerts: []
|
||||
# 02:23Z, backlog 0
|
||||
- eval_time: 15m
|
||||
alertname: CNPGWalArchiveBacklog
|
||||
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: 20m).
|
||||
- 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:22Z, backlog 14
|
||||
- eval_time: 14m
|
||||
alertname: CNPGWalArchiveBacklog
|
||||
exp_alerts: []
|
||||
# 02:24Z, backlog 0
|
||||
- eval_time: 16m
|
||||
alertname: CNPGWalArchiveBacklog
|
||||
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. 11 samples above 5,
|
||||
# 10m pending against `for: 20m`.
|
||||
# ---------------------------------------------------------------------
|
||||
# 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: 20m).
|
||||
- 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:23Z, backlog 15
|
||||
- eval_time: 15m
|
||||
alertname: CNPGWalArchiveBacklog
|
||||
exp_alerts: []
|
||||
# 02:26Z, backlog 0
|
||||
- eval_time: 18m
|
||||
alertname: CNPGWalArchiveBacklog
|
||||
exp_alerts: []
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# 9. THE WORST NIGHT ON RECORD, 2026-09-11, as raw 30s samples (see
|
||||
# WORST_NIGHT in the generator). Peak 141, 26 samples above 5 = 12.5m
|
||||
# of true expression. At `evaluation_interval: 1m` the rule sees it
|
||||
# true from 02:13Z to 02:25Z: 12m pending against `for: 20m`,
|
||||
# 8m of margin. This is the case that makes the margin
|
||||
# load-bearing: at `for: 12m` it fires at 02:25Z, and nothing else in
|
||||
# the suite notices (mutations.txt:backlog-for-12m).
|
||||
# ---------------------------------------------------------------------
|
||||
- name: worst night on record 2026-09-11 — peak 141, raw samples
|
||||
interval: 30s
|
||||
input_series:
|
||||
- series: 'cnpg_collector_pg_wal_archive_status{namespace="home-assistant",pod="ha-pg-1",container="postgres",role="primary",value="ready"}'
|
||||
# 02:08:00Z .. 02:47:00Z, one sample per 30s
|
||||
values: '0 0 0 0 0 0 0 0 0 28 53 80 110 141 136 133 130 125 117 117 115 112 102 91 81 75 68 60 52 45 37 29 22 15 8 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 1 0 0 0 0 0 0 0 0 1 0 0 0'
|
||||
- series: 'cnpg_collector_pg_wal_archive_status{namespace="home-assistant",pod="ha-pg-1",container="postgres",role="primary",value="done"}'
|
||||
values: '148000 148007 148014 148021 148028 148035 148042 148049 148056 148063 148070 148077 148084 148091 148098 148105 148112 148119 148126 148133 148140 148147 148154 148161 148168 148175 148182 148189 148196 148203 148210 148217 148224 148231 148238 148245 148252 148259 148266 148273 148280 148287 148294 148301 148308 148315 148322 148329 148336 148343 148350 148357 148364 148371 148378 148385 148392 148399 148406 148413 148420 148427 148434 148441 148448 148455 148462 148469 148476 148483 148490 148497 148504 148511 148518 148525 148532 148539 148546'
|
||||
alert_rule_test:
|
||||
# 02:14Z, backlog 110
|
||||
- eval_time: 6m
|
||||
alertname: CNPGWalArchiveBacklog
|
||||
exp_alerts: []
|
||||
# 02:25Z, backlog 8
|
||||
- eval_time: 17m
|
||||
alertname: CNPGWalArchiveBacklog
|
||||
exp_alerts: []
|
||||
# 02:47Z, backlog 0
|
||||
- eval_time: 39m
|
||||
alertname: CNPGWalArchiveBacklog
|
||||
exp_alerts: []
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# 10. THE FAILURE THE RULE EXISTS FOR: an archiver that stopped with
|
||||
# 70 segments (1.1 GiB) queued. Not a burst — it does not drain. The
|
||||
# backlog crosses 5 at t=5m, so `for: 20m` elapses at t=25m.
|
||||
# The assertions bracket the transition: one minute early nothing is
|
||||
# firing, on the minute it is.
|
||||
# ---------------------------------------------------------------------
|
||||
- name: stalled archiver at 70 segments — 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 rule does
|
||||
# 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:
|
||||
- eval_time: 24m
|
||||
alertname: CNPGWalArchiveBacklog
|
||||
exp_alerts: []
|
||||
- eval_time: 25m
|
||||
alertname: CNPGWalArchiveBacklog
|
||||
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 is falling behind on home-assistant/ha-pg-1"
|
||||
description: "70 WAL segments have been waiting to archive for over 20 minutes. NOTE ON TIMING: a nightly write burst on ha-pg-1 at 02:14Z (04:14 local) is normal and drains itself within about 13 minutes, and this rule is tuned to stay silent through it — so if this fired, the backlog did NOT clear and the archiver is stuck. Point-in-time recovery is broken for anything after the last archived segment, and pg_wal will grow until the PVC fills. Check the barman-cloud sidecar logs and the ObjectStore credentials: `kubectl -n home-assistant logs ha-pg-1 -c plugin-barman-cloud`."
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# 11. THE STALL A DEPTH THRESHOLD CANNOT SEE: 8 segments, stuck. It
|
||||
# would never reach any number tuned above the nightly envelope, and
|
||||
# point-in-time recovery is just as broken at 8 segments as at 70. This
|
||||
# is why REN-152 keyed the rule on persistence and retired the depth rule.
|
||||
# ---------------------------------------------------------------------
|
||||
- name: low stall at 8 segments — 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 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8'
|
||||
alert_rule_test:
|
||||
- eval_time: 24m
|
||||
alertname: CNPGWalArchiveBacklog
|
||||
exp_alerts: []
|
||||
- eval_time: 25m
|
||||
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: "8 WAL segments have been waiting to archive for over 20 minutes. NOTE ON TIMING: a nightly write burst on ha-pg-1 at 02:14Z (04:14 local) is normal and drains itself within about 13 minutes, and this rule is tuned to stay silent through it — so if this fired, the backlog did NOT clear and the archiver is stuck. Point-in-time recovery is broken for anything after the last archived segment, and pg_wal will grow until the PVC fills. Check the barman-cloud sidecar logs and the ObjectStore credentials: `kubectl -n home-assistant logs ha-pg-1 -c plugin-barman-cloud`."
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# 12. 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: []
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# 13. 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 20m after the backlog started (t=25m) rather
|
||||
# than 20m after the failover. Without `max by` this case is
|
||||
# silent at t=25m — 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: 24m
|
||||
alertname: CNPGWalArchiveBacklog
|
||||
exp_alerts: []
|
||||
- eval_time: 25m
|
||||
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 20 minutes. NOTE ON TIMING: a nightly write burst on ha-pg-1 at 02:14Z (04:14 local) is normal and drains itself within about 13 minutes, and this rule is tuned to stay silent through it — so if this fired, the backlog did NOT clear and the archiver is stuck. Point-in-time recovery is broken for anything after the last archived segment, and pg_wal will grow until the PVC fills. Check the barman-cloud sidecar logs and the ObjectStore credentials: `kubectl -n home-assistant logs ha-pg-1 -c plugin-barman-cloud`."
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# 14. 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: 25m
|
||||
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 20 minutes. NOTE ON TIMING: a nightly write burst on ha-pg-1 at 02:14Z (04:14 local) is normal and drains itself within about 13 minutes, and this rule is tuned to stay silent through it — so if this fired, the backlog did NOT clear and the archiver is stuck. Point-in-time recovery is broken for anything after the last archived segment, and pg_wal will grow until the PVC fills. Check the barman-cloud sidecar logs and the ObjectStore credentials: `kubectl -n home-assistant logs ha-pg-1 -c plugin-barman-cloud`."
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# 15. 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,115 @@
|
||||
# promtool test rules for prometheusrule-external-secrets.yaml.
|
||||
#
|
||||
# Run by the `promtool` step of .woodpecker/ci-manifests-test.yml through
|
||||
# scripts/ci/promtool-rules.sh, which strips `spec:` from the manifest into a
|
||||
# mirrored tree — hence `../` below, resolved from this file's directory. See
|
||||
# README.md here. The negative controls for this suite are the `eso-*` entries
|
||||
# in mutations.txt.
|
||||
#
|
||||
# WHAT IT IS FOR. Every claim in the rule file that a reviewer would otherwise
|
||||
# have to take on trust is asserted here instead: that the tombstone series does
|
||||
# not alert, that `for: 15m` really is 15m, that the annotation names the
|
||||
# ExternalSecret's own namespace and not the controller's, and that `max`
|
||||
# survives a two-pod scrape where `sum` does not.
|
||||
#
|
||||
# THE REJECTED FORM, for the record. REN-100 and ESO's shipped dashboard both
|
||||
# use `sum(...) by (namespace, name) == 1`. Run against the identical input
|
||||
# series below, that expression produces exactly one alert,
|
||||
# {namespace="external-secrets", name="gitea-db"} — the wrong namespace on the
|
||||
# one it catches, and nothing at all for monitoring/loki-basic-auth, whose two
|
||||
# scraped pods sum to 2 and stop matching `== 1`. Both defects, one expression.
|
||||
rule_files:
|
||||
- ../prometheusrule-external-secrets.yaml
|
||||
|
||||
evaluation_interval: 1m
|
||||
|
||||
tests:
|
||||
# Series shapes copied from the live cluster after #994 landed: the metric's
|
||||
# own `namespace` has already been shadowed to `exported_namespace` by the
|
||||
# PodMonitor's target label, and `namespace` is the controller pod's.
|
||||
- interval: 1m
|
||||
input_series:
|
||||
# A healthy ExternalSecret. ESO writes BOTH polarities of the Ready
|
||||
# condition on every reconcile, the inactive one at 0 — which is why the
|
||||
# rule needs `== 1` and not a bare selector.
|
||||
- series: 'externalsecret_status_condition{condition="Ready",status="True",name="uptime-robot-api-key",exported_namespace="uptime-monitor",namespace="external-secrets",job="monitoring/external-secrets",pod="external-secrets-56c649799b-t65gf"}'
|
||||
values: '1+0x60'
|
||||
- series: 'externalsecret_status_condition{condition="Ready",status="False",name="uptime-robot-api-key",exported_namespace="uptime-monitor",namespace="external-secrets",job="monitoring/external-secrets",pod="external-secrets-56c649799b-t65gf"}'
|
||||
values: '0+0x60'
|
||||
|
||||
# Starts failing at t=10m and stays failed.
|
||||
- series: 'externalsecret_status_condition{condition="Ready",status="False",name="gitea-db",exported_namespace="gitea",namespace="external-secrets",job="monitoring/external-secrets",pod="external-secrets-56c649799b-t65gf"}'
|
||||
values: '0+0x9 1+0x50'
|
||||
- series: 'externalsecret_status_condition{condition="Ready",status="True",name="gitea-db",exported_namespace="gitea",namespace="external-secrets",job="monitoring/external-secrets",pod="external-secrets-56c649799b-t65gf"}'
|
||||
values: '1+0x9 0+0x50'
|
||||
|
||||
# Tombstone left behind for an ExternalSecret that no longer exists. Held
|
||||
# at 1 forever until the controller restarts, so a rule that matched
|
||||
# `condition!="Ready"` or ignored `status` would page on it every day.
|
||||
- series: 'externalsecret_status_condition{condition="Deleted",status="False",name="wiki-index-basic-auth",exported_namespace="ai",namespace="external-secrets",job="monitoring/external-secrets",pod="external-secrets-56c649799b-t65gf"}'
|
||||
values: '1+0x60'
|
||||
|
||||
# The same failing ExternalSecret reported by TWO controller pods, as
|
||||
# happens for the seconds of a Deployment rollout when Prometheus still
|
||||
# has the old target and already has the new one.
|
||||
- series: 'externalsecret_status_condition{condition="Ready",status="False",name="loki-basic-auth",exported_namespace="monitoring",namespace="external-secrets",job="monitoring/external-secrets",pod="external-secrets-56c649799b-t65gf"}'
|
||||
values: '0+0x9 1+0x50'
|
||||
- series: 'externalsecret_status_condition{condition="Ready",status="False",name="loki-basic-auth",exported_namespace="monitoring",namespace="external-secrets",job="monitoring/external-secrets",pod="external-secrets-7d4f9c6b88-qq2xv"}'
|
||||
values: '0+0x9 1+0x50'
|
||||
|
||||
alert_rule_test:
|
||||
# 5m into the failure: pending, not firing. Asserts the window is real —
|
||||
# a `for:` accidentally set to 0 passes every other case here and fails
|
||||
# only this one.
|
||||
- eval_time: 15m
|
||||
alertname: ExternalSecretSyncFailing
|
||||
exp_alerts: []
|
||||
|
||||
# 20m into the failure. Both failing ExternalSecrets fire, ONCE each; the
|
||||
# healthy one and the tombstone stay silent. The annotations are asserted
|
||||
# in full because the whole point of REN-103's label finding is that the
|
||||
# text names `gitea`, not `external-secrets`.
|
||||
- eval_time: 30m
|
||||
alertname: ExternalSecretSyncFailing
|
||||
exp_alerts:
|
||||
- exp_labels:
|
||||
severity: warning
|
||||
exported_namespace: gitea
|
||||
name: gitea-db
|
||||
exp_annotations:
|
||||
summary: "ExternalSecret gitea/gitea-db is not syncing"
|
||||
description: "ESO has been unable to sync this ExternalSecret for 15 minutes. The Secret it manages still holds its last successfully synced value, so consumers keep working — what breaks is that a rotation at the provider no longer propagates, which surfaces much later as a credential that stopped being accepted. Get the reason with `kubectl -n gitea get externalsecret gitea-db -o jsonpath='{.status.conditions}'`; the common causes are a key that was renamed or removed in OpenBao, a ClusterSecretStore that cannot authenticate, and a target Secret owned by something else."
|
||||
- exp_labels:
|
||||
severity: warning
|
||||
exported_namespace: monitoring
|
||||
name: loki-basic-auth
|
||||
exp_annotations:
|
||||
summary: "ExternalSecret monitoring/loki-basic-auth is not syncing"
|
||||
description: "ESO has been unable to sync this ExternalSecret for 15 minutes. The Secret it manages still holds its last successfully synced value, so consumers keep working — what breaks is that a rotation at the provider no longer propagates, which surfaces much later as a credential that stopped being accepted. Get the reason with `kubectl -n monitoring get externalsecret loki-basic-auth -o jsonpath='{.status.conditions}'`; the common causes are a key that was renamed or removed in OpenBao, a ClusterSecretStore that cannot authenticate, and a target Secret owned by something else."
|
||||
|
||||
# Metrics are present, so the dead-man's-switch stays quiet.
|
||||
- eval_time: 30m
|
||||
alertname: ExternalSecretMetricsMissing
|
||||
exp_alerts: []
|
||||
|
||||
# The scrape disappears entirely: PodMonitor deleted, selector broken by a
|
||||
# chart bump, or the `monitoring` ingress grant reverted out of
|
||||
# external-secrets-ns.yaml. ExternalSecretSyncFailing cannot fire on series
|
||||
# that do not exist — this is the case that made REN-103 necessary, so it is
|
||||
# the one the dead-man's-switch has to cover.
|
||||
- interval: 1m
|
||||
input_series:
|
||||
- series: 'up{job="monitoring/external-secrets"}'
|
||||
values: '0+0x60'
|
||||
alert_rule_test:
|
||||
- eval_time: 30m
|
||||
alertname: ExternalSecretSyncFailing
|
||||
exp_alerts: []
|
||||
- eval_time: 30m
|
||||
alertname: ExternalSecretMetricsMissing
|
||||
exp_alerts:
|
||||
- exp_labels:
|
||||
severity: warning
|
||||
exp_annotations:
|
||||
summary: "No ExternalSecret metrics are reaching Prometheus"
|
||||
description: "`externalsecret_status_condition` has no series. Either the ESO controller is gone or its scrape stopped being collected, and ExternalSecretSyncFailing is blind for as long as this is true. Check the PodMonitor `monitoring/external-secrets` still selects the controller pods, and that external-secrets-ns.yaml still admits `monitoring` on TCP/8080."
|
||||
@@ -0,0 +1,555 @@
|
||||
#!/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)
|
||||
|
||||
# The worst night in Prometheus' 27-night retention (2026-08-30..09-26):
|
||||
# 2026-09-11, peak 141, the longest excursion above 5 on record. Not rebuilt
|
||||
# from a model like NIGHTS — these are the raw samples, read back through the
|
||||
# API server's services/proxy with
|
||||
# max by (namespace, pod) (cnpg_collector_pg_wal_archive_status{
|
||||
# value="ready", pod="ha-pg-1"})
|
||||
# over 02:08:00Z..02:47:00Z at the real 30s scrape step (REN-152). 26
|
||||
# consecutive samples above 5 = 12.5 minutes pending, which is the number the
|
||||
# `for: 20m` margin is measured against, and why mutations.txt:backlog-for-12m
|
||||
# is caught here and nowhere else.
|
||||
WORST_NIGHT = ("2026-09-11", [
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 28, 53, 80, 110, 141, 136, 133, 130, 125, 117,
|
||||
117, 115, 112, 102, 91, 81, 75, 68, 60, 52, 45, 37, 29, 22, 15, 8, 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, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0,
|
||||
])
|
||||
|
||||
FOR_MIN = 20 # `for:` on CNPGWalArchiveBacklog, in minutes
|
||||
|
||||
# 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()
|
||||
_, last = longest_run_above(vals, 5)
|
||||
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)
|
||||
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: {FOR_MIN}m)."
|
||||
)
|
||||
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)
|
||||
w(f" # {clock}Z, backlog {vals[i]}")
|
||||
w(f" - eval_time: {i}m")
|
||||
w(" alertname: CNPGWalArchiveBacklog")
|
||||
w(" exp_alerts: []")
|
||||
w("")
|
||||
return vals, n5
|
||||
|
||||
|
||||
def desc(n, pod=POD, ns=NS):
|
||||
"""The rule's `description` as promtool renders it for `n` segments."""
|
||||
return (
|
||||
f"{n} WAL segments have been waiting to archive for over {FOR_MIN} minutes."
|
||||
" NOTE ON TIMING: a nightly write burst on ha-pg-1 at 02:14Z (04:14 local)"
|
||||
" is normal and drains itself within about 13 minutes, and this rule is"
|
||||
" tuned to stay silent through it — so if this fired, the backlog did NOT"
|
||||
" clear and the archiver is stuck. Point-in-time recovery is broken for"
|
||||
" anything after the last archived segment, and pg_wal will grow until the"
|
||||
" PVC fills. Check the barman-cloud sidecar logs and the ObjectStore"
|
||||
f" credentials: `kubectl -n {ns} logs {pod} -c plugin-barman-cloud`."
|
||||
)
|
||||
|
||||
|
||||
def firing(eval_time, n, pod=POD, ns=NS, note=None):
|
||||
w(f" - eval_time: {eval_time}")
|
||||
w(" alertname: CNPGWalArchiveBacklog")
|
||||
w(" exp_alerts:")
|
||||
if note:
|
||||
w(f" # {note}")
|
||||
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(f' description: "{desc(n, pod, ns)}"')
|
||||
|
||||
|
||||
def silent(eval_time, alert="CNPGWalArchiveBacklog"):
|
||||
w(f" - eval_time: {eval_time}")
|
||||
w(f" alertname: {alert}")
|
||||
w(" exp_alerts: []")
|
||||
|
||||
|
||||
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` is what the `for` window is 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}")
|
||||
for date, peak, pm, fill, drain, dur in NIGHTS + [ENVELOPE]:
|
||||
vals = trace(peak, parse_hhmm(pm), fill, drain)
|
||||
n5, _ = longest_run_above(vals, 5)
|
||||
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}")
|
||||
wn, _ = longest_run_above(WORST_NIGHT[1], 5)
|
||||
print(f"{WORST_NIGHT[0]:12} {max(WORST_NIGHT[1]):5} {'raw':>6} {'30s':>6} "
|
||||
f"{'-':>7} {wn:>6} {max(0, wn - 1) / 2:>8}")
|
||||
print()
|
||||
print(f"for: {FOR_MIN}m on >5 must exceed every '>5 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, the")
|
||||
w("# worst night in Prometheus' retention as raw samples (REN-152), an")
|
||||
w("# adversarial combination of the extremes of those numbers, or a failure")
|
||||
w("# this cluster has not had but the rule exists 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. The rule must stay silent through")
|
||||
w(" # every one of them. The retired depth rule (`> 64`) shipped for review")
|
||||
w(" # at `for: 2m` and fired on 6 of these 7 nights; REN-152 removed it.")
|
||||
w(" # ---------------------------------------------------------------------")
|
||||
checks = []
|
||||
for date, peak, pm, fill, drain, dur in NIGHTS:
|
||||
vals, n5 = 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. 11 samples above 5,")
|
||||
w(" # 10m pending against `for: 20m`.")
|
||||
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.",
|
||||
)
|
||||
|
||||
date, raw = WORST_NIGHT
|
||||
wn, wlast = longest_run_above(raw, 5)
|
||||
peak_i = max(range(len(raw)), key=lambda i: raw[i])
|
||||
w(" # ---------------------------------------------------------------------")
|
||||
w(f" # 9. THE WORST NIGHT ON RECORD, {date}, as raw 30s samples (see")
|
||||
w(" # WORST_NIGHT in the generator). Peak 141, 26 samples above 5 = 12.5m")
|
||||
w(" # of true expression. At `evaluation_interval: 1m` the rule sees it")
|
||||
w(f" # true from 02:13Z to 02:25Z: 12m pending against `for: {FOR_MIN}m`,")
|
||||
w(f" # {FOR_MIN - 12}m of margin. This is the case that makes the margin")
|
||||
w(" # load-bearing: at `for: 12m` it fires at 02:25Z, and nothing else in")
|
||||
w(" # the suite notices (mutations.txt:backlog-for-12m).")
|
||||
w(" # ---------------------------------------------------------------------")
|
||||
w(f" - name: worst night on record {date} — peak 141, raw samples")
|
||||
w(" interval: 30s")
|
||||
w(" input_series:")
|
||||
w(f" - series: '{labels()}'")
|
||||
w(f" # {hhmm(T0)}:00Z .. {hhmm(T0 + 39)}:00Z, one sample per 30s")
|
||||
w(f" values: '{fmt(raw)}'")
|
||||
w(f" - series: '{labels(value='done')}'")
|
||||
w(f" values: '{fmt(counter(148_000, 7, len(raw)))}'")
|
||||
w(" alert_rule_test:")
|
||||
for i in (peak_i, wlast, len(raw) - 1):
|
||||
# eval times in whole minutes: the group evaluates on the minute.
|
||||
m = (i // 2)
|
||||
w(f" # {hhmm(T0 + m)}Z, backlog {raw[m * 2]}")
|
||||
silent(f"{m}m")
|
||||
w("")
|
||||
|
||||
# ---- 10. stalled archiver, well above threshold ------------------------
|
||||
stalled = [0] * 5 + [70] * 35
|
||||
fire_at = 5 + FOR_MIN
|
||||
w(" # ---------------------------------------------------------------------")
|
||||
w(" # 10. THE FAILURE THE RULE EXISTS FOR: an archiver that stopped with")
|
||||
w(" # 70 segments (1.1 GiB) queued. Not a burst — it does not drain. The")
|
||||
w(f" # backlog crosses 5 at t=5m, so `for: {FOR_MIN}m` elapses at t={fire_at}m.")
|
||||
w(" # The assertions bracket the transition: one minute early nothing is")
|
||||
w(" # firing, on the minute it is.")
|
||||
w(" # ---------------------------------------------------------------------")
|
||||
w(" - name: stalled archiver at 70 segments — 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 rule does")
|
||||
w(" # not read this; it is here so the input stays realistic.")
|
||||
w(f" values: '{fmt([148_000] * SAMPLES)}'")
|
||||
w(" alert_rule_test:")
|
||||
silent(f"{fire_at - 1}m")
|
||||
firing(f"{fire_at}m", 70, note="No `role` label: proof that `max by (namespace, pod)` "
|
||||
"aggregated it away. See mutations.txt:no-max-by.")
|
||||
w("")
|
||||
|
||||
# ---- 11. low stall ------------------------------------------------------
|
||||
low = [0] * 5 + [8] * 35
|
||||
w(" # ---------------------------------------------------------------------")
|
||||
w(" # 11. THE STALL A DEPTH THRESHOLD CANNOT SEE: 8 segments, stuck. It")
|
||||
w(" # would never reach any number tuned above the nightly envelope, and")
|
||||
w(" # point-in-time recovery is just as broken at 8 segments as at 70. This")
|
||||
w(" # is why REN-152 keyed the rule on persistence and retired the depth rule.")
|
||||
w(" # ---------------------------------------------------------------------")
|
||||
w(" - name: low stall at 8 segments — must fire")
|
||||
w(" interval: 1m")
|
||||
w(" input_series:")
|
||||
w(f" - series: '{labels()}'")
|
||||
w(f" values: '{fmt(low)}'")
|
||||
w(" alert_rule_test:")
|
||||
silent(f"{fire_at - 1}m")
|
||||
firing(f"{fire_at}m", 8)
|
||||
w("")
|
||||
|
||||
# ---- 12. exactly at the threshold ------------------------------------
|
||||
w(" # ---------------------------------------------------------------------")
|
||||
w(" # 12. 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:")
|
||||
silent("39m")
|
||||
w("")
|
||||
|
||||
# ---- 13. role flip ---------------------------------------------------
|
||||
w(" # ---------------------------------------------------------------------")
|
||||
w(" # 13. 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(f" # still lands {FOR_MIN}m after the backlog started (t={fire_at}m) rather")
|
||||
w(f" # than {FOR_MIN}m after the failover. Without `max by` this case is")
|
||||
w(f" # silent at t={fire_at}m — 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:")
|
||||
silent(f"{fire_at - 1}m")
|
||||
firing(f"{fire_at}m", 70)
|
||||
w("")
|
||||
|
||||
# ---- 14. one stalled pod among healthy ones --------------------------
|
||||
w(" # ---------------------------------------------------------------------")
|
||||
w(" # 14. 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:")
|
||||
firing(f"{fire_at}m", 70, pod="ha-pg-1")
|
||||
w("")
|
||||
|
||||
# ---- 15. CNPGBackupFailed --------------------------------------------
|
||||
w(" # ---------------------------------------------------------------------")
|
||||
w(" # 15. 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:")
|
||||
silent("39m", "CNPGBackupFailed")
|
||||
|
||||
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,223 @@
|
||||
# promtool test rules for prometheusrule-home-assistant.yaml (REN-245).
|
||||
#
|
||||
# Run by the `promtool` step of .woodpecker/ci-manifests-test.yml through
|
||||
# scripts/ci/promtool-rules.sh — see README.md here. Negative controls are the
|
||||
# `ha-*` entries in mutations.txt.
|
||||
#
|
||||
# WHAT IT IS FOR. Each rule claims three things in its comments: it fires 5m
|
||||
# after the outage starts and not before, it stays silent on a healthy system,
|
||||
# and one outage is one alert even when the outage changes shape. Each claim
|
||||
# is asserted here. Series names and labels follow the live shapes recorded in
|
||||
# the REN-244 "findings" document.
|
||||
rule_files:
|
||||
- ../prometheusrule-home-assistant.yaml
|
||||
|
||||
evaluation_interval: 1m
|
||||
|
||||
tests:
|
||||
# --- HomeAssistantDown --------------------------------------------------
|
||||
# Target answers for 10m, fails for 5m, disappears (Recreate rollout) for
|
||||
# 3m, then comes back still failing. One outage from t=10m: the alert must
|
||||
# fire at t=15m and still be the same alert after the target changed shape.
|
||||
- interval: 1m
|
||||
input_series:
|
||||
- series: 'up{job="home-assistant",namespace="home-assistant",instance="10.0.0.1:8124",pod="home-assistant-6d9c7b5f4-abcde"}'
|
||||
values: '1x9 0x4'
|
||||
- series: 'up{job="home-assistant",namespace="home-assistant",instance="10.0.0.2:8124",pod="home-assistant-6d9c7b5f4-fghij"}'
|
||||
values: '_x22 0x10'
|
||||
alert_rule_test:
|
||||
- eval_time: 14m
|
||||
alertname: HomeAssistantDown
|
||||
exp_alerts: []
|
||||
- eval_time: 15m
|
||||
alertname: HomeAssistantDown
|
||||
exp_alerts:
|
||||
- exp_labels: {severity: critical, job: home-assistant, namespace: home-assistant}
|
||||
exp_annotations:
|
||||
summary: "Home Assistant has been down for 5 minutes"
|
||||
description: "The home-assistant scrape target in namespace home-assistant is down or missing. A restart after an automatic image upgrade can take up to 10 minutes, so if an upgrade just rolled out, give it until then before acting. Rollback: kubernetes/RUNBOOK-home-assistant-image-rollback.md."
|
||||
# Old target gone (stale after 5m lookback), new one failing: same alert.
|
||||
- eval_time: 25m
|
||||
alertname: HomeAssistantDown
|
||||
exp_alerts:
|
||||
- exp_labels: {severity: critical, job: home-assistant, namespace: home-assistant}
|
||||
exp_annotations:
|
||||
summary: "Home Assistant has been down for 5 minutes"
|
||||
description: "The home-assistant scrape target in namespace home-assistant is down or missing. A restart after an automatic image upgrade can take up to 10 minutes, so if an upgrade just rolled out, give it until then before acting. Rollback: kubernetes/RUNBOOK-home-assistant-image-rollback.md."
|
||||
|
||||
# Healthy HA stays silent.
|
||||
- interval: 1m
|
||||
input_series:
|
||||
- series: 'up{job="home-assistant",namespace="home-assistant",instance="10.0.0.1:8124",pod="home-assistant-6d9c7b5f4-abcde"}'
|
||||
values: '1x30'
|
||||
alert_rule_test:
|
||||
- eval_time: 30m
|
||||
alertname: HomeAssistantDown
|
||||
exp_alerts: []
|
||||
|
||||
# --- HomeAssistantRecorderDBUnhealthy ----------------------------------
|
||||
# Two instances as in ha-pg-cluster.yaml. The replica's Postgres goes down at
|
||||
# t=10m (its exporter keeps answering with cnpg_collector_up=0).
|
||||
- interval: 1m
|
||||
input_series:
|
||||
- series: 'cnpg_collector_up{job="home-assistant/cnpg-ha-pg",namespace="home-assistant",pod="ha-pg-2",role="primary",cluster="ha-pg"}'
|
||||
values: '1x30'
|
||||
- series: 'cnpg_collector_up{job="home-assistant/cnpg-ha-pg",namespace="home-assistant",pod="ha-pg-1",role="replica",cluster="ha-pg"}'
|
||||
values: '1x9 0x20'
|
||||
alert_rule_test:
|
||||
- eval_time: 9m
|
||||
alertname: HomeAssistantRecorderDBUnhealthy
|
||||
exp_alerts: []
|
||||
- eval_time: 14m
|
||||
alertname: HomeAssistantRecorderDBUnhealthy
|
||||
exp_alerts: []
|
||||
- eval_time: 15m
|
||||
alertname: HomeAssistantRecorderDBUnhealthy
|
||||
exp_alerts:
|
||||
- exp_labels: {severity: critical, namespace: home-assistant}
|
||||
exp_annotations:
|
||||
summary: "Recorder database ha-pg has no primary or is missing an instance"
|
||||
description: "In CNPG cluster home-assistant/ha-pg either no instance reports role=primary with Postgres up, or fewer than 2 instances have Postgres up. Without a primary the Home Assistant recorder cannot write history. Check `kubectl -n home-assistant get cluster ha-pg` and the pods' logs."
|
||||
|
||||
# Primary's Postgres goes down at t=10m. Both branches are true at once (no
|
||||
# primary AND only one instance up); still exactly one alert.
|
||||
- interval: 1m
|
||||
input_series:
|
||||
- series: 'cnpg_collector_up{job="home-assistant/cnpg-ha-pg",namespace="home-assistant",pod="ha-pg-2",role="primary",cluster="ha-pg"}'
|
||||
values: '1x9 0x20'
|
||||
- series: 'cnpg_collector_up{job="home-assistant/cnpg-ha-pg",namespace="home-assistant",pod="ha-pg-1",role="replica",cluster="ha-pg"}'
|
||||
values: '1x30'
|
||||
alert_rule_test:
|
||||
- eval_time: 15m
|
||||
alertname: HomeAssistantRecorderDBUnhealthy
|
||||
exp_alerts:
|
||||
- exp_labels: {severity: critical, namespace: home-assistant}
|
||||
exp_annotations:
|
||||
summary: "Recorder database ha-pg has no primary or is missing an instance"
|
||||
description: "In CNPG cluster home-assistant/ha-pg either no instance reports role=primary with Postgres up, or fewer than 2 instances have Postgres up. Without a primary the Home Assistant recorder cannot write history. Check `kubectl -n home-assistant get cluster ha-pg` and the pods' logs."
|
||||
|
||||
# Both instances up but neither is primary (failover stuck): fires.
|
||||
- interval: 1m
|
||||
input_series:
|
||||
- series: 'cnpg_collector_up{job="home-assistant/cnpg-ha-pg",namespace="home-assistant",pod="ha-pg-2",role="replica",cluster="ha-pg"}'
|
||||
values: '1x30'
|
||||
- series: 'cnpg_collector_up{job="home-assistant/cnpg-ha-pg",namespace="home-assistant",pod="ha-pg-1",role="replica",cluster="ha-pg"}'
|
||||
values: '1x30'
|
||||
alert_rule_test:
|
||||
- eval_time: 4m
|
||||
alertname: HomeAssistantRecorderDBUnhealthy
|
||||
exp_alerts: []
|
||||
- eval_time: 5m
|
||||
alertname: HomeAssistantRecorderDBUnhealthy
|
||||
exp_alerts:
|
||||
- exp_labels: {severity: critical, namespace: home-assistant}
|
||||
exp_annotations:
|
||||
summary: "Recorder database ha-pg has no primary or is missing an instance"
|
||||
description: "In CNPG cluster home-assistant/ha-pg either no instance reports role=primary with Postgres up, or fewer than 2 instances have Postgres up. Without a primary the Home Assistant recorder cannot write history. Check `kubectl -n home-assistant get cluster ha-pg` and the pods' logs."
|
||||
|
||||
# Clean switchover at t=10m: ha-pg-1 takes primary, ha-pg-2 becomes replica.
|
||||
# The role label flips, so both old series go stale and new ones start. Must
|
||||
# stay silent throughout.
|
||||
- interval: 1m
|
||||
input_series:
|
||||
- series: 'cnpg_collector_up{job="home-assistant/cnpg-ha-pg",namespace="home-assistant",pod="ha-pg-2",role="primary",cluster="ha-pg"}'
|
||||
values: '1x9'
|
||||
- series: 'cnpg_collector_up{job="home-assistant/cnpg-ha-pg",namespace="home-assistant",pod="ha-pg-1",role="replica",cluster="ha-pg"}'
|
||||
values: '1x9'
|
||||
- series: 'cnpg_collector_up{job="home-assistant/cnpg-ha-pg",namespace="home-assistant",pod="ha-pg-1",role="primary",cluster="ha-pg"}'
|
||||
values: '_x10 1x20'
|
||||
- series: 'cnpg_collector_up{job="home-assistant/cnpg-ha-pg",namespace="home-assistant",pod="ha-pg-2",role="replica",cluster="ha-pg"}'
|
||||
values: '_x10 1x20'
|
||||
alert_rule_test:
|
||||
- eval_time: 12m
|
||||
alertname: HomeAssistantRecorderDBUnhealthy
|
||||
exp_alerts: []
|
||||
- eval_time: 30m
|
||||
alertname: HomeAssistantRecorderDBUnhealthy
|
||||
exp_alerts: []
|
||||
|
||||
# --- MosquittoDown ------------------------------------------------------
|
||||
# The broker goes not-Ready at t=10m while a credentials-bootstrap Job pod is
|
||||
# Ready. The Job pod must not mask the broker.
|
||||
- interval: 1m
|
||||
input_series:
|
||||
- series: 'kube_pod_status_ready{namespace="home-assistant",pod="mosquitto-7f8b9c6d5-x2k9q",condition="true"}'
|
||||
values: '1x9 0x20'
|
||||
- series: 'kube_pod_status_ready{namespace="home-assistant",pod="mosquitto-credentials-bootstrap-q7w2e",condition="true"}'
|
||||
values: '1x30'
|
||||
alert_rule_test:
|
||||
- eval_time: 14m
|
||||
alertname: MosquittoDown
|
||||
exp_alerts: []
|
||||
- eval_time: 15m
|
||||
alertname: MosquittoDown
|
||||
exp_alerts:
|
||||
- exp_labels: {severity: critical, namespace: home-assistant}
|
||||
exp_annotations:
|
||||
summary: "Mosquitto (MQTT broker) has not been ready for 5 minutes"
|
||||
description: "No mosquitto pod in namespace home-assistant is Ready. Zigbee2MQTT, Shelly and every other MQTT device lose their link to Home Assistant while this lasts, so Zigbee2MQTTDownOrCoordinatorOffline may fire alongside it for the same cause."
|
||||
|
||||
# --- Zigbee2MQTTDownOrCoordinatorOffline -------------------------------
|
||||
# Pod Ready throughout; HA's bridge sensor reads 0 from t=10m (coordinator
|
||||
# unplugged).
|
||||
- interval: 1m
|
||||
input_series:
|
||||
- series: 'kube_pod_status_ready{namespace="home-assistant",pod="zigbee2mqtt-5c4d8f7b6-m3n4p",condition="true"}'
|
||||
values: '1x30'
|
||||
- series: 'homeassistant_binary_sensor_state{entity="binary_sensor.zigbee2mqtt_bridge_connection_state",domain="binary_sensor",friendly_name="Zigbee2MQTT Bridge Connection state",job="home-assistant",namespace="home-assistant",instance="10.0.0.1:8124"}'
|
||||
values: '1x9 0x20'
|
||||
alert_rule_test:
|
||||
- eval_time: 14m
|
||||
alertname: Zigbee2MQTTDownOrCoordinatorOffline
|
||||
exp_alerts: []
|
||||
- eval_time: 15m
|
||||
alertname: Zigbee2MQTTDownOrCoordinatorOffline
|
||||
exp_alerts:
|
||||
- exp_labels: {severity: critical, namespace: home-assistant}
|
||||
exp_annotations:
|
||||
summary: "Zigbee2MQTT is down or its coordinator/bridge is offline"
|
||||
description: "Either the zigbee2mqtt pod in namespace home-assistant is not Ready, or Home Assistant's binary_sensor.zigbee2mqtt_bridge_connection_state reads off. If MosquittoDown is firing too, fix the broker first; the bridge sensor goes off whenever MQTT is gone."
|
||||
|
||||
# Pod not Ready from t=10m with the bridge sensor at 0; a replacement pod is
|
||||
# Ready from t=18m but the bridge stays offline. One outage that changes
|
||||
# shape: fires at t=15m as one alert, and is still that alert once only the
|
||||
# sensor branch is true.
|
||||
- interval: 1m
|
||||
input_series:
|
||||
- series: 'kube_pod_status_ready{namespace="home-assistant",pod="zigbee2mqtt-5c4d8f7b6-m3n4p",condition="true"}'
|
||||
values: '1x9 0x20'
|
||||
- series: 'kube_pod_status_ready{namespace="home-assistant",pod="zigbee2mqtt-5c4d8f7b6-r5s6t",condition="true"}'
|
||||
values: '_x17 1x12'
|
||||
- series: 'homeassistant_binary_sensor_state{entity="binary_sensor.zigbee2mqtt_bridge_connection_state",domain="binary_sensor",friendly_name="Zigbee2MQTT Bridge Connection state",job="home-assistant",namespace="home-assistant",instance="10.0.0.1:8124"}'
|
||||
values: '1x9 0x20'
|
||||
alert_rule_test:
|
||||
- eval_time: 14m
|
||||
alertname: Zigbee2MQTTDownOrCoordinatorOffline
|
||||
exp_alerts: []
|
||||
- eval_time: 15m
|
||||
alertname: Zigbee2MQTTDownOrCoordinatorOffline
|
||||
exp_alerts:
|
||||
- exp_labels: {severity: critical, namespace: home-assistant}
|
||||
exp_annotations:
|
||||
summary: "Zigbee2MQTT is down or its coordinator/bridge is offline"
|
||||
description: "Either the zigbee2mqtt pod in namespace home-assistant is not Ready, or Home Assistant's binary_sensor.zigbee2mqtt_bridge_connection_state reads off. If MosquittoDown is firing too, fix the broker first; the bridge sensor goes off whenever MQTT is gone."
|
||||
- eval_time: 20m
|
||||
alertname: Zigbee2MQTTDownOrCoordinatorOffline
|
||||
exp_alerts:
|
||||
- exp_labels: {severity: critical, namespace: home-assistant}
|
||||
exp_annotations:
|
||||
summary: "Zigbee2MQTT is down or its coordinator/bridge is offline"
|
||||
description: "Either the zigbee2mqtt pod in namespace home-assistant is not Ready, or Home Assistant's binary_sensor.zigbee2mqtt_bridge_connection_state reads off. If MosquittoDown is firing too, fix the broker first; the bridge sensor goes off whenever MQTT is gone."
|
||||
|
||||
# HA down: the bridge sensor goes stale and disappears, the Z2M pod is fine.
|
||||
# HomeAssistantDown owns this; the Zigbee rule must stay silent.
|
||||
- interval: 1m
|
||||
input_series:
|
||||
- series: 'kube_pod_status_ready{namespace="home-assistant",pod="zigbee2mqtt-5c4d8f7b6-m3n4p",condition="true"}'
|
||||
values: '1x30'
|
||||
- series: 'homeassistant_binary_sensor_state{entity="binary_sensor.zigbee2mqtt_bridge_connection_state",domain="binary_sensor",friendly_name="Zigbee2MQTT Bridge Connection state",job="home-assistant",namespace="home-assistant",instance="10.0.0.1:8124"}'
|
||||
values: '1x9'
|
||||
alert_rule_test:
|
||||
- eval_time: 30m
|
||||
alertname: Zigbee2MQTTDownOrCoordinatorOffline
|
||||
exp_alerts: []
|
||||
@@ -0,0 +1,143 @@
|
||||
# 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 margin control, and the only reason case 9 is in the suite. The worst
|
||||
# night in Prometheus' retention (2026-09-11, peak 141) holds the expression
|
||||
# true for 12 minutes at a 1m evaluation step, so at `for: 12m` it fires. The
|
||||
# seven reconstructed nights peak at 10m pending and would stay silent — without
|
||||
# the raw worst-night case, `for: 20m` could be cut to 12m with the suite green.
|
||||
# Detected by: case 9, the worst night on record.
|
||||
backlog-for-12m|kubernetes/apps/monitoring-rules/prometheusrule-cnpg-backup.yaml|s/^\([[:space:]]*\)for: 20m$/\1for: 12m/
|
||||
|
||||
# The value REN-129 changed, in the direction it changed it from. At 30m the
|
||||
# rule does not page until 35 minutes into a stall that started at t=5m, so the
|
||||
# assertion at t=25m sees nothing. This is the control that stops the 20m being
|
||||
# quietly walked back.
|
||||
# Detected by: cases 10, 11, 13, 14 — every case that expects a page.
|
||||
backlog-for-30m|kubernetes/apps/monitoring-rules/prometheusrule-cnpg-backup.yaml|s/^\([[:space:]]*\)for: 20m$/\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 20m after the promotion
|
||||
# instead of 20m 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 13, 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 the
|
||||
# rule 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-9 and 12 — 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. `> 5` is strict; `>= 5` pages
|
||||
# on a backlog sitting at exactly five segments, which is 80 MiB and normal.
|
||||
# Detected by: case 12, pinned at exactly 5.
|
||||
backlog-ge-5|kubernetes/apps/monitoring-rules/prometheusrule-cnpg-backup.yaml|/^[[:space:]]*expr:/s/> 5$/>= 5/
|
||||
|
||||
# --- prometheusrule-external-secrets.yaml (REN-103) -------------------------
|
||||
# Scoped to the ExternalSecretSyncFailing expression with /max by/ or /== 1/
|
||||
# where it matters: the manifest's comments quote the rejected forms, and
|
||||
# rewriting those would mutate the documentation and nothing else.
|
||||
|
||||
# The window. At 5m the failure that starts at t=10m is already firing at the
|
||||
# t=15m assertion, which expects pending.
|
||||
# Detected by: the eval_time 15m case.
|
||||
eso-for-5m|kubernetes/apps/monitoring-rules/prometheusrule-external-secrets.yaml|s/^\([[:space:]]*\)for: 15m$/\1for: 5m/
|
||||
|
||||
# REN-100's aggregation. `sum` over the two-pod rollout case is 2, fails
|
||||
# `== 1`, and loki-basic-auth never fires.
|
||||
# Detected by: the eval_time 30m case, loki-basic-auth.
|
||||
eso-sum-not-max|kubernetes/apps/monitoring-rules/prometheusrule-external-secrets.yaml|s/^\([[:space:]]*\)max by (exported_namespace, name) ($/\1sum by (exported_namespace, name) (/
|
||||
|
||||
# The label Argus found shadowed on #994. Grouping by `namespace` names the
|
||||
# controller's namespace on every alert.
|
||||
# Detected by: the eval_time 30m case, exp_labels and the rendered summary.
|
||||
eso-by-namespace|kubernetes/apps/monitoring-rules/prometheusrule-external-secrets.yaml|s/^\([[:space:]]*\)max by (exported_namespace, name) ($/\1max by (namespace, name) (/
|
||||
|
||||
# Drop `== 1`. ESO writes the inactive Ready polarity at 0, so every healthy
|
||||
# ExternalSecret matches the bare selector and fires.
|
||||
# Detected by: the eval_time 30m case, uptime-robot-api-key.
|
||||
eso-no-eq-1|kubernetes/apps/monitoring-rules/prometheusrule-external-secrets.yaml|s/^\([[:space:]]*\)) == 1$/\1)/
|
||||
|
||||
# Drop the condition selector. The Deleted tombstone is status="False" at 1
|
||||
# forever, so it pages until the controller restarts.
|
||||
# Detected by: the eval_time 30m case, wiki-index-basic-auth.
|
||||
eso-any-condition|kubernetes/apps/monitoring-rules/prometheusrule-external-secrets.yaml|s/^\([[:space:]]*\)externalsecret_status_condition{condition="Ready",status="False"}$/\1externalsecret_status_condition{status="False"}/
|
||||
|
||||
# prometheusrule-home-assistant.yaml (REN-245). Detected by the matching
|
||||
# `eval_time: 14m` / `15m` pairs in home-assistant_test.yaml.
|
||||
ha-down-for-10m|kubernetes/apps/monitoring-rules/prometheusrule-home-assistant.yaml|/alert: HomeAssistantDown/,/for:/s/for: 5m/for: 10m/
|
||||
# Drops the label normalisation: the alert changes identity when the target
|
||||
# comes back failing, and the t=25m assertion loses it.
|
||||
ha-down-no-max|kubernetes/apps/monitoring-rules/prometheusrule-home-assistant.yaml|s/max by (job, namespace) (up{job="home-assistant", namespace="home-assistant"}) == 0/up{job="home-assistant", namespace="home-assistant"} == 0/
|
||||
# The instance floor pinned to spec.instances. At 3 the healthy two-instance
|
||||
# cluster pages. Detected by the switchover case, which must stay silent.
|
||||
ha-recorder-count-3|kubernetes/apps/monitoring-rules/prometheusrule-home-assistant.yaml|s/== 1) < 2$/== 1) < 3/
|
||||
# The loose regex that lets a Ready bootstrap Job pod mask a down broker.
|
||||
ha-mosquitto-loose-regex|kubernetes/apps/monitoring-rules/prometheusrule-home-assistant.yaml|s/pod=~"mosquitto-\[a-z0-9\]{6,10}-\[a-z0-9\]{5}"/pod=~"mosquitto-.*"/
|
||||
# Drops the bridge-sensor branch. Detected by the coordinator-offline case.
|
||||
ha-z2m-no-bridge|kubernetes/apps/monitoring-rules/prometheusrule-home-assistant.yaml|s/bridge_connection_state"}) == 0$/bridge_connection_state"}) == 2/
|
||||
|
||||
# --- prometheusrule-ceph.yaml (REN-93) --------------------------------------
|
||||
# The canary and the critical rule it guards share the `{type="replicated"}`
|
||||
# join text, so these scope by the canary's `absent(` ... `)` block: inside it
|
||||
# for the canary, `!` for everything else. The annotation quotes the selector
|
||||
# with escaped quotes and so never matches.
|
||||
|
||||
# The window, shortened. At 15m the canary pages on a mgr-failover-length gap.
|
||||
# Detected by: case F, the eval_time 75m silence.
|
||||
ceph-blind-for-15m|kubernetes/apps/monitoring-rules/prometheusrule-ceph.yaml|s/^\([[:space:]]*\)for: 1h$/\1for: 15m/
|
||||
|
||||
# The window, lengthened. At 2h a real blindness that started at t=30m is
|
||||
# still not reported at 95m.
|
||||
# Detected by: case F, the eval_time 95m page.
|
||||
ceph-blind-for-2h|kubernetes/apps/monitoring-rules/prometheusrule-ceph.yaml|s/^\([[:space:]]*\)for: 1h$/\1for: 2h/
|
||||
|
||||
# Drop the value filter from the canary only. It then watches a join the
|
||||
# critical rule does not use, and stays quiet through a renamed `type` label
|
||||
# or value while the critical rule is dark.
|
||||
# Detected by: cases C and D.
|
||||
ceph-blind-any-type|kubernetes/apps/monitoring-rules/prometheusrule-ceph.yaml|/^[[:space:]]*absent($/,/^[[:space:]]*)$/s/{type="replicated"}//
|
||||
|
||||
# Invert the canary: the bare join instead of its absence. It then fires on
|
||||
# every healthy cluster and on every real redundancy loss, i.e. it masks the
|
||||
# alert it exists to protect.
|
||||
# Detected by: cases A and B.
|
||||
ceph-blind-inverted|kubernetes/apps/monitoring-rules/prometheusrule-ceph.yaml|s/^\([[:space:]]*\)absent($/\1(/
|
||||
|
||||
# REN-88's gate, removed from the critical rule. Erasure-coded and renamed
|
||||
# pools then match the critical rule again. Proves this suite loads and asserts
|
||||
# the critical rule, not only the canary.
|
||||
# Detected by: cases C, D and E, which assert the critical rule is silent.
|
||||
ceph-critical-any-type|kubernetes/apps/monitoring-rules/prometheusrule-ceph.yaml|/^[[:space:]]*absent($/,/^[[:space:]]*)$/!s/{type="replicated"}//
|
||||
@@ -2,6 +2,7 @@ apiVersion: helm.toolkit.fluxcd.io/v2
|
||||
kind: HelmRelease
|
||||
metadata:
|
||||
name: blackbox-exporter
|
||||
# anchor: namespace
|
||||
namespace: monitoring
|
||||
spec:
|
||||
interval: 30m
|
||||
|
||||
@@ -17,7 +17,7 @@ spec:
|
||||
chart:
|
||||
spec:
|
||||
chart: kube-prometheus-stack
|
||||
version: ">=72.0.0"
|
||||
version: "91.7.0"
|
||||
sourceRef:
|
||||
kind: HelmRepository
|
||||
name: prometheus-community
|
||||
@@ -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
|
||||
|
||||
@@ -18,12 +18,18 @@ data:
|
||||
# "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 (navidrome-ns.yaml:9-11)
|
||||
# - traefik (navidrome-ns.yaml:13-16)
|
||||
# - the navidrome-gate outpost (navidrome-ns.yaml:17-20)
|
||||
# - the lidarr-gate outpost (navidrome-ns.yaml:21-24) - reaches navidrome too
|
||||
# - the monitoring namespace (navidrome-ns.yaml:25-27)
|
||||
# - host / remote-node (navidrome-ns.yaml:28-30)
|
||||
# - 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
|
||||
@@ -77,6 +83,7 @@ spec:
|
||||
- name: music
|
||||
mountPath: /music
|
||||
readOnly: true
|
||||
# anchor: kubelet-probes
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /ping
|
||||
|
||||
@@ -16,6 +16,7 @@ spec:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
# anchor: navidrome-gate
|
||||
backend:
|
||||
service:
|
||||
name: ak-outpost-navidrome-gate
|
||||
|
||||
@@ -23,6 +23,7 @@ metadata:
|
||||
name: lidarr-reconcile
|
||||
namespace: navidrome
|
||||
data:
|
||||
# anchor: reconcile-script
|
||||
reconcile.sh: |
|
||||
set -eu
|
||||
API=http://localhost:8686/api/v1
|
||||
@@ -111,6 +112,7 @@ spec:
|
||||
mountPath: /config
|
||||
- name: music
|
||||
mountPath: /music
|
||||
# anchor: kubelet-probes
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /ping
|
||||
@@ -213,6 +215,7 @@ spec:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
# anchor: lidarr-gate
|
||||
backend:
|
||||
service:
|
||||
name: ak-outpost-lidarr-gate
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# paperclip-api.renner.uno — LAN-only machine door (no dns.renner.uno/public label).
|
||||
# Bypasses the Authentik gate; Paperclip's bearer agent/board keys are the auth.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: paperclip-api
|
||||
namespace: paperclip
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-cluster
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
tls:
|
||||
- hosts:
|
||||
- paperclip-api.renner.uno
|
||||
secretName: paperclip-api-tls
|
||||
rules:
|
||||
- host: paperclip-api.renner.uno
|
||||
http:
|
||||
paths:
|
||||
- path: /api
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: paperclip
|
||||
port:
|
||||
number: 3100
|
||||
---
|
||||
# Board sign-in stays behind the gate: every /api/auth request on this host is refused.
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: Middleware
|
||||
metadata:
|
||||
name: deny-all
|
||||
namespace: paperclip
|
||||
spec:
|
||||
ipAllowList:
|
||||
sourceRange:
|
||||
- 255.255.255.255/32
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: paperclip-api-auth-deny
|
||||
namespace: paperclip
|
||||
annotations:
|
||||
traefik.ingress.kubernetes.io/router.middlewares: paperclip-deny-all@kubernetescrd
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
tls:
|
||||
- hosts:
|
||||
- paperclip-api.renner.uno
|
||||
secretName: paperclip-api-tls
|
||||
rules:
|
||||
- host: paperclip-api.renner.uno
|
||||
http:
|
||||
paths:
|
||||
- path: /api/auth
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: paperclip
|
||||
port:
|
||||
number: 3100
|
||||
@@ -58,7 +58,8 @@
|
||||
# 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
|
||||
# the `flux-view-flux-system` ClusterRole in
|
||||
# `../../clusters/homelab/flux-system/gotk-components.yaml`. 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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -9,7 +9,9 @@ resources:
|
||||
- pvc.yaml
|
||||
- prometheus-read-rbac.yaml
|
||||
- cluster-view-rbac.yaml
|
||||
- sandbox-rbac.yaml
|
||||
- paperclip.yaml
|
||||
- gate-service.yaml
|
||||
- ingress.yaml
|
||||
- api-door.yaml
|
||||
- network-policy.yaml
|
||||
|
||||
@@ -16,6 +16,15 @@ spec:
|
||||
- ports:
|
||||
- port: "3100"
|
||||
protocol: TCP
|
||||
# LAN machine door paperclip-api.renner.uno (api-door.yaml): bearer-key API only.
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": ingress
|
||||
app.kubernetes.io/name: traefik
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "3100"
|
||||
protocol: TCP
|
||||
---
|
||||
# Covers the server and every agent it spawns (same pod).
|
||||
apiVersion: cilium.io/v2
|
||||
|
||||
@@ -34,7 +34,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: paperclip
|
||||
image: ghcr.io/paperclipai/paperclip:nightly@sha256:9222efbf11863160669e13d5559e6a1be881886dd1758650b74c3993dc3ef2a6 # {"$imagepolicy": "flux-system:paperclip"}
|
||||
image: ghcr.io/paperclipai/paperclip:nightly@sha256:45f3e7fee1cb1a045854bce42ab189163d1dd573ba3b1b2a4c7469714ec29621 # {"$imagepolicy": "flux-system:paperclip"}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 3100
|
||||
@@ -58,7 +58,7 @@ spec:
|
||||
- name: PAPERCLIP_API_URL
|
||||
value: http://paperclip.paperclip.svc.cluster.local:3100
|
||||
- name: PAPERCLIP_ALLOWED_HOSTNAMES
|
||||
value: paperclip.paperclip.svc.cluster.local
|
||||
value: paperclip.paperclip.svc.cluster.local,paperclip-api.renner.uno
|
||||
- name: PAPERCLIP_AUTH_DISABLE_SIGN_UP
|
||||
value: "true"
|
||||
- name: PAPERCLIP_TELEMETRY_DISABLED
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
# Write rights for the Paperclip Kubernetes plugin in namespace
|
||||
# `paperclip-renner` (REN-285, owner-approved plan REN-284 step 1).
|
||||
#
|
||||
# The plugin (backend `sandbox-cr`, `inCluster: true`) runs inside the
|
||||
# paperclip server and uses the pod's mounted token. For each agent run it
|
||||
# creates a Sandbox CR (agent-sandbox controller, infrastructure/agent-sandbox),
|
||||
# a NetworkPolicy/CiliumNetworkPolicy, Secrets, and then execs the agent CLI
|
||||
# into the pod.
|
||||
#
|
||||
# WHICH ServiceAccount
|
||||
#
|
||||
# The card names ServiceAccount `paperclip`. There is none: the paperclip pod
|
||||
# runs as `paperclip-prometheus-read` (paperclip.yaml, `serviceAccountName`), so
|
||||
# that is the account whose token the plugin holds, and the one bound here.
|
||||
# See prometheus-read-rbac.yaml for why it keeps that name.
|
||||
#
|
||||
# WHAT IT COSTS
|
||||
#
|
||||
# Every process in the paperclip pod can read the token, so this is a grant to
|
||||
# every agent in that pod: create pods, exec into them, read and write Secrets —
|
||||
# all only in `paperclip-renner`. It cannot touch any other namespace.
|
||||
#
|
||||
# WHY IT CANNOT ESCAPE THE NAMESPACE
|
||||
#
|
||||
# * All namespaced rights are a Role in `paperclip-renner`, not a ClusterRole,
|
||||
# so no later binding can widen them to another namespace.
|
||||
# * No `escalate`, no `bind`: the account can create Roles and RoleBindings
|
||||
# only with rights it already holds there (apiserver escalation check).
|
||||
# * No `patch`/`update` on namespaces. The namespace carries PSS `restricted`
|
||||
# (infrastructure/namespaces), and that is what stops a privileged pod.
|
||||
# * The one cluster-scoped right is `create namespaces`. Kubernetes cannot
|
||||
# limit `create` by name. A namespace created this way is empty and the
|
||||
# account holds no rights inside it. `paperclip-renner` itself is declared
|
||||
# in git, so the plugin's create is a no-op AlreadyExists here.
|
||||
#
|
||||
# PRODUCTION
|
||||
#
|
||||
# RBAC only. No pod restarts, no traffic change; RBAC is evaluated per request.
|
||||
# Revert: delete this file and its line in kustomization.yaml.
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
metadata:
|
||||
name: paperclip-sandbox-manager
|
||||
namespace: paperclip-renner
|
||||
labels:
|
||||
app.kubernetes.io/name: paperclip
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources:
|
||||
- pods
|
||||
- secrets
|
||||
- serviceaccounts
|
||||
- resourcequotas
|
||||
- limitranges
|
||||
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
|
||||
- apiGroups: [""]
|
||||
resources: ["pods/log"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
# WebSocket exec is an HTTP GET; the apiserver authorizes it as `create`
|
||||
# (kube 1.31+). `get` is kept for older SPDY clients.
|
||||
- apiGroups: [""]
|
||||
resources: ["pods/exec"]
|
||||
verbs: ["get", "create"]
|
||||
- apiGroups: ["rbac.authorization.k8s.io"]
|
||||
resources: ["roles", "rolebindings"]
|
||||
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
|
||||
- apiGroups: ["networking.k8s.io"]
|
||||
resources: ["networkpolicies"]
|
||||
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
|
||||
- apiGroups: ["cilium.io"]
|
||||
resources: ["ciliumnetworkpolicies"]
|
||||
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
|
||||
- apiGroups: ["agents.x-k8s.io"]
|
||||
resources: ["sandboxes"]
|
||||
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
|
||||
- apiGroups: ["agents.x-k8s.io"]
|
||||
resources: ["sandboxes/status"]
|
||||
verbs: ["get"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata:
|
||||
name: paperclip-sandbox-manager
|
||||
namespace: paperclip-renner
|
||||
labels:
|
||||
app.kubernetes.io/name: paperclip
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: Role
|
||||
name: paperclip-sandbox-manager
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: paperclip-prometheus-read
|
||||
# The subject lives in `paperclip`, the binding in `paperclip-renner`.
|
||||
namespace: paperclip
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: paperclip-sandbox-namespace-create
|
||||
labels:
|
||||
app.kubernetes.io/name: paperclip
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["namespaces"]
|
||||
verbs: ["create"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: paperclip-sandbox-namespace-create
|
||||
labels:
|
||||
app.kubernetes.io/name: paperclip
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: ClusterRole
|
||||
name: paperclip-sandbox-namespace-create
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: paperclip-prometheus-read
|
||||
namespace: paperclip
|
||||
@@ -1,6 +1,7 @@
|
||||
apiVersion: postgresql.cnpg.io/v1
|
||||
kind: Cluster
|
||||
metadata:
|
||||
# anchor: pprl-pg
|
||||
name: pprl-pg
|
||||
namespace: paperless
|
||||
annotations:
|
||||
|
||||
@@ -59,6 +59,7 @@ metadata:
|
||||
spec:
|
||||
selector:
|
||||
app.kubernetes.io/name: gotenberg
|
||||
# anchor: service-port
|
||||
ports:
|
||||
- port: 3000
|
||||
targetPort: http
|
||||
|
||||
@@ -11,14 +11,19 @@
|
||||
# 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)
|
||||
# - the paperless namespace
|
||||
# ../../infrastructure/network-policies/paperless-ns.yaml#paperless-admit
|
||||
# - the database namespace
|
||||
# ../../infrastructure/network-policies/paperless-ns.yaml#database-admit
|
||||
# - the monitoring namespace
|
||||
# ../../infrastructure/network-policies/paperless-ns.yaml#monitoring-admit
|
||||
# - host / remote-node
|
||||
# ../../infrastructure/network-policies/paperless-ns.yaml#host-admit
|
||||
# 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.
|
||||
#
|
||||
# anchor: cnp-union
|
||||
# This is a CiliumNetworkPolicy (not a standard NetworkPolicy) so it
|
||||
# composes additively with the cluster's baseline CNP
|
||||
# `default-deny-ingress` for the paperless namespace — Cilium's allow
|
||||
|
||||
@@ -24,6 +24,7 @@ data:
|
||||
PAPERLESS_ACCOUNT_DEFAULT_GROUPS: "Familie"
|
||||
PAPERLESS_URL: "https://paperless.renner.uno"
|
||||
PAPERLESS_CSRF_TRUSTED_ORIGINS: "https://paperless.renner.uno,https://docs.renner.uno"
|
||||
# anchor: allowed-hosts
|
||||
PAPERLESS_ALLOWED_HOSTS: "*"
|
||||
PAPERLESS_TIME_ZONE: "Europe/Berlin"
|
||||
PAPERLESS_OCR_LANGUAGE: "deu+eng"
|
||||
@@ -43,6 +44,7 @@ data:
|
||||
PAPERLESS_CONSUMER_POLLING_INTERVAL: "10"
|
||||
PAPERLESS_REDIS: "redis://redis:6379"
|
||||
PAPERLESS_DBENGINE: "postgresql"
|
||||
# anchor: db-host
|
||||
PAPERLESS_DBHOST: "pprl-pg-rw"
|
||||
PAPERLESS_DBNAME: "paperless"
|
||||
PAPERLESS_TIKA_ENABLED: "1"
|
||||
@@ -249,6 +251,7 @@ spec:
|
||||
# answers, giving that boot 30 * 20s = 10 minutes. Without it the
|
||||
# 120s liveness delay plus three 30s periods kills the pod 210s in,
|
||||
# part-way through a migration, and the next boot starts over.
|
||||
# anchor: http-probes
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /accounts/login/
|
||||
@@ -282,6 +285,7 @@ spec:
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
add: ["CHOWN", "DAC_OVERRIDE", "FOWNER", "SETGID", "SETUID"]
|
||||
# anchor: bridge-tls-proxy
|
||||
- name: bridge-tls-proxy
|
||||
image: alpine/socat:1.8.0.3
|
||||
args:
|
||||
|
||||
@@ -72,6 +72,7 @@ metadata:
|
||||
spec:
|
||||
selector:
|
||||
app.kubernetes.io/name: redis
|
||||
# anchor: service-port
|
||||
ports:
|
||||
- port: 6379
|
||||
targetPort: redis
|
||||
|
||||
@@ -53,6 +53,7 @@ metadata:
|
||||
spec:
|
||||
selector:
|
||||
app.kubernetes.io/name: tika
|
||||
# anchor: service-port
|
||||
ports:
|
||||
- port: 9998
|
||||
targetPort: http
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
# Renovate bot (global) configuration (REN-216).
|
||||
#
|
||||
# This is the configuration of the RUNNER only: where Gitea is, and which
|
||||
# repositories to process. What Renovate does INSIDE a repository — schedule,
|
||||
# grouping, commit titles, digest pinning — lives in the shared preset repository
|
||||
# `k.renner/renovate-config`, which each repository's renovate.json extends. Change
|
||||
# update policy there, not here.
|
||||
#
|
||||
# autodiscover + requireConfig=required + onboarding=false
|
||||
# Process every repository renovate-bot can see that already has a
|
||||
# renovate.json, and nothing else. No onboarding pull requests: a
|
||||
# repository opts in when its maintainer commits renovate.json.
|
||||
# persistRepoData=false
|
||||
# The pod is ephemeral; every run clones fresh into an emptyDir.
|
||||
# binarySource=install
|
||||
# The slim image installs a tool (for example helm) only when a manager
|
||||
# needs it. The managers homelab uses (flux, kubernetes, woodpecker) need
|
||||
# none.
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: renovate-config
|
||||
namespace: renovate
|
||||
data:
|
||||
config.json: |
|
||||
{
|
||||
"platform": "gitea",
|
||||
"endpoint": "https://git.renner.uno/api/v1",
|
||||
"gitAuthor": "Renovate Bot <renovate-bot@renner.uno>",
|
||||
"autodiscover": true,
|
||||
"onboarding": false,
|
||||
"requireConfig": "required",
|
||||
"persistRepoData": false,
|
||||
"binarySource": "install",
|
||||
"baseDir": "/tmp/renovate"
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
# Renovate runner (REN-216).
|
||||
#
|
||||
# RUNS DAILY, BUT OPENS ROUTINE PULL REQUESTS ON MONDAY ONLY. The weekly rhythm
|
||||
# is enforced by the `schedule` in the shared preset (k.renner/renovate-config),
|
||||
# not by this cron line. The daily run exists for one reason: a security update
|
||||
# (`vulnerabilityAlerts`, which the preset leaves unscheduled) must not wait up
|
||||
# to six days. On Tuesday to Sunday a run finds nothing in schedule, opens and
|
||||
# rebases nothing (`updateNotScheduled: false` in the preset), and exits.
|
||||
#
|
||||
# Blast radius: this pod has no Kubernetes API access (no token mounted, no
|
||||
# Role) and writes only to Gitea, as renovate-bot, on branches `renovate/*`. It
|
||||
# cannot merge. If it misbehaves, the worst result is unwanted pull requests;
|
||||
# suspend it with `spec.suspend: true` in git, or revert this directory.
|
||||
apiVersion: batch/v1
|
||||
kind: CronJob
|
||||
metadata:
|
||||
name: renovate
|
||||
namespace: renovate
|
||||
labels:
|
||||
app.kubernetes.io/name: renovate
|
||||
spec:
|
||||
schedule: "0 4 * * *"
|
||||
timeZone: Europe/Berlin
|
||||
# Two runs against the same repositories would race on the same branches.
|
||||
concurrencyPolicy: Forbid
|
||||
successfulJobsHistoryLimit: 2
|
||||
failedJobsHistoryLimit: 3
|
||||
startingDeadlineSeconds: 3600
|
||||
jobTemplate:
|
||||
spec:
|
||||
backoffLimit: 0
|
||||
activeDeadlineSeconds: 3600
|
||||
ttlSecondsAfterFinished: 604800
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: renovate
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
enableServiceLinks: false
|
||||
automountServiceAccountToken: false
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
# `ubuntu` in the upstream image.
|
||||
runAsUser: 12021
|
||||
runAsGroup: 12021
|
||||
fsGroup: 12021
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: renovate
|
||||
# Pinned by tag and digest. Renovate updates itself through
|
||||
# homelab's renovate.json, like any other image here.
|
||||
image: docker.io/renovate/renovate:44.115.10@sha256:e262ed52b23dd8c545c80faf5b1ad5c95a07b3f198888de8d7b8928f45c307c7
|
||||
env:
|
||||
- name: RENOVATE_CONFIG_FILE
|
||||
value: /config/config.json
|
||||
- name: LOG_LEVEL
|
||||
value: info
|
||||
- name: LOG_FORMAT
|
||||
value: json
|
||||
# The root file system is read-only; git, npm caches and
|
||||
# containerbase all write below $HOME.
|
||||
- name: HOME
|
||||
value: /tmp/home
|
||||
# RENOVATE_TOKEN and RENOVATE_HOST_RULES.
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: renovate-token
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /config
|
||||
readOnly: true
|
||||
- name: tmp
|
||||
mountPath: /tmp
|
||||
resources:
|
||||
requests:
|
||||
cpu: 250m
|
||||
memory: 512Mi
|
||||
limits:
|
||||
memory: 2Gi
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
volumes:
|
||||
- name: config
|
||||
configMap:
|
||||
name: renovate-config
|
||||
- name: tmp
|
||||
emptyDir:
|
||||
sizeLimit: 4Gi
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
# Gitea token for the `renovate-bot` account (REN-216).
|
||||
#
|
||||
# OpenBao key infrastructure/gitea/renovate-bot, field `token`. Hermes owns the
|
||||
# account and the token. The token needs these scopes, and no others:
|
||||
# write:repository push update branches, open pull requests
|
||||
# write:issue the Dependency Dashboard issue, labels
|
||||
# read:user Renovate reads its own user at startup
|
||||
# read:organization autodiscover lists repositories through org membership
|
||||
# read:package look up tags of CI images on gitea.renner.uno
|
||||
#
|
||||
# Which repositories Renovate touches is decided by Gitea, not by this file:
|
||||
# autodiscover finds only the repositories renovate-bot is a collaborator on,
|
||||
# and `requireConfig: required` (configmap.yaml) skips any of those that has no
|
||||
# renovate.json. So a repository opts in twice: Hermes grants access, and the
|
||||
# maintainer commits renovate.json. Branch protection on `main`/`master` keeps
|
||||
# the merge with Argus; the bot cannot merge.
|
||||
#
|
||||
# `data` + `property`, not `dataFrom: extract`, for the reason written in
|
||||
# apps/uptime-monitor/externalsecret.yaml: name the one field, so a field added
|
||||
# at the KV path later does not land here unseen.
|
||||
#
|
||||
# The template renders two env vars from the one token:
|
||||
# RENOVATE_TOKEN the platform token.
|
||||
# RENOVATE_HOST_RULES the same token as registry credentials for
|
||||
# gitea.renner.uno, so Renovate can list the tags of the
|
||||
# in-house CI images (.woodpecker/*.yml).
|
||||
#
|
||||
# refreshInterval 1h, as for the other un-rotated keys. The CronJob reads the
|
||||
# Secret at pod start, so a new value is used from the next run. No Reloader
|
||||
# annotation is needed.
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
metadata:
|
||||
name: renovate-token
|
||||
namespace: renovate
|
||||
spec:
|
||||
refreshInterval: 1h
|
||||
secretStoreRef:
|
||||
kind: ClusterSecretStore
|
||||
name: openbao
|
||||
target:
|
||||
name: renovate-token
|
||||
template:
|
||||
engineVersion: v2
|
||||
data:
|
||||
RENOVATE_TOKEN: "{{ .token }}"
|
||||
RENOVATE_HOST_RULES: '[{"matchHost":"gitea.renner.uno","hostType":"docker","username":"renovate-bot","password":"{{ .token }}"}]'
|
||||
data:
|
||||
- secretKey: token
|
||||
remoteRef:
|
||||
key: infrastructure/gitea/renovate-bot
|
||||
property: token
|
||||
@@ -0,0 +1,6 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- externalsecret.yaml
|
||||
- configmap.yaml
|
||||
- cronjob.yaml
|
||||
@@ -1,4 +1,47 @@
|
||||
---
|
||||
# ============================================================================
|
||||
# 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
|
||||
# (Web Service 8 in ../gatus/configmap.yaml#gatus-endpoints) 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:
|
||||
#
|
||||
@@ -12,7 +55,9 @@
|
||||
# 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 has a monitor in the account today. The nine that exist are
|
||||
# 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
|
||||
@@ -27,7 +72,8 @@
|
||||
# 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).
|
||||
# field on it (crd-endpointmonitors.yaml, schema path
|
||||
# `spec.urlFrom.ingressRef`).
|
||||
# * `discoverURLFromRefs` resolves that name against
|
||||
# `ingressMonitor.Namespace` — the EndpointMonitor's OWN namespace
|
||||
# (v2.2.13 pkg/kube/util/url.go:71 → :38-40).
|
||||
@@ -66,34 +112,57 @@
|
||||
# template.
|
||||
#
|
||||
# ---
|
||||
# NO `alertContacts` YET — AND THIS IS A GAP, NOT AN OVERSIGHT
|
||||
# `alertContacts` — WIRED TO THE OWNER'S EXISTING CHANNEL (REN-7, REN-138)
|
||||
#
|
||||
# `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.
|
||||
# (uptime-monitor.go:240-244). Neither was set before this change, so the pilot
|
||||
# monitors were created notifying 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.
|
||||
# The value below is copied from the account, not chosen: every one of the nine
|
||||
# hand-made monitors carries exactly these two contacts, at threshold 0 and
|
||||
# recurrence 0 (read with `getMonitors&alert_contacts=1`, 2026-09-26). So an IMC
|
||||
# monitor now alerts wherever the owner's existing monitors already alert — no
|
||||
# new channel, no new recipient. Contact ids from `getAlertContacts`:
|
||||
#
|
||||
# 7880965 type 2 (e-mail)
|
||||
# 8026823 type 13
|
||||
#
|
||||
# ORDER MATTERS. `Equal` compares the rendered request strings, and the read-back
|
||||
# side builds its string from the API's `alert_contacts` array in the order the
|
||||
# API returns it (uptime-mappers.go:22-29). The API returns 7880965 first, so the
|
||||
# value is written in that order; reversed, IMC would see a "change" and re-send
|
||||
# `editMonitor` every 300s forever.
|
||||
#
|
||||
# ALERT DELIVERY IS OWNER-VERIFIED, NOT PROVEN HERE. By the owner's ruling on
|
||||
# REN-138 no test alert is fired from this repo: no broken monitor, no sentinel.
|
||||
# This file proves the wiring only.
|
||||
#
|
||||
# ---
|
||||
# NO `statusPages` — DELIBERATELY, AS AN EXPERIMENT (Zeus, on REN-7)
|
||||
# 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). Leaving it unset therefore asks the page itself
|
||||
# the open REN-9 question: after these two monitors exist, do they appear on
|
||||
# the `uptime.renner.uno` feed on their own (the page is in an all-monitors
|
||||
# mode) or not (each monitor must be added explicitly)?
|
||||
# (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:
|
||||
#
|
||||
# Baseline for that comparison, read keyless at 2026-09-26 from
|
||||
# 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.
|
||||
# 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
|
||||
@@ -135,6 +204,47 @@
|
||||
# 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:
|
||||
@@ -143,7 +253,7 @@ metadata:
|
||||
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.
|
||||
# (../wordpress/ingress.yaml#gated-paths). `/` is the ungated one.
|
||||
url: https://www.renner.uno/
|
||||
uptimeRobotConfig:
|
||||
# `http`, not `keyword`. The survey recommends a keyword check here
|
||||
@@ -157,18 +267,30 @@ spec:
|
||||
# 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
|
||||
# See "alertContacts" in the header: the owner's existing two contacts.
|
||||
alertContacts: 7880965_0_0-8026823_0_0
|
||||
---
|
||||
# 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 is in the URL rather than in `spec.healthEndpoint`, because
|
||||
# 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).
|
||||
# Endpoint taken from the existing Gatus check (apps/gatus/configmap.yaml:134).
|
||||
# `/healthcheck` is ungated, so the check reaches application health rather
|
||||
# than mere reachability — it is the same endpoint the Gatus check uses
|
||||
# (Web Service 8 in ../gatus/configmap.yaml#gatus-endpoints).
|
||||
url: https://audiobooks.renner.uno/healthcheck
|
||||
uptimeRobotConfig:
|
||||
monitorType: http
|
||||
interval: 300
|
||||
alertContacts: 7880965_0_0-8026823_0_0
|
||||
|
||||
@@ -75,6 +75,7 @@ spec:
|
||||
name: ak-outpost-wordpress-gate
|
||||
port:
|
||||
number: 9000
|
||||
# anchor: gated-paths
|
||||
# Covers admin-ajax.php too. That is only safe because the gift
|
||||
# registry plugin -- the sole thing on this site that ever emitted
|
||||
# an ajaxurl -- is being dropped in the migration. Verified against
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# Renovate (REN-216): a CronJob that opens dependency-update pull requests on
|
||||
# git.renner.uno. It holds the renovate-bot token ExternalSecret, the runner
|
||||
# config and the CronJob. The namespace and its CiliumNetworkPolicy live in
|
||||
# infrastructure/ (see the `renovate` entry in namespaces.yaml).
|
||||
#
|
||||
# A dedicated Kustomization, and the same ExternalSecret health check as
|
||||
# uptime-monitor.yaml, for the same reasons written out there at length: a
|
||||
# missing or failed key must hold THIS Kustomization NotReady, and nothing
|
||||
# depends on this one, so the blast radius of that is Renovate alone.
|
||||
# `wait: true` without `healthCheckExprs` would go Ready with no Secret behind
|
||||
# it (REN-100).
|
||||
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||
kind: Kustomization
|
||||
metadata:
|
||||
name: renovate
|
||||
namespace: flux-system
|
||||
spec:
|
||||
interval: 10m
|
||||
retryInterval: 2m
|
||||
timeout: 5m
|
||||
sourceRef:
|
||||
kind: GitRepository
|
||||
name: flux-system
|
||||
path: ./kubernetes/apps/renovate
|
||||
prune: true
|
||||
wait: true
|
||||
healthCheckExprs:
|
||||
- apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
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 `renovate` namespace.
|
||||
- name: infrastructure
|
||||
# The `openbao` ClusterSecretStore, including the `renovate` entry in its
|
||||
# conditions.namespaces allowlist.
|
||||
- name: infrastructure-crds
|
||||
@@ -105,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
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# kubernetes-sigs/agent-sandbox controller (REN-285, plan REN-284). Provides the
|
||||
# `sandboxes.agents.x-k8s.io` CRD and reconciles each Sandbox into one pod. The
|
||||
# Paperclip Kubernetes plugin (backend `sandbox-cr`) uses it to run each agent
|
||||
# run in its own pod in namespace `paperclip-renner`.
|
||||
#
|
||||
# Upstream publishes the Helm chart only inside the git repo (./helm, chart
|
||||
# version frozen at 0.1.0), not in a chart repository. So the source is a
|
||||
# GitRepository pinned to a release tag, and `reconcileStrategy: Revision`
|
||||
# makes a tag bump re-render the chart even though its version does not move.
|
||||
#
|
||||
# TWO PINS, ONE RELEASE: the git tag below and `image.tag` in the values. The
|
||||
# chart requires the tag and does not derive it from the git ref. Renovate
|
||||
# tracks both (flux manager: GitRepository tag, HelmRelease values image) and
|
||||
# the `agent-sandbox` packageRule in renovate.json groups them into one PR.
|
||||
#
|
||||
# Not a healthCheck of `infrastructure`: nothing in the cluster depends on this
|
||||
# at startup, so a failing controller must not hold up GitOps.
|
||||
apiVersion: source.toolkit.fluxcd.io/v1
|
||||
kind: GitRepository
|
||||
metadata:
|
||||
name: agent-sandbox
|
||||
namespace: agent-sandbox-system
|
||||
spec:
|
||||
interval: 24h
|
||||
url: https://github.com/kubernetes-sigs/agent-sandbox
|
||||
ref:
|
||||
tag: v1.0.4
|
||||
# Only the chart; the rest of the repo is not needed in the artifact.
|
||||
ignore: |
|
||||
/*
|
||||
!/helm/
|
||||
---
|
||||
apiVersion: helm.toolkit.fluxcd.io/v2
|
||||
kind: HelmRelease
|
||||
metadata:
|
||||
name: agent-sandbox
|
||||
namespace: agent-sandbox-system
|
||||
spec:
|
||||
interval: 30m
|
||||
chart:
|
||||
spec:
|
||||
chart: ./helm
|
||||
reconcileStrategy: Revision
|
||||
sourceRef:
|
||||
kind: GitRepository
|
||||
name: agent-sandbox
|
||||
namespace: agent-sandbox-system
|
||||
install:
|
||||
# The chart ships its CRDs in crds/, which plain Helm never upgrades.
|
||||
# CreateReplace keeps them in step with the controller on every tag bump.
|
||||
# It also installs the three extensions CRDs (sandboxclaims,
|
||||
# sandboxtemplates, sandboxwarmpools); they stay unused while
|
||||
# `controller.extensions` is false.
|
||||
crds: CreateReplace
|
||||
remediation:
|
||||
retries: 3
|
||||
upgrade:
|
||||
crds: CreateReplace
|
||||
remediation:
|
||||
retries: 3
|
||||
values:
|
||||
namespace:
|
||||
# Declared in infrastructure/namespaces with the PSS labels.
|
||||
create: false
|
||||
name: agent-sandbox-system
|
||||
image:
|
||||
repository: registry.k8s.io/agent-sandbox/agent-sandbox-controller
|
||||
# Keep equal to the GitRepository tag above.
|
||||
tag: v1.0.4@sha256:c241245d7ff0068784ec0423f35efeb77c70ab08592533ca5b0cd7c36f8b2020
|
||||
controller:
|
||||
leaderElect: true
|
||||
extensions: false
|
||||
resources:
|
||||
requests:
|
||||
cpu: 10m
|
||||
memory: 64Mi
|
||||
limits:
|
||||
memory: 256Mi
|
||||
podSecurityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 65532
|
||||
runAsGroup: 65532
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containerSecurityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
@@ -0,0 +1,4 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- helmrelease.yaml
|
||||
@@ -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
|
||||
|
||||
@@ -90,7 +90,7 @@ for image-policy to sort against:
|
||||
- `openthread/otbr` — only Ubuntu codenames (`focal`, `jammy`,
|
||||
`hirsute`) and `:latest`/`:test`/`:reference-device`; no semver
|
||||
release stream.
|
||||
- `ghcr.io/konairius/ical-proxy` — only `main`, `latest`, and
|
||||
- `gitea.renner.uno/konairius/ical-proxy` — only `main`, `latest`, and
|
||||
`main-<sha>` commit builds; no release tags.
|
||||
|
||||
The security-scan agent will continue to surface vuln findings on
|
||||
@@ -152,14 +152,38 @@ What exists today:
|
||||
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 does **not** exist yet: a standing per-object "this ImagePolicy is
|
||||
not Ready" alert. The Flux controllers at the versions pinned in
|
||||
`clusters/homelab/flux-system/gotk-components.yaml` no longer populate
|
||||
`gotk_reconcile_condition` — the metric is declared by their shared
|
||||
runtime library but never recorded — so per-object readiness has to come
|
||||
from kube-state-metrics custom-resource-state instead. That work, and
|
||||
the not-Ready alert built on it, is tracked separately; the same change
|
||||
covers a policy that is perfectly Ready while pinned to a tag its own
|
||||
filter has frozen (`tika` if upstream ships a 3.x), which no readiness
|
||||
signal would catch either.
|
||||
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.
|
||||
|
||||
@@ -31,6 +31,7 @@ resources:
|
||||
- external-secrets/
|
||||
- reloader/
|
||||
- admission-policies/
|
||||
- agent-sandbox/
|
||||
# The OpenBao ClusterSecretStore lives in post-crds/ (needs ESO's CRDs and
|
||||
# an initialized OpenBao; see kubernetes/MIGRATION-sops-openbao.md).
|
||||
# cnpg-barman-plugin/ lives in its own Flux Kustomization
|
||||
|
||||
@@ -260,3 +260,52 @@ apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: uptime-monitor
|
||||
---
|
||||
# Renovate (REN-216): a weekly CronJob that opens dependency-update pull
|
||||
# requests on git.renner.uno. Declared here, not in apps/renovate, for the same
|
||||
# ordering reason as `uptime-monitor` above: this namespace has a
|
||||
# CiliumNetworkPolicy (../network-policies/renovate-ns.yaml), and
|
||||
# infrastructure-crds applies that policy after `infrastructure` creates the
|
||||
# namespace.
|
||||
#
|
||||
# No pod-security labels: the job needs no privilege, so the cluster's default
|
||||
# `baseline` enforcement is enough.
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: renovate
|
||||
---
|
||||
# kubernetes-sigs/agent-sandbox controller (REN-285, plan REN-284). Declared
|
||||
# here for the same ordering reason as `uptime-monitor` above: it has a
|
||||
# CiliumNetworkPolicy (../network-policies/agent-sandbox-system-ns.yaml). The
|
||||
# chart's own namespace template is switched off (`namespace.create: false`).
|
||||
#
|
||||
# `restricted`: the controller is a plain API client; helmrelease.yaml sets the
|
||||
# securityContext that level requires.
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: agent-sandbox-system
|
||||
labels:
|
||||
pod-security.kubernetes.io/enforce: restricted
|
||||
pod-security.kubernetes.io/audit: restricted
|
||||
pod-security.kubernetes.io/warn: restricted
|
||||
---
|
||||
# One pod per Paperclip agent run (REN-284). The Paperclip Kubernetes plugin
|
||||
# creates Sandbox CRs, NetworkPolicies and Secrets here; the rights it has to do
|
||||
# that are in ../../apps/paperclip/sandbox-rbac.yaml. Declared here because that
|
||||
# file puts a RoleBinding in this namespace, and the paperclip Kustomization
|
||||
# runs after `infrastructure`.
|
||||
#
|
||||
# `restricted` is load-bearing, not cosmetic: the paperclip ServiceAccount can
|
||||
# create pods here but has no right to patch this Namespace, so these labels are
|
||||
# what keeps it from starting a privileged pod. Do not grant `patch`/`update` on
|
||||
# namespaces to that account.
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: paperclip-renner
|
||||
labels:
|
||||
pod-security.kubernetes.io/enforce: restricted
|
||||
pod-security.kubernetes.io/audit: restricted
|
||||
pod-security.kubernetes.io/warn: restricted
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# agent-sandbox controller (REN-285). The controller only talks to the
|
||||
# kube-apiserver: it watches Sandbox CRs and creates pods, services and PVCs.
|
||||
# Nothing calls it; there is no webhook while `controller.extensions` is false.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: default-deny-ingress
|
||||
namespace: agent-sandbox-system
|
||||
spec:
|
||||
endpointSelector: {}
|
||||
ingress:
|
||||
# kubelet liveness/readiness probes on :8081.
|
||||
- fromEntities:
|
||||
- host
|
||||
- remote-node
|
||||
egress:
|
||||
# host/remote-node: kube-apiserver — watches, pod create, leader-election
|
||||
# Lease. The client uses KUBERNETES_SERVICE_HOST (an IP), so no DNS rule.
|
||||
- toEntities:
|
||||
- host
|
||||
- remote-node
|
||||
- kube-apiserver
|
||||
@@ -30,4 +30,6 @@ resources:
|
||||
- ai-ns.yaml
|
||||
- navidrome-ns.yaml
|
||||
- uptime-monitor-ns.yaml
|
||||
- renovate-ns.yaml
|
||||
- agent-sandbox-system-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
|
||||
|
||||
@@ -6,6 +6,7 @@ metadata:
|
||||
spec:
|
||||
endpointSelector: {}
|
||||
ingress:
|
||||
# anchor: paperless-admit
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": paperless
|
||||
@@ -25,15 +26,18 @@ spec:
|
||||
# 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`.
|
||||
# (kubernetes/apps/monitoring-rules/probes-blackbox.yaml#paperless-app-health)
|
||||
# runs from `monitoring`.
|
||||
# Scoping them to specific pods and ports is tracked in REN-28.
|
||||
# anchor: database-admit
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": database
|
||||
# anchor: monitoring-admit
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": monitoring
|
||||
# anchor: host-admit
|
||||
- fromEntities:
|
||||
- host
|
||||
- remote-node
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
# Baseline policy for the `renovate` namespace (REN-216).
|
||||
#
|
||||
# The namespace holds one CronJob pod that serves nothing and only calls out.
|
||||
# It has no probes and no port, so it admits no ingress at all. `ingress: [{}]`
|
||||
# is the Cilium idiom for that: an empty rule selects no peer, and its presence
|
||||
# puts the endpoint into ingress default-deny. An absent `ingress` key would not.
|
||||
#
|
||||
# Egress, and why each rule is there:
|
||||
# * kube-dns — name resolution.
|
||||
# * Traefik in `ingress` — git.renner.uno (Gitea API, git over HTTPS) and the
|
||||
# gitea.renner.uno container registry, when the name resolves to the
|
||||
# in-cluster load balancer. Same rule as paperclip-ns.yaml.
|
||||
# * world:443 — the upstream registries and release-note sources Renovate
|
||||
# looks up (Docker Hub, ghcr.io, quay.io, Helm repositories, GitHub), and
|
||||
# git.renner.uno when it resolves to the public address.
|
||||
#
|
||||
# Deliberately no kube-apiserver egress: Renovate never talks to the Kubernetes
|
||||
# API. Its ServiceAccount has no token mounted and no Role. ESO, in the
|
||||
# external-secrets namespace, writes the token Secret; the pod reads it as an
|
||||
# env var. Deliberately no OpenBao egress either, for the same reason.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: default-deny
|
||||
namespace: renovate
|
||||
spec:
|
||||
endpointSelector: {}
|
||||
ingress:
|
||||
- {}
|
||||
egress:
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": kube-system
|
||||
k8s-app: kube-dns
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "53"
|
||||
protocol: UDP
|
||||
- port: "53"
|
||||
protocol: TCP
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": ingress
|
||||
app.kubernetes.io/name: traefik
|
||||
- toEntities:
|
||||
- world
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "443"
|
||||
protocol: TCP
|
||||
@@ -0,0 +1,21 @@
|
||||
# OpenBao policy: secret-exists-check
|
||||
#
|
||||
# Lets the Paperclip agents Argus and Atlas check whether a secret exists in the
|
||||
# `homelab` KV v2 mount (paths, versions, timestamps) without reading any value.
|
||||
# Metadata only: no access to homelab/data/*, no writes.
|
||||
#
|
||||
# Identity: userpass user `agent-secret-check`. Its password is a Paperclip
|
||||
# agent secret bound to Argus and Atlas.
|
||||
#
|
||||
# NOT applied by Flux. Apply ONCE with an admin token:
|
||||
#
|
||||
# bao policy write secret-exists-check kubernetes/infrastructure/openbao/policies/secret-exists-check.hcl
|
||||
# bao write auth/userpass/users/agent-secret-check \
|
||||
# password=<generated> token_policies=secret-exists-check \
|
||||
# token_ttl=1h token_max_ttl=4h
|
||||
#
|
||||
# Check: `bao kv metadata get homelab/<path>` succeeds, `bao kv get homelab/<path>` is denied.
|
||||
|
||||
path "homelab/metadata/*" {
|
||||
capabilities = ["read", "list"]
|
||||
}
|
||||
@@ -29,6 +29,7 @@ spec:
|
||||
- monitoring
|
||||
- paperclip
|
||||
- paperless
|
||||
- renovate # renovate-bot token (apps/renovate/externalsecret.yaml)
|
||||
- trmnl
|
||||
- uptime-monitor # uptime-robot-api-key (apps/uptime-monitor/externalsecret.yaml)
|
||||
- velero
|
||||
|
||||
@@ -88,12 +88,21 @@ spec:
|
||||
nodeAgent:
|
||||
privileged: false
|
||||
# Reference the node-agent BackupPVC config (node-agent-config.yaml).
|
||||
# Sets `readOnly: true` for ceph-block and ceph-filesystem source
|
||||
# storage classes so the data-mover skips the CSI clone and reads
|
||||
# snapshot contents directly. Saves ~2.5 h per multi-hundred-GiB
|
||||
# Sets `readOnly: true` for the CephFS source storage classes
|
||||
# (ceph-filesystem, ceph-filesystem-bulk) so the data-mover skips
|
||||
# the CSI clone and reads snapshot contents directly. Saves ~2.5 h per multi-hundred-GiB
|
||||
# CephFS Backup. Requires Velero >= 1.15 and ceph-csi >= 3.10.
|
||||
extraArgs:
|
||||
- --node-agent-configmap=node-agent-config
|
||||
# node-agent reads node-agent-config only at startup and the chart
|
||||
# does not hash it into the pod template. Bump this label whenever
|
||||
# node-agent-config.yaml changes so Flux rolls the node-agent pods
|
||||
# (and only them — top-level podAnnotations would also roll the
|
||||
# server). Rolling kills in-flight DataUploads: merge changes here
|
||||
# outside the backup window (schedules 03:15–09:15Z, immich upload
|
||||
# runs ~4 h).
|
||||
podLabels:
|
||||
renner.uno/node-agent-config: ren-177
|
||||
metrics:
|
||||
# Service stays (chart-rendered) so Prometheus has a target;
|
||||
# ServiceMonitor + nodeAgent PodMonitor are declared standalone under
|
||||
|
||||
@@ -11,6 +11,16 @@
|
||||
# skip the clone entirely. Requires ceph-csi >= 3.10 (cluster runs
|
||||
# 3.16.2) and Velero >= 1.15 (cluster runs 1.18.0).
|
||||
#
|
||||
# backupPVC is keyed by the SOURCE PVC's storage class name, so every
|
||||
# CephFS class needs its own entry. ceph-filesystem-bulk (added for the
|
||||
# immich / audiobookshelf bulk libraries) was missing and fell back to
|
||||
# the full clone: its data-mover pods sat Pending on "unbound immediate
|
||||
# PersistentVolumeClaims" for 4 and 22 min on their first nightly run
|
||||
# (REN-177), growing with the data. Its backup PVC uses
|
||||
# ceph-filesystem-bulk-snapshot-readonly (snapshot-readonly-
|
||||
# storageclass.yaml): explicit backingSnapshot, and reclaimPolicy Delete
|
||||
# instead of the source class's Retain.
|
||||
#
|
||||
# ceph-block (RBD) volumes intentionally use the default clone path
|
||||
# (no readOnly here). The previous approach used readOnly: true with
|
||||
# storageClass: ceph-block-snapshot-readonly (noload) to skip ext4
|
||||
@@ -38,6 +48,10 @@ data:
|
||||
"backupPVC": {
|
||||
"ceph-filesystem": {
|
||||
"readOnly": true
|
||||
},
|
||||
"ceph-filesystem-bulk": {
|
||||
"readOnly": true,
|
||||
"storageClass": "ceph-filesystem-bulk-snapshot-readonly"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,3 +37,39 @@ mountOptions:
|
||||
- noload
|
||||
reclaimPolicy: Delete
|
||||
allowVolumeExpansion: true
|
||||
---
|
||||
# Storage class used only by Velero's data-mover exposer pod for
|
||||
# ceph-filesystem-bulk source PVCs (node-agent-config.yaml,
|
||||
# backupPVC."ceph-filesystem-bulk".storageClass). Same cluster / fs /
|
||||
# secrets as ceph-filesystem-bulk, plus:
|
||||
#
|
||||
# - backingSnapshot: "true" — ceph-csi provisions a shallow ReadOnlyMany
|
||||
# volume that points at the CephFS snapshot instead of cloning the
|
||||
# subvolume. Binds in seconds regardless of data size (REN-177: the
|
||||
# clone path left the immich data mover Pending 22 min, and it grows
|
||||
# with the library). Stated explicitly rather than relying on the
|
||||
# ceph-csi default.
|
||||
# - reclaimPolicy: Delete — the backup PVC is thrown away after each
|
||||
# DataUpload; the source class's Retain would strand a PV per run.
|
||||
# - no volume expansion — shallow volumes are read-only snapshots.
|
||||
#
|
||||
# Workload PVCs keep using ceph-filesystem-bulk; nothing else may use
|
||||
# this class.
|
||||
apiVersion: storage.k8s.io/v1
|
||||
kind: StorageClass
|
||||
metadata:
|
||||
name: ceph-filesystem-bulk-snapshot-readonly
|
||||
provisioner: rook-ceph.cephfs.csi.ceph.com
|
||||
parameters:
|
||||
clusterID: rook-ceph
|
||||
fsName: cephfs
|
||||
pool: cephfs-bulk
|
||||
backingSnapshot: "true"
|
||||
csi.storage.k8s.io/provisioner-secret-name: rook-csi-cephfs-provisioner
|
||||
csi.storage.k8s.io/provisioner-secret-namespace: rook-ceph
|
||||
csi.storage.k8s.io/controller-expand-secret-name: rook-csi-cephfs-provisioner
|
||||
csi.storage.k8s.io/controller-expand-secret-namespace: rook-ceph
|
||||
csi.storage.k8s.io/node-stage-secret-name: rook-csi-cephfs-node
|
||||
csi.storage.k8s.io/node-stage-secret-namespace: rook-ceph
|
||||
reclaimPolicy: Delete
|
||||
allowVolumeExpansion: false
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"local>k.renner/renovate-config"
|
||||
],
|
||||
"enabledManagers": [
|
||||
"flux",
|
||||
"kubernetes",
|
||||
"woodpecker",
|
||||
"dockerfile"
|
||||
],
|
||||
"ignorePaths": [
|
||||
"kubernetes/clusters/homelab/flux-system/**"
|
||||
],
|
||||
"flux": {
|
||||
"managerFilePatterns": [
|
||||
"/^kubernetes/.+\\.ya?ml$/"
|
||||
]
|
||||
},
|
||||
"kubernetes": {
|
||||
"managerFilePatterns": [
|
||||
"/^kubernetes/.+\\.ya?ml$/"
|
||||
]
|
||||
},
|
||||
"packageRules": [
|
||||
{
|
||||
"description": "Flux image automation (kubernetes/infrastructure/image-automation) already bumps these images. Renovate must not open a second, competing update. Keep this list equal to the ImageRepository images there.",
|
||||
"matchDatasources": [
|
||||
"docker"
|
||||
],
|
||||
"matchPackageNames": [
|
||||
"apache/tika",
|
||||
"deluan/navidrome",
|
||||
"eclipse-mosquitto",
|
||||
"everythingsmarthome/everything-presence-mmwave-configurator",
|
||||
"ghcr.io/advplyr/audiobookshelf",
|
||||
"ghcr.io/cloudnative-pg/plugin-barman-cloud",
|
||||
"ghcr.io/cloudnative-pg/plugin-barman-cloud-sidecar",
|
||||
"ghcr.io/home-assistant/home-assistant",
|
||||
"ghcr.io/immich-app/immich-machine-learning",
|
||||
"ghcr.io/immich-app/immich-server",
|
||||
"ghcr.io/matter-js/python-matter-server",
|
||||
"ghcr.io/music-assistant/server",
|
||||
"ghcr.io/paperclipai/paperclip",
|
||||
"ghcr.io/paperless-ngx/paperless-ngx",
|
||||
"ghcr.io/usetrmnl/terminus",
|
||||
"ghcr.io/usetrmnl/trmnl-ha-amd64",
|
||||
"gitea.renner.uno/k.renner/shelly-manager",
|
||||
"gitea.renner.uno/k.renner/vsftpd",
|
||||
"gitea.renner.uno/konairius/wiki",
|
||||
"gotenberg/gotenberg",
|
||||
"lscr.io/linuxserver/lidarr",
|
||||
"netbirdio/netbird",
|
||||
"quay.io/ceph/ceph",
|
||||
"redis",
|
||||
"rhasspy/wyoming-openwakeword",
|
||||
"rhasspy/wyoming-speech-to-phrase",
|
||||
"rhasspy/wyoming-whisper",
|
||||
"shenxn/protonmail-bridge",
|
||||
"twinproduction/gatus",
|
||||
"valkey/valkey",
|
||||
"velero/velero-plugin-for-aws",
|
||||
"woodpeckerci/woodpecker-agent",
|
||||
"woodpeckerci/woodpecker-server"
|
||||
],
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"description": "homelab only: pinning a digest changes the pod spec and restarts the workload. Keep these out of the version-update PR, so a reviewer can merge them in a window, separately.",
|
||||
"matchUpdateTypes": [
|
||||
"pinDigest"
|
||||
],
|
||||
"groupName": "pin image digests",
|
||||
"groupSlug": "pin-image-digests"
|
||||
},
|
||||
{
|
||||
"description": "Cilium and Rook-Ceph: a minor version is a planned change with a window (maintainer duties). One PR per update, never in the weekly group.",
|
||||
"matchPackageNames": [
|
||||
"cilium",
|
||||
"rook-ceph",
|
||||
"rook-ceph-cluster"
|
||||
],
|
||||
"matchUpdateTypes": [
|
||||
"minor",
|
||||
"major"
|
||||
],
|
||||
"groupName": null
|
||||
},
|
||||
{
|
||||
"description": "agent-sandbox (REN-285): the chart comes from the git tag and the controller image is pinned separately in the HelmRelease values. Both must move together, in one PR.",
|
||||
"matchPackageNames": [
|
||||
"kubernetes-sigs/agent-sandbox",
|
||||
"registry.k8s.io/agent-sandbox/agent-sandbox-controller"
|
||||
],
|
||||
"groupName": "agent-sandbox",
|
||||
"groupSlug": "agent-sandbox"
|
||||
}
|
||||
]
|
||||
}
|
||||
Executable
+306
@@ -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"
|
||||
Executable
+264
@@ -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
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/bin/sh
|
||||
# Offline self-test for yaml-citations.sh: each case is a small fixture tree
|
||||
# that must FAIL (or, for happy-path, PASS). Run: sh scripts/ci/test-yaml-citations.sh
|
||||
set -eu
|
||||
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
SUT="$HERE/yaml-citations.sh"
|
||||
[ -f "$SUT" ] || { echo "FAIL: $SUT not found"; exit 1; }
|
||||
|
||||
TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP"' EXIT INT TERM
|
||||
fails=0
|
||||
|
||||
# new_tree NAME — a fixture with one anchored target the cases can cite.
|
||||
new_tree() {
|
||||
d="$TMP/$1"; mkdir -p "$d/k/a" "$d/k/b"
|
||||
printf 'kind: X\n# anchor: target-block\nspec: {}\n' > "$d/k/a/target.yaml"
|
||||
echo "$d"
|
||||
}
|
||||
|
||||
# expect CASE want(pass|fail) DIR
|
||||
expect() {
|
||||
if YAML_CITATIONS_FLOOR=1 sh "$SUT" "$3" > "$TMP/$1.out" 2>&1; then got=pass; else got=fail; fi
|
||||
if [ "$got" = "$2" ]; then echo "ok $1 ($got)"
|
||||
else echo "FAIL $1: wanted $2, got $got"; sed 's/^/ /' "$TMP/$1.out"; fails=1; fi
|
||||
}
|
||||
|
||||
d=$(new_tree happy-path)
|
||||
printf '# see target.yaml#target-block and ../a/target.yaml#target-block\nx: 1\n' > "$d/k/a/same-dir.yaml"
|
||||
printf '# root-relative: k/a/target.yaml#target-block\nx: 1\n' > "$d/k/b/root.yaml"
|
||||
printf 'url: "http://h/p.yaml#frag" # a # inside a value is not a comment\n' > "$d/k/b/url.yaml"
|
||||
expect happy-path pass "$d"
|
||||
|
||||
d=$(new_tree line-number)
|
||||
printf '# see ../a/target.yaml:2\nx: 1\n' > "$d/k/b/c.yaml"
|
||||
expect line-number fail "$d"
|
||||
|
||||
d=$(new_tree line-range-yml)
|
||||
printf '# see ../a/target.yml:2-3\nx: 1\n' > "$d/k/b/c.yml"
|
||||
expect line-range-yml fail "$d"
|
||||
|
||||
d=$(new_tree missing-file)
|
||||
printf '# see ../a/gone.yaml#target-block\nx: 1\n' > "$d/k/b/c.yaml"
|
||||
expect missing-file fail "$d"
|
||||
|
||||
d=$(new_tree missing-anchor)
|
||||
printf '# see ../a/target.yaml#no-such-block\nx: 1\n' > "$d/k/b/c.yaml"
|
||||
expect missing-anchor fail "$d"
|
||||
|
||||
d=$(new_tree duplicate-anchor)
|
||||
printf '# anchor: target-block\n' >> "$d/k/a/target.yaml"
|
||||
expect duplicate-anchor fail "$d"
|
||||
|
||||
d=$(new_tree malformed-anchor)
|
||||
printf '# anchor: Not_Kebab\nx: 1\n' > "$d/k/b/c.yaml"
|
||||
expect malformed-anchor fail "$d"
|
||||
|
||||
d="$TMP/empty-discovery"; mkdir -p "$d"
|
||||
if sh "$SUT" "$d" > "$TMP/empty.out" 2>&1; then echo "FAIL empty-discovery: passed with no files"; fails=1
|
||||
else echo "ok empty-discovery (fail)"; fi
|
||||
|
||||
exit $fails
|
||||
@@ -0,0 +1,110 @@
|
||||
#!/bin/sh
|
||||
# Guard the citation convention in YAML comments (REN-183, see CONTRIBUTING.md).
|
||||
#
|
||||
# Run: sh scripts/ci/yaml-citations.sh [repo-root]
|
||||
# Needs: sh, find, awk, grep (BusyBox is fine). Offline.
|
||||
# Tests: scripts/ci/test-yaml-citations.sh
|
||||
#
|
||||
# A comment cites another block as `path.yaml#name`, and the cited block
|
||||
# carries a marker line `# anchor: name`. `path` is relative to the citing
|
||||
# file's directory or to the repo root. This fails when:
|
||||
#
|
||||
# * a comment cites by line number (`foo.yaml:12`). Line numbers go stale on
|
||||
# the first unrelated edit above them, silently; three drift fixes were
|
||||
# merged before this guard existed (REN-181).
|
||||
# * a `path#name` citation names a file or an anchor that does not exist.
|
||||
# * an anchor is not kebab-case, or is declared twice in one file.
|
||||
#
|
||||
# Existence only. It does not check that the anchored block still says what the
|
||||
# citing comment claims — that stays a reviewer's job, and it is why this guard
|
||||
# has no false positives from ordinary edits.
|
||||
set -eu
|
||||
|
||||
ROOT="${1:-.}"
|
||||
cd "$ROOT"
|
||||
|
||||
# Same loose-floor reasoning as ci-manifests-test.yml: catch discovery that
|
||||
# silently returns nothing, not normal churn. ~650 YAML files exist today.
|
||||
FLOOR="${YAML_CITATIONS_FLOOR:-50}"
|
||||
|
||||
TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP"' EXIT INT TERM
|
||||
|
||||
# Not `find -printf` (BusyBox lacks it).
|
||||
find . -path ./.git -prune -o -type f \( -name '*.yaml' -o -name '*.yml' \) -print \
|
||||
| sed 's|^\./||' | sort > "$TMP/files"
|
||||
count=$(grep -c . "$TMP/files" || true)
|
||||
if [ "$count" -lt "$FLOOR" ]; then
|
||||
echo "FAIL: found only $count YAML files; floor is $FLOOR. Discovery is broken."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# One pass over every file. A YAML comment starts at a `#` that begins the line
|
||||
# or follows whitespace, so a `#` inside a URL or a quoted value is not one.
|
||||
# Emits tab-separated records:
|
||||
# L file line text a line-number citation
|
||||
# C file line path name a path#name citation
|
||||
# A file line name an anchor declaration
|
||||
# B file line text a malformed anchor declaration
|
||||
while IFS= read -r f; do
|
||||
awk -v f="$f" '
|
||||
{
|
||||
if (!match($0, /(^|[ \t])#/)) next
|
||||
c = substr($0, RSTART)
|
||||
if (c ~ /^[ \t]*# anchor:/) {
|
||||
if (c ~ /^[ \t]*# anchor: [a-z0-9]+(-[a-z0-9]+)*[ \t]*$/) {
|
||||
n = c; sub(/^[ \t]*# anchor: /, "", n); sub(/[ \t]*$/, "", n)
|
||||
print "A\t" f "\t" NR "\t" n
|
||||
} else {
|
||||
print "B\t" f "\t" NR "\t" c
|
||||
}
|
||||
next
|
||||
}
|
||||
if (c ~ /\.ya?ml:[0-9]/) print "L\t" f "\t" NR "\t" c
|
||||
while (match(c, /[A-Za-z0-9_.\/-]+\.ya?ml#[A-Za-z0-9_-]+/)) {
|
||||
m = substr(c, RSTART, RLENGTH)
|
||||
p = m; sub(/#.*/, "", p)
|
||||
n = m; sub(/.*#/, "", n)
|
||||
print "C\t" f "\t" NR "\t" p "\t" n
|
||||
c = substr(c, RSTART + RLENGTH)
|
||||
}
|
||||
}' "$f"
|
||||
done < "$TMP/files" > "$TMP/records"
|
||||
|
||||
fail=0
|
||||
while IFS="$(printf '\t')" read -r kind f line a b; do
|
||||
case "$kind" in
|
||||
L)
|
||||
echo "FAIL $f:$line cites by line number; use path.yaml#anchor instead"
|
||||
echo " $a"
|
||||
fail=1 ;;
|
||||
B)
|
||||
echo "FAIL $f:$line malformed anchor; want '# anchor: kebab-case-name'"
|
||||
fail=1 ;;
|
||||
C)
|
||||
dir=$(dirname "$f")
|
||||
if [ -f "$dir/$a" ]; then target="$dir/$a"
|
||||
elif [ -f "$a" ]; then target="$a"
|
||||
else
|
||||
echo "FAIL $f:$line cites $a#$b, but no such file (tried $dir/$a and ./$a)"
|
||||
fail=1; continue
|
||||
fi
|
||||
if ! grep -qE "^[[:space:]]*# anchor: $b[[:space:]]*\$" "$target"; then
|
||||
echo "FAIL $f:$line cites $a#$b, but $target has no '# anchor: $b'"
|
||||
fail=1
|
||||
fi ;;
|
||||
esac
|
||||
done < "$TMP/records"
|
||||
|
||||
# Duplicate anchors in one file make a citation ambiguous.
|
||||
awk -F '\t' '$1 == "A" { k = $2 "\t" $4; if (k in seen) print $2 ":" $3 " duplicate anchor " $4 " (first at line " seen[k] ")"; else seen[k] = $3 }' \
|
||||
"$TMP/records" > "$TMP/dups"
|
||||
if [ -s "$TMP/dups" ]; then
|
||||
sed 's/^/FAIL /' "$TMP/dups"
|
||||
fail=1
|
||||
fi
|
||||
|
||||
anchors=$(grep -c '^A' "$TMP/records" || true)
|
||||
cites=$(grep -c '^C' "$TMP/records" || true)
|
||||
[ "$fail" -eq 0 ] && echo "ok $count YAML files, $anchors anchors, $cites path#anchor citations, 0 line-number citations"
|
||||
exit $fail
|
||||
Reference in New Issue
Block a user