Twelve `<app>-down` SLI rules were STRUCTURALLY INERT. Each read `up{job="<app>"} == 0`,
and the ServiceMonitor rendered beside it named `endpoints[].port: metrics` — a port NO
rendered app Service defined. Every monitor discovered ZERO targets, so no `up{job="<app>"}`
series existed and the rule could not fire. Verified on prod before this change:
`count by (job) (up)` listed 24 jobs and not one of the twelve appeared; `up{job="plex"}`
and `up{job="gitea"}` both returned nothing. `PrometheusTargetsMissing` could not see it
either — a target that was never discovered has no `up` series to be 0.
THE PORT NAME WAS NEVER THE MISSING PIECE. Ground truth per app: Plex, Pi-hole, Jackett,
qBittorrent, Sonarr, Radarr, Seerr, Semaphore and Nextcloud export NO Prometheus metrics
without a separate exporter sidecar; Gitea can but only with app.ini metrics enabled;
Keycloak does, on the operator's management port, already scraped by
platform/keycloak/servicemonitor.yaml; vm-demo is a KubeVirt VM with no Service at all, so
its monitor selected nothing whatsoever. `metrics: true` was asserting a fact none of them
had. There was also a THIRD layer of inertness nobody had hit yet: every app namespace ships
default-deny with `ingress: []` and the renderer emitted no monitoring allow, so even a
correct port would have been dropped by policy — while renderer-contract.md claimed the
baseline carried such an allow.
FIXED IN THE GENERATOR, not in twelve rendered files:
* `slis: [up]` now renders a `<app>-down` alert against kube-state-metrics readiness of the
service's own workload object —
`kube_deployment_status_replicas_available{namespace,deployment} == 0 or absent(...)`,
`kube_statefulset_status_replicas_ready` for a StatefulSet, and
`absent(kubevirt_vmi_info{namespace,name,phase="running"})` for a VM. The same idiom
KeycloakDown has used since it was fixed. kube-state-metrics IS scraped, so this needs no
exporter and no new target. The `or absent(...)` half is load-bearing: the signal
DISAPPEARING now fires the alert instead of silencing it.
* schema 1.6.0 adds `observability.metricsEndpoint` — WHERE metrics are served. A
ServiceMonitor is rendered ONLY from it, and it renders all three halves of a working
scrape together: the named Service port, the monitor endpoint, and the
monitoring-namespace CiliumNetworkPolicy allow. No catalog entry declares one, so twelve
dead ServiceMonitors are gone. `metrics: true` keeps meaning logs-to-Loki plus
kube-state-metrics liveness.
* schema 1.6.0 also adds `observability.livenessWorkload`, and catalog/keycloak.yaml uses
it. Keycloak's server is a StatefulSet run by the operator; the default Deployment shape
would have made its `absent()` half fire FOREVER — the other failure mode this repo has
shipped.
* renderer selftest gains cases 9/9a/9b/9c: no metricsEndpoint => no monitor; the up SLI
must not read `up{job=...}`; endpoint port must exist on the Service; the metrics port
must NOT reach the Gateway allow; an unknown livenessWorkload.kind is REFUSED.
PROOF, both directions, for all twelve (apps/wave-{0,1,2}/tests/app-slis.test.yaml, run by
platform/monitoring/tests/run.sh over the RENDERED manifests):
* quiet on the healthy state; firing on no-ready-replica; firing when the series is ABSENT;
the VM asserted both gone and in a non-running phase; per-app isolation asserted, and
keycloak asserted un-maskable by its neighbours (keycloak-operator Deployment,
keycloak-db StatefulSet ready in the same namespace).
* mutation-checked: six mutations, each turning its suite red — reverting plex to
`up{job=...} == 0`; dropping seerr's `absent()` half; pointing keycloak at a Deployment;
capitalising the VMI phase to `Running`; widening keycloak's matcher to the namespace;
inverting plex's `== 0` to `!= 0`. Control run green.
* live on prod with `promtool query instant`: all ten deployed app Deployments export
`kube_deployment_status_replicas_available`,
`kube_statefulset_status_replicas_ready{namespace="keycloak",statefulset="keycloak"}` = 2,
`kubevirt_vmi_info` present with a lowercase `phase="running"`; and evaluating the twelve
SHIPPED expressions returns no series for eleven (quiet, not permanently firing). The
twelfth is vm-demo, which is not in apps/wave-0/kustomization.yaml and so is not deployed
— its rule is not applied either.
DELETION WAS NOT THE ANSWER, and the evidence is recorded rather than assumed:
kube-prometheus-stack's `kubernetes-apps` group IS live on prod (17 rules, KubePodNotReady /
KubeDeploymentReplicasMismatch among them), so app pods are not unmonitored — but that is a
generic warning keyed to a namespace, not the per-service CRITICAL SLI R-OBS-01 declares.
platform/monitoring/alerts.yaml carries no generic app-readiness rule at all.
GUARD SO IT CANNOT RECUR SILENTLY: new whole-repo L0 invariant
conformance/tests/l0-static/servicemonitor-ports-resolve. It walks the MONITORS, not the
alerts, so it needs no `up{job=...}` rule to reach one, and resolves each endpoint by SELECTOR
across the whole tree rather than by same-file adjacency. Fixing the alerts DELETED the only
thing that made the broken monitors visible to the sibling check, which is exactly why this
axis needed its own home. It declares `conformance.home.k3s/scope: whole-repo` and adds no
ci/l0.sh stage. Proven to FAIL on the pre-fix tree: 15 failures — eleven port disagreements,
vm-demo's monitor matching no Service at all, and the three generator/contract assertions —
and to pass after. Its resolver is additionally proven on in-check fixtures (agreeing pair
accepted, disagreeing pair rejected, target-less endpoint rejected), because after the fix
there are zero in-repo monitors left for it to judge and a guard whose only evidence is
"nothing failed" cannot be told apart from one that stopped working. It also asserts
r_servicemonitor does not HARDCODE a port name — the mechanism by which one edit gave twelve
services the same wrong one.
Also fixed, found on the way:
* platform/monitoring/tests/run.sh discovered the SUITE convention only inside
platform/monitoring/tests/ while documenting it as general — a suite added anywhere else
was never run and still looked like coverage. Now discovered tree-wide; a misplaced
*.test.yaml fails loudly instead of vanishing.
* KNOWN_UNWIRED_JOBS in alert-metrics-have-scrape-targets is now EMPTY, and the twelve
apps/* entries in alert-rules-unit-tested's UNTESTED_FILES are gone — both because the
gaps were CLOSED, not waived. Both registers are shrink-only and a stale entry fails.
* renderer-contract.md claimed the default-deny baseline carried a standing monitoring
scrape allow. It never did; the claim is corrected and the allow is now real, per declared
metrics endpoint and scoped to that one port.
Co-Authored-By: Claude Opus 5 <[email protected]>
268 lines
13 KiB
YAML
268 lines
13 KiB
YAML
# promtool rule-unit tests for the wave-0 per-app `<app>-down` SLI rules.
|
|
# Manifests under test: every PrometheusRule in apps/wave-0/ — gitea, jackett, pihole, plex,
|
|
# qbittorrent (container/Deployment) and vm-demo (KubeVirt VMI). The harness discovers this file by
|
|
# the SUITE convention (`<dir>/tests/*.test.yaml`) and flattens those RENDERED manifests, so these
|
|
# tests run against the exact expressions Flux applies, never a copy.
|
|
#
|
|
# Run with: platform/monitoring/tests/run.sh apps/wave-0/tests/app-slis.test.yaml
|
|
# (the merge gate runs the same harness via
|
|
# conformance/tests/l0-static/alert-rules-unit-tested)
|
|
#
|
|
# WHY THESE EXIST. Until 2026-09-13 every one of these rules read `up{job="<app>"} == 0` while the
|
|
# rendered ServiceMonitor named a Service port (`metrics`) that no rendered Service defined. The
|
|
# monitor discovered ZERO targets, so no `up{job="<app>"}` series existed and the rule could not
|
|
# fire — on prod, `count by (job) (up)` listed 24 jobs and not one of the twelve appeared.
|
|
# `PrometheusTargetsMissing` could not see it either: a target that was never discovered has no `up`
|
|
# series to be 0. The rules now read kube-state-metrics / kubevirt series that DO exist for these
|
|
# workloads (verified on prod), and every rule below is pinned in BOTH directions:
|
|
# * QUIET on the healthy state -> it is not permanently firing;
|
|
# * FIRING when the workload has no ready replica -> it is not inert;
|
|
# * FIRING when the signal is ABSENT -> the failure mode that produced this whole defect class
|
|
# (a rule that goes quiet because its series vanished) is itself alerted on.
|
|
#
|
|
# CLOCK/`for` CONVENTION: sample interval 1m, series 21 samples long, evaluated at 10m — comfortably
|
|
# past every rule's `for: 5m` and well inside Prometheus' 5m lookback.
|
|
rule_files:
|
|
- generated.rules.yaml
|
|
|
|
evaluation_interval: 1m
|
|
|
|
tests:
|
|
# ===========================================================================================
|
|
# 1. HEALTHY WAVE-0 (what prod looks like right now) -> every rule SILENT.
|
|
# This is the "not permanently firing" half. It is also the test that catches a mis-spelled
|
|
# label: get `deployment=` wrong and the `== 0` half matches nothing while the `absent()` half
|
|
# matches everything, so this test goes red instead of the rule going quietly wrong.
|
|
# ===========================================================================================
|
|
- interval: 1m
|
|
name: wave-0 healthy - every app has a ready replica, the demo VM runs - all quiet
|
|
input_series:
|
|
- series: 'kube_deployment_status_replicas_available{namespace="gitea", deployment="gitea"}'
|
|
values: '1+0x20'
|
|
- series: 'kube_deployment_status_replicas_available{namespace="jackett", deployment="jackett"}'
|
|
values: '1+0x20'
|
|
- series: 'kube_deployment_status_replicas_available{namespace="pihole", deployment="pihole"}'
|
|
values: '1+0x20'
|
|
- series: 'kube_deployment_status_replicas_available{namespace="plex", deployment="plex"}'
|
|
values: '1+0x20'
|
|
- series: 'kube_deployment_status_replicas_available{namespace="qbittorrent", deployment="qbittorrent"}'
|
|
values: '1+0x20'
|
|
- series: 'kubevirt_vmi_info{namespace="vm-demo", name="vm-demo", phase="running", node="k3s-prod-1"}'
|
|
values: '1+0x20'
|
|
alert_rule_test:
|
|
- eval_time: 10m
|
|
alertname: gitea-down
|
|
exp_alerts: []
|
|
- eval_time: 10m
|
|
alertname: jackett-down
|
|
exp_alerts: []
|
|
- eval_time: 10m
|
|
alertname: pihole-down
|
|
exp_alerts: []
|
|
- eval_time: 10m
|
|
alertname: plex-down
|
|
exp_alerts: []
|
|
- eval_time: 10m
|
|
alertname: qbittorrent-down
|
|
exp_alerts: []
|
|
- eval_time: 10m
|
|
alertname: vm-demo-down
|
|
exp_alerts: []
|
|
|
|
# ===========================================================================================
|
|
# 2. EACH APP DOWN IN TURN (0 available replicas) -> ONLY that app's rule fires.
|
|
# The per-app isolation matters: a rule that forgot its `namespace=`/`deployment=` matchers
|
|
# would light up every app at once and be useless for routing.
|
|
# ===========================================================================================
|
|
- interval: 1m
|
|
name: wave-0 plex has no available replica - only plex-down fires
|
|
input_series:
|
|
- series: 'kube_deployment_status_replicas_available{namespace="gitea", deployment="gitea"}'
|
|
values: '1+0x20'
|
|
- series: 'kube_deployment_status_replicas_available{namespace="jackett", deployment="jackett"}'
|
|
values: '1+0x20'
|
|
- series: 'kube_deployment_status_replicas_available{namespace="pihole", deployment="pihole"}'
|
|
values: '1+0x20'
|
|
- series: 'kube_deployment_status_replicas_available{namespace="plex", deployment="plex"}'
|
|
values: '0+0x20'
|
|
- series: 'kube_deployment_status_replicas_available{namespace="qbittorrent", deployment="qbittorrent"}'
|
|
values: '1+0x20'
|
|
- series: 'kubevirt_vmi_info{namespace="vm-demo", name="vm-demo", phase="running"}'
|
|
values: '1+0x20'
|
|
alert_rule_test:
|
|
- eval_time: 10m
|
|
alertname: plex-down
|
|
exp_alerts:
|
|
- exp_labels:
|
|
severity: critical
|
|
service: plex
|
|
namespace: plex
|
|
deployment: plex
|
|
exp_annotations:
|
|
summary: "plex is down"
|
|
description: "plex: its Deployment has no available/ready replica for 5m, or the signal is gone entirely (SLI: up, via kube_deployment_status_replicas_available)."
|
|
- eval_time: 10m
|
|
alertname: gitea-down
|
|
exp_alerts: []
|
|
- eval_time: 10m
|
|
alertname: jackett-down
|
|
exp_alerts: []
|
|
- eval_time: 10m
|
|
alertname: pihole-down
|
|
exp_alerts: []
|
|
- eval_time: 10m
|
|
alertname: qbittorrent-down
|
|
exp_alerts: []
|
|
- eval_time: 10m
|
|
alertname: vm-demo-down
|
|
exp_alerts: []
|
|
# Before `for: 5m` has elapsed the rule must be PENDING, not firing — proof the 5m is real and
|
|
# a single scrape blip does not page anyone.
|
|
promql_expr_test:
|
|
- expr: 'kube_deployment_status_replicas_available{namespace="plex", deployment="plex"} == 0 or absent(kube_deployment_status_replicas_available{namespace="plex", deployment="plex"})'
|
|
eval_time: 2m
|
|
exp_samples:
|
|
- labels: 'kube_deployment_status_replicas_available{deployment="plex", namespace="plex"}'
|
|
value: 0
|
|
|
|
- interval: 1m
|
|
name: wave-0 gitea/jackett/pihole/qbittorrent each down - each own rule fires
|
|
input_series:
|
|
- series: 'kube_deployment_status_replicas_available{namespace="gitea", deployment="gitea"}'
|
|
values: '0+0x20'
|
|
- series: 'kube_deployment_status_replicas_available{namespace="jackett", deployment="jackett"}'
|
|
values: '0+0x20'
|
|
- series: 'kube_deployment_status_replicas_available{namespace="pihole", deployment="pihole"}'
|
|
values: '0+0x20'
|
|
- series: 'kube_deployment_status_replicas_available{namespace="plex", deployment="plex"}'
|
|
values: '1+0x20'
|
|
- series: 'kube_deployment_status_replicas_available{namespace="qbittorrent", deployment="qbittorrent"}'
|
|
values: '0+0x20'
|
|
- series: 'kubevirt_vmi_info{namespace="vm-demo", name="vm-demo", phase="running"}'
|
|
values: '1+0x20'
|
|
alert_rule_test:
|
|
- eval_time: 10m
|
|
alertname: gitea-down
|
|
exp_alerts:
|
|
- exp_labels:
|
|
severity: critical
|
|
service: gitea
|
|
namespace: gitea
|
|
deployment: gitea
|
|
exp_annotations:
|
|
summary: "gitea is down"
|
|
description: "gitea: its Deployment has no available/ready replica for 5m, or the signal is gone entirely (SLI: up, via kube_deployment_status_replicas_available)."
|
|
- eval_time: 10m
|
|
alertname: jackett-down
|
|
exp_alerts:
|
|
- exp_labels:
|
|
severity: critical
|
|
service: jackett
|
|
namespace: jackett
|
|
deployment: jackett
|
|
exp_annotations:
|
|
summary: "jackett is down"
|
|
description: "jackett: its Deployment has no available/ready replica for 5m, or the signal is gone entirely (SLI: up, via kube_deployment_status_replicas_available)."
|
|
- eval_time: 10m
|
|
alertname: pihole-down
|
|
exp_alerts:
|
|
- exp_labels:
|
|
severity: critical
|
|
service: pihole
|
|
namespace: pihole
|
|
deployment: pihole
|
|
exp_annotations:
|
|
summary: "pihole is down"
|
|
description: "pihole: its Deployment has no available/ready replica for 5m, or the signal is gone entirely (SLI: up, via kube_deployment_status_replicas_available)."
|
|
- eval_time: 10m
|
|
alertname: qbittorrent-down
|
|
exp_alerts:
|
|
- exp_labels:
|
|
severity: critical
|
|
service: qbittorrent
|
|
namespace: qbittorrent
|
|
deployment: qbittorrent
|
|
exp_annotations:
|
|
summary: "qbittorrent is down"
|
|
description: "qbittorrent: its Deployment has no available/ready replica for 5m, or the signal is gone entirely (SLI: up, via kube_deployment_status_replicas_available)."
|
|
- eval_time: 10m
|
|
alertname: plex-down
|
|
exp_alerts: []
|
|
- eval_time: 10m
|
|
alertname: vm-demo-down
|
|
exp_alerts: []
|
|
|
|
# ===========================================================================================
|
|
# 3. THE SIGNAL VANISHES -> the rule FIRES. This is the defect class itself, asserted on.
|
|
# The old `up{job=...} == 0` rule had exactly this shape of hole and was silent for it. Here
|
|
# the Deployment series for plex is simply not in the input at all, and plex-down must still
|
|
# fire (via the `absent()` half), with the labels the selector's equality matchers carry.
|
|
# ===========================================================================================
|
|
- interval: 1m
|
|
name: wave-0 plex Deployment series ABSENT (never scraped / workload gone) - plex-down fires
|
|
input_series:
|
|
- series: 'kube_deployment_status_replicas_available{namespace="gitea", deployment="gitea"}'
|
|
values: '1+0x20'
|
|
- series: 'kubevirt_vmi_info{namespace="vm-demo", name="vm-demo", phase="running"}'
|
|
values: '1+0x20'
|
|
alert_rule_test:
|
|
- eval_time: 10m
|
|
alertname: plex-down
|
|
exp_alerts:
|
|
- exp_labels:
|
|
severity: critical
|
|
service: plex
|
|
namespace: plex
|
|
deployment: plex
|
|
exp_annotations:
|
|
summary: "plex is down"
|
|
description: "plex: its Deployment has no available/ready replica for 5m, or the signal is gone entirely (SLI: up, via kube_deployment_status_replicas_available)."
|
|
- eval_time: 10m
|
|
alertname: gitea-down
|
|
exp_alerts: []
|
|
|
|
# ===========================================================================================
|
|
# 4. THE VM: a VMI carries no replica count, so liveness is the EXISTENCE of a running-phase
|
|
# series. Two ways to be down, both asserted: the VMI is gone entirely, and the VMI exists in
|
|
# some OTHER phase. The second is the one a naive `kubevirt_vmi_info == 1` would miss, and it
|
|
# is why the phase value is matched — LOWERCASE `running`, the value KubeVirt actually writes
|
|
# (a capital-R matcher selects nothing and would be just as inert).
|
|
# ===========================================================================================
|
|
- interval: 1m
|
|
name: wave-0 demo VM has no running-phase VMI - vm-demo-down fires
|
|
input_series:
|
|
- series: 'kube_deployment_status_replicas_available{namespace="gitea", deployment="gitea"}'
|
|
values: '1+0x20'
|
|
alert_rule_test:
|
|
- eval_time: 10m
|
|
alertname: vm-demo-down
|
|
exp_alerts:
|
|
- exp_labels:
|
|
severity: critical
|
|
service: vm-demo
|
|
namespace: vm-demo
|
|
name: vm-demo
|
|
phase: running
|
|
exp_annotations:
|
|
summary: "vm-demo is down"
|
|
description: "vm-demo: no KubeVirt VMI in phase running for 5m (SLI: up, via kubevirt_vmi_info)."
|
|
|
|
- interval: 1m
|
|
name: wave-0 demo VM exists but is Failed, not running - vm-demo-down still fires
|
|
input_series:
|
|
- series: 'kubevirt_vmi_info{namespace="vm-demo", name="vm-demo", phase="failed"}'
|
|
values: '1+0x20'
|
|
alert_rule_test:
|
|
- eval_time: 10m
|
|
alertname: vm-demo-down
|
|
exp_alerts:
|
|
- exp_labels:
|
|
severity: critical
|
|
service: vm-demo
|
|
namespace: vm-demo
|
|
name: vm-demo
|
|
phase: running
|
|
exp_annotations:
|
|
summary: "vm-demo is down"
|
|
description: "vm-demo: no KubeVirt VMI in phase running for 5m (SLI: up, via kubevirt_vmi_info)."
|