Files
home-network-k3s-impl/platform/monitoring/alerts.yaml
T
jakman85andClaude Opus 5 7057bbb711 fix(monitoring): alert when a Velero schedule has NEVER backed up, or is paused
VeleroNoRecentBackup could not fire for the failure mode that actually happened.
`(time() - velero_backup_last_successful_timestamp{schedule!=""}) > 24*3600` is the
right SHAPE — per-schedule, un-aggregated — but Velero only creates that series once
a backup for the schedule SUCCEEDS, and `time() - <absent series>` yields no series.
So it detects "backed up once, then stopped" and is structurally INERT on "never
backed up". Prod ran with `velero-daily` (the schedule carrying VOLUME DATA) paused
and never-successful: no volume backups at all, nothing alerted, and it was masked
because `velero-daily-resources-only` completed cleanly every 03:00, so every other
backup signal read green while only Kubernetes objects were protected.

A PromQL-only fix is impossible, verified rather than assumed: velero v1.18.1 exports
35 velero_* metric names and every one carrying a `schedule` label is created by a
backup ATTEMPT, not by the Schedule object. A throwaway `paused: true` Schedule on
prod produced ZERO velero_* series, and nothing exposes `spec.paused`. The fact is
not in the TSDB, so the Schedule CRs are exported via kube-state-metrics'
custom-resource-state collector — the path this repo already uses for
kube_node_labels — as kube_velero_schedule_{created,paused,protects_volume_data,info}.
Proven by running KSM v2.20.0 with this exact config against prod's API: the CRD
list/watch rule the chart adds is MANDATORY, not cosmetic — without it KSM emits the
CRS metrics as an empty set, silently.

Three rules, all keyed on PROPERTIES rather than schedule names:
  * VeleroScheduleNeverBackedUp (critical) — a Schedule older than 36h with no
    successful backup ever. The 36h grace comes from creationTimestamp so a newly
    added schedule is not paged before its first run is even due.
  * VeleroSchedulePaused (warning) — the direct root-cause signal; a paused backup
    schedule must never be silently indefinite.
  * VeleroNoVolumeDataBackup (critical) — THE ANTI-MASKING RULE. Fires unless some
    schedule with spec.template.defaultVolumesToFsBackup=true succeeded in 36h, so a
    healthy resources-only schedule can never make the platform look protected.
    Reading the property that DEFINES volume protection (not a name list) matches
    platform/velero/volume-policies.yaml and survives renames. `or vector(0)` is
    load-bearing: `count(<empty>) == 0` returns no series and would be inert —
    absence of the exporter is deliberately the alarm.

Adds the repo's first Prometheus rule-unit-test harness (platform/monitoring/tests/,
wired as the `prom-rules` gate stage). It flattens the COMMITTED PrometheusRule CRs —
never a copy — and pins every rule in the group both ways: quiet on today's healthy
prod state, firing on the reconstructed bad state, with `for:` durations asserted.
Two mutation checks confirm the tests are not vacuous: dropping `or vector(0)` and
breaking the `unless on (schedule)` join each turn the suite red. This is the tool
that makes both of this repo's recurring alert bugs — permanently-firing and
structurally inert — provable offline, which neither yamllint, Kyverno, nor a healthy
cluster can do.

Proof of the fix is by construction; the live metrics cannot show it any more, because
`velero-daily` was unpaused on 2026-09-12 and has since completed 2492/2492 items.
The throwaway probe Schedule and the probe KSM deployment were deleted and the
deletion confirmed; `velero-daily` remains unpaused.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-12 23:45:15 -05:00

691 lines
39 KiB
YAML

---
# Platform-health alert rules (docs/10, R-OBS-04). These complement the default node/kubernetes
# rules bundled by kube-prometheus-stack and add FIRST-CLASS alerts for the platform's own moving
# parts: nodes, Longhorn, Flux, cert-manager, networking/VPN, backups, identity, and the monitoring
# stack's own capacity ("don't let the monitor blind itself").
#
# Each rule maps to a docs/10 platform-health bullet and carries a severity (critical = page-worthy
# vs warning = degraded/capacity) plus enough annotation context (summary + runbook) to act.
#
# The `release: kube-prometheus-stack` label makes the operator's Prometheus select these rules
# (ruleSelector). Some expressions depend on exporters/ServiceMonitors owned by other workstreams
# (Flux, cert-manager, Longhorn, Velero, Keycloak, Cilium/Hubble ship their own metrics); the rules
# are authored to fire as soon as those series arrive. Metric names flagged TODO need confirming at
# bring-up against the deployed exporter.
#
# runbook_url points at a docs base; swap for the real runbook location when it exists.
# =====================================================================================
# Nodes
# =====================================================================================
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: platform-health-nodes
namespace: monitoring
labels:
release: kube-prometheus-stack
platform-health: "true"
spec:
groups:
- name: platform-health.nodes
rules:
- alert: NodeExporterDown
expr: up{job="node-exporter"} == 0
for: 5m
labels:
severity: critical
annotations:
summary: "Node exporter down on {{ $labels.instance }}"
description: "Prometheus cannot scrape node-exporter on {{ $labels.instance }} for 5m — the node may be down or unreachable."
runbook_url: "https://${BASE_DOMAIN}/runbooks/node-down"
- alert: NodeMemoryPressureHigh
expr: (1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) > 0.90
for: 10m
labels:
severity: warning
annotations:
summary: "Memory pressure high on {{ $labels.instance }}"
description: "Available memory below 10% for 10m on {{ $labels.instance }} — the ~22 GiB box is under pressure."
runbook_url: "https://${BASE_DOMAIN}/runbooks/node-memory"
- alert: NodeDiskFillingUp
expr: |
(node_filesystem_avail_bytes{fstype!~"tmpfs|overlay|squashfs"}
/ node_filesystem_size_bytes{fstype!~"tmpfs|overlay|squashfs"}) < 0.10
for: 15m
labels:
severity: warning
annotations:
summary: "Disk filling up on {{ $labels.instance }} ({{ $labels.mountpoint }})"
description: "Less than 10% free on {{ $labels.mountpoint }} of {{ $labels.instance }} for 15m."
runbook_url: "https://${BASE_DOMAIN}/runbooks/node-disk"
- alert: NodeCPUPressureHigh
# PSI "some" for CPU = fraction of wall-clock time at least one task was stalled waiting to
# run. <5% is healthy; sustained >15% means bursty workloads are queueing against the vCPU
# cap (measured 15-23% at 6 vCPU before the 8-vCPU resize). Averaged CPU% (kubectl top) hides
# this — PSI is the metric that actually surfaces the "spikes cause blips of delay" symptom.
expr: rate(node_pressure_cpu_waiting_seconds_total[5m]) > 0.15
for: 15m
labels:
severity: warning
annotations:
summary: "CPU pressure high on {{ $labels.instance }}"
description: "CPU PSI (some) above 15% for 15m on {{ $labels.instance }} — tasks are being delayed waiting for CPU (bursts exceeding the node's vCPU headroom). Add vCPUs or trim CPU-heavy workloads."
runbook_url: "https://${BASE_DOMAIN}/runbooks/node-cpu"
- alert: NodeCPULoadHigh
# 5-min load average per core. Sustained >1 means the run queue exceeds the core count (more
# runnable/blocked tasks than CPUs). Core count = number of idle-mode cpu series per node.
# Pair with NodeCPUPressureHigh to confirm the contention is CPU (vs I/O, which inflates load
# but not CPU-PSI).
expr: |
node_load5
/ count without (cpu, mode) (node_cpu_seconds_total{mode="idle"}) > 1
for: 15m
labels:
severity: warning
annotations:
summary: "Load per core high on {{ $labels.instance }}"
description: "5-min load average per core above 1.0 for 15m on {{ $labels.instance }} — the node's run queue exceeds its core count."
runbook_url: "https://${BASE_DOMAIN}/runbooks/node-cpu"
# --- I/O PRESSURE (added after the 2026-09-07 k3s-prod-1 NotReady incident) ---------
# THE METRIC THAT WOULD HAVE CALLED THE 2026-09-07 OUTAGE AND DID NOT EXIST.
# At 06:05:21Z k3s-prod-1 went NotReady ("Kubelet stopped posting node status") with the
# host up, pingable and SSH-able. Root cause was disk I/O starvation of the embedded etcd:
# slow fdatasync took=3.56s/6.71s/4.95s/3.40s (etcd expects 1s)
# apply request took too long took=8.30s (expects 100ms)
# etcdserver: too many requests
# `/proc/pressure/io` FULL avg300 read 11.36 on k3s-prod-1 at the time, against 0.75 on
# k3s-prod-2 (NVMe) and 1.29 on k3s-prod-3. The stack already alerted on CPU-PSI and
# memory but had NO I/O alert at all, so the first signal the operator got was a node
# already out of the cluster.
#
# WHY "full" AND NOT "some": node_pressure_io_stalled_seconds_total is PSI *full* — time
# in which EVERY runnable task was blocked on I/O, i.e. the machine did no useful work.
# `some` (node_pressure_io_waiting_seconds_total) is noisy on a storage node; `full` is
# the one that tracked the outage.
#
# THRESHOLDS ARE MEASURED, NOT GUESSED. rate(...[5m]) is a 0-1 fraction, so the incident's
# 11.36% is 0.1136 and the healthy steady state measured on the same node is ~0.01:
# warning 0.05 for 10m — 5x steady state, well below the outage, leaves room to act
# critical 0.10 for 5m — the level k3s-prod-1 actually sat at while etcd was stalling
# Deliberately NOT scoped to prod: dev shares this file and its k3s VMs are HDD-backed,
# where the same alert is if anything more likely to earn its keep.
- alert: NodeIOPressureHigh
expr: rate(node_pressure_io_stalled_seconds_total[5m]) > 0.05
for: 10m
labels:
severity: warning
annotations:
summary: "Disk I/O pressure high on {{ $labels.instance }}"
description: "I/O PSI (full) above 5% for 10m on {{ $labels.instance }} — every task on the node is spending real time blocked on disk. On a node whose etcd WAL shares a partition with Longhorn replicas this is the leading indicator of etcd fsync stalls. Check `cat /proc/pressure/io`, `iostat -x 1`, and whether Longhorn is rebuilding replicas."
runbook_url: "https://${BASE_DOMAIN}/runbooks/node-io"
- alert: NodeIOPressureCritical
# 0.10 is the level k3s-prod-1 held while etcd logged multi-second fdatasync and then
# dropped out of the cluster. At this point etcd is already at risk; do not wait 10m.
expr: rate(node_pressure_io_stalled_seconds_total[5m]) > 0.10
for: 5m
labels:
severity: critical
annotations:
summary: "Disk I/O starvation on {{ $labels.instance }}"
description: "I/O PSI (full) above 10% for 5m on {{ $labels.instance }} — the level at which k3s-prod-1 stalled etcd's WAL fsync and went NotReady on 2026-09-07 while the host stayed up. Expect etcd 'slow fdatasync' / 'apply request took too long' in `journalctl -u k3s`. Stop or throttle whatever is writing (Longhorn rebuild, backup, restore) before the node drops."
runbook_url: "https://${BASE_DOMAIN}/runbooks/node-io"
# =====================================================================================
# Storage — Longhorn
# =====================================================================================
---
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: platform-health-longhorn
namespace: monitoring
labels:
release: kube-prometheus-stack
platform-health: "true"
spec:
groups:
- name: platform-health.longhorn
rules:
# longhorn_volume_robustness: 0=unknown 1=healthy 2=degraded 3=faulted
- alert: LonghornVolumeDegraded
expr: longhorn_volume_robustness == 2
for: 10m
labels:
severity: warning
annotations:
summary: "Longhorn volume degraded: {{ $labels.volume }}"
description: "Volume {{ $labels.volume }} has been degraded (replica rebuild / missing replica) for 10m."
runbook_url: "https://${BASE_DOMAIN}/runbooks/longhorn-degraded"
- alert: LonghornVolumeFaulted
expr: longhorn_volume_robustness == 3
for: 1m
labels:
severity: critical
annotations:
summary: "Longhorn volume FAULTED: {{ $labels.volume }}"
description: "Volume {{ $labels.volume }} is faulted — data at risk / offline. Immediate attention required."
runbook_url: "https://${BASE_DOMAIN}/runbooks/longhorn-faulted"
- alert: LonghornVolumeCapacityHigh
expr: (longhorn_volume_actual_size_bytes / longhorn_volume_capacity_bytes) > 0.85
for: 30m
labels:
severity: warning
annotations:
summary: "Longhorn volume near capacity: {{ $labels.volume }}"
description: "Volume {{ $labels.volume }} is over 85% used for 30m."
runbook_url: "https://${BASE_DOMAIN}/runbooks/longhorn-capacity"
- alert: LonghornNodeStorageHigh
expr: (longhorn_node_storage_usage_bytes / longhorn_node_storage_capacity_bytes) > 0.85
for: 30m
labels:
severity: warning
annotations:
summary: "Longhorn node storage near capacity: {{ $labels.node }}"
description: "Longhorn node {{ $labels.node }} is over 85% of its storage capacity for 30m."
runbook_url: "https://${BASE_DOMAIN}/runbooks/longhorn-capacity"
# =====================================================================================
# GitOps — Flux
# =====================================================================================
---
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: platform-health-flux
namespace: monitoring
labels:
release: kube-prometheus-stack
platform-health: "true"
spec:
groups:
- name: platform-health.flux
rules:
- alert: FluxReconciliationFailure
expr: |
max by (namespace, name, kind) (
gotk_reconcile_condition{type="Ready", status="False"}
) == 1
for: 15m
labels:
severity: warning
annotations:
summary: "Flux resource not ready: {{ $labels.kind }}/{{ $labels.name }}"
description: "{{ $labels.kind }} {{ $labels.namespace }}/{{ $labels.name }} has failed to reconcile (Ready=False) for 15m — GitOps drift/failure (R-GITOPS-03)."
runbook_url: "https://${BASE_DOMAIN}/runbooks/flux-reconcile"
- alert: FluxResourceSuspended
expr: |
max by (namespace, name, kind) (gotk_suspend_status) == 1
for: 1h
labels:
severity: warning
annotations:
summary: "Flux resource suspended: {{ $labels.kind }}/{{ $labels.name }}"
description: "{{ $labels.kind }} {{ $labels.namespace }}/{{ $labels.name }} has been suspended for 1h — reconciliation/drift-correction is off."
runbook_url: "https://${BASE_DOMAIN}/runbooks/flux-suspended"
# =====================================================================================
# Certificates — cert-manager
# =====================================================================================
---
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: platform-health-certificates
namespace: monitoring
labels:
release: kube-prometheus-stack
platform-health: "true"
spec:
groups:
- name: platform-health.certificates
rules:
- alert: CertificateExpiringSoon
expr: (certmanager_certificate_expiration_timestamp_seconds - time()) < (14 * 24 * 3600)
for: 1h
labels:
severity: warning
annotations:
summary: "Certificate expiring soon: {{ $labels.name }}"
description: "Certificate {{ $labels.namespace }}/{{ $labels.name }} expires in under 14 days."
runbook_url: "https://${BASE_DOMAIN}/runbooks/cert-expiry"
- alert: CertificateExpiryImminent
expr: (certmanager_certificate_expiration_timestamp_seconds - time()) < (3 * 24 * 3600)
for: 1h
labels:
severity: critical
annotations:
summary: "Certificate expiry imminent: {{ $labels.name }}"
description: "Certificate {{ $labels.namespace }}/{{ $labels.name }} expires in under 3 days and has not renewed."
runbook_url: "https://${BASE_DOMAIN}/runbooks/cert-expiry"
- alert: CertificateNotReady
expr: certmanager_certificate_ready_status{condition="False"} == 1
for: 1h
labels:
severity: warning
annotations:
summary: "Certificate not ready / issuance failing: {{ $labels.name }}"
description: "Certificate {{ $labels.namespace }}/{{ $labels.name }} has been in a not-ready state for 1h (ACME issuance failure?)."
runbook_url: "https://${BASE_DOMAIN}/runbooks/cert-issuance"
# =====================================================================================
# Networking — VPN / egress-gateway / denied flows (Hubble)
# =====================================================================================
---
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: platform-health-networking
namespace: monitoring
labels:
release: kube-prometheus-stack
platform-health: "true"
spec:
groups:
- name: platform-health.networking
rules:
# VPN tunnel health (ADR-0015: node-level host OpenVPN tunnel on the egress-gateway node, steered
# by Cilium Egress Gateway — there is NO in-cluster gateway pod). OpenVPN creates/destroys the tun0
# device with the connection, so the tun0 netdev series exists iff the tunnel is up. node_network_up
# is unreliable here (tun0 operstate=unknown => 0), so key on the byte-counter's PRESENCE. absent()
# also fires if the gateway node / its node-exporter is down (the tun0 series goes stale), covering
# gateway-node-down as well. Metric confirmed live on the dev cluster 2026-08-24.
#
# FIXED 2026-09-06 — the bare `absent(...)` was evaluated CLUSTER-WIDE, so on a cluster with NO
# egress-gateway node it could never clear. Prod has no gateway node, and this alert fired
# continuously from 2026-09-05T21:23Z into `email-critical` — unbounded noise on the one channel
# that must stay trustworthy. The expression is now GATED on the cluster actually being supposed
# to have a tunnel, via two independent guards OR'd together:
#
# 1. A node carries `homelab.keithjackson.dev/egress-gateway=true` — the same label the
# CiliumEgressGatewayPolicy nodeSelector uses, so the alert is armed exactly when the
# steering datapath has a target. Requires the kube-state-metrics node-label allowlist
# enabled in kube-prometheus-stack.yaml (KSM exposes no custom labels by default). This is
# what makes a gateway-less cluster SELF-ARM the moment a gateway node is labelled — no
# manual re-enable to forget. The Node object survives a NotReady node, so this stays true
# while the gateway node is DOWN: gateway-node-down is still alerted.
# 2. `tun0` was observed at all in the last hour — holds the alert armed through a
# kube-state-metrics outage, and keeps dev's behaviour unchanged even before/independently
# of the KSM rollout.
#
# `and on()` joins on the empty label set deliberately: absent() carries `device="tun0"` while the
# guards carry no labels, so an unqualified `and` would never match. Both clauses verified live
# against dev and prod Prometheus (read-only) on 2026-09-06: empty on prod (cannot fire), empty on
# dev (tunnel up), and clause 2 already true on dev.
- alert: VPNTunnelDown
expr: |
absent(node_network_receive_bytes_total{device="tun0"})
and on()
(
(count(kube_node_labels{label_homelab_keithjackson_dev_egress_gateway="true"}) > 0)
or
(count(count_over_time(node_network_receive_bytes_total{device="tun0"}[1h])) > 0)
)
for: 5m
labels:
severity: critical
annotations:
summary: "Egress VPN tunnel down"
description: "The egress-gateway VPN tunnel is down (or its metric is absent) for 5m — outbound traffic could leak or stall (leak risk, docs/07)."
runbook_url: "https://${BASE_DOMAIN}/runbooks/vpn-down"
# (ADR-0015) The former `EgressGatewayUnhealthy` pod-readiness alert was REMOVED with the gluetun
# retirement: there is no in-cluster egress-gateway pod anymore (the tunnel is a host systemd
# service, steering is Cilium eBPF). Tunnel + gateway-node health is covered by `VPNTunnelDown`
# above (tun0 presence, which also goes absent if the gateway node is down); Cilium datapath
# health is covered by the Cilium platform alerts.
- alert: HubbleDeniedFlowSpike
# Cilium/Hubble denied (policy-drop) flows. Requires hubble metrics enabled (docs/03).
expr: sum(rate(hubble_drop_total{reason="POLICY_DENIED"}[5m])) > 5
for: 10m
labels:
severity: warning
annotations:
summary: "Spike in policy-denied network flows"
description: "Policy-denied flow rate is elevated for 10m — a NetworkPolicy regression or an intrusion probe (security audit, docs/07)."
runbook_url: "https://${BASE_DOMAIN}/runbooks/denied-flows"
# =====================================================================================
# Backups — Velero
# =====================================================================================
---
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: platform-health-backups
namespace: monitoring
labels:
release: kube-prometheus-stack
platform-health: "true"
spec:
groups:
- name: platform-health.backups
rules:
- alert: VeleroBackupFailure
expr: increase(velero_backup_failure_total[1h]) > 0
for: 5m
labels:
severity: critical
annotations:
summary: "Velero backup failed: schedule {{ $labels.schedule }}"
description: "One or more Velero backups failed in the last hour (R-STATE-03 — off-cluster DR at risk)."
runbook_url: "https://${BASE_DOMAIN}/runbooks/backup-failure"
- alert: VeleroBackupPartialFailure
expr: increase(velero_backup_partial_failure_total[1h]) > 0
for: 5m
labels:
severity: warning
annotations:
summary: "Velero backup partially failed"
description: "A Velero backup completed with partial failures in the last hour — some resources/volumes were skipped."
runbook_url: "https://${BASE_DOMAIN}/runbooks/backup-partial"
# Covers "backed up before, then STOPPED". It is deliberately per-schedule and
# un-aggregated, but it is blind to a schedule that has never succeeded ONCE — see the
# block below, which closes that hole rather than complicating this expression.
- alert: VeleroNoRecentBackup
expr: (time() - velero_backup_last_successful_timestamp{schedule!=""}) > (24 * 3600)
for: 1h
labels:
severity: critical
annotations:
summary: "No successful Velero backup in 24h: {{ $labels.schedule }}"
description: "Schedule {{ $labels.schedule }} has not produced a successful backup in over 24h (missed schedule)."
runbook_url: "https://${BASE_DOMAIN}/runbooks/backup-missed"
# --- THE "NEVER BACKED UP" BLIND SPOT (closed 2026-09-13) ------------------------------
# VeleroNoRecentBackup cannot fire for a schedule that has NEVER succeeded. Velero creates
# velero_backup_last_successful_timestamp{schedule="X"} only when a backup for X actually
# succeeds, so for a never-successful schedule the series is ABSENT and
# `time() - <absent series>` evaluates to no series at all. The rule detects "stopped
# backing up"; it is structurally inert on "never backed up".
#
# That is not hypothetical. Measured on prod 2026-09-12/13: `velero-daily` — the schedule
# that carries VOLUME DATA — sat `paused: true` with zero successful backups, so the cluster
# had NO volume backups and nothing alerted. It was doubly masked, because
# `velero-daily-resources-only` ran cleanly every 03:00: every other backup signal
# (failure_total, partial_failure_total, "last backup" in the UI) looked green while only
# Kubernetes OBJECTS were being protected.
#
# WHY NOT A PROMQL-ONLY FIX (verified, prod 2026-09-13, velero v1.18.1): the Velero metrics
# endpoint exports 35 velero_* metric names, and EVERY one carrying a `schedule` label is
# created by a backup ATTEMPT, not by the Schedule object. A throwaway Schedule created with
# `paused: true` produced ZERO velero_* series (`curl velero:8085/metrics | grep -c <name>`
# -> 0). There is no "schedule exists" metric and no `spec.paused` metric, so no expression
# over velero_* can see either failure mode. The Schedule CRs are therefore exported to
# Prometheus by kube-state-metrics' custom-resource-state collector as
# kube_velero_schedule_{created,paused,protects_volume_data,info} — configured in
# kube-prometheus-stack.yaml, which is the same repo-owned exporter already used for
# kube_node_labels, not a new sidecar.
#
# MASKING, and why the rules key on a PROPERTY rather than a schedule NAME:
# `kube_velero_schedule_protects_volume_data` is read straight from
# `spec.template.defaultVolumesToFsBackup`, which IS the definition of "this schedule backs
# up volume data". So VeleroNoVolumeDataBackup asks the operator's real question — "is
# anything at all protecting volume data right now?" — and a resources-only schedule can
# never answer it, whatever it is called. Same stance as platform/velero/volume-policies.yaml
# (test the property, not a name list a future object would slip past): renaming, adding or
# removing schedules cannot re-open the hole.
- alert: VeleroScheduleNeverBackedUp
expr: |
(time() - kube_velero_schedule_created) > (36 * 3600)
unless on (schedule) velero_backup_last_successful_timestamp{schedule!=""}
for: 1h
labels:
severity: critical
annotations:
summary: "Velero schedule has NEVER produced a successful backup: {{ $labels.schedule }}"
description: "Schedule {{ $labels.schedule }} has existed over 36h and Velero has never recorded a successful backup for it — the case VeleroNoRecentBackup is structurally blind to (prod ran with a paused velero-daily and NO volume backups this way). Check `kubectl -n velero get schedules.velero.io` for paused, and `backups.velero.io` (never the bare `backup` short name)."
runbook_url: "https://${BASE_DOMAIN}/runbooks/backup-missed"
# The direct root-cause signal. A paused backup schedule is a legitimate but strictly
# TEMPORARY state; it must never be silently indefinite. Warning, not critical, because the
# outcome alerts above escalate to critical on their own if the pause outlasts a day —
# this one exists to name the cause while it is still cheap to fix.
- alert: VeleroSchedulePaused
expr: kube_velero_schedule_paused == 1
for: 30m
labels:
severity: warning
annotations:
summary: "Velero schedule is PAUSED: {{ $labels.schedule }}"
description: "Schedule {{ $labels.schedule }} has spec.paused=true for over 30m and is creating no backups. Unpause it, or delete the Schedule if it is genuinely retired — an indefinitely paused schedule is a silent gap in R-STATE-03 off-cluster DR."
runbook_url: "https://${BASE_DOMAIN}/runbooks/backup-missed"
# THE ANTI-MASKING RULE. Fires unless at least one schedule that actually captures volume
# data has succeeded in the last 36h, so a healthy resources-only schedule can never make
# the platform look protected. `or vector(0)` is load-bearing: `count(<empty>) == 0` returns
# NO series and would itself be inert — exactly the bug being fixed — so the empty case is
# given an explicit 0. It also means this rule fires if kube-state-metrics stops exporting
# the Schedule CRs at all: ABSENCE OF THE SIGNAL IS THE ALARM, by design.
# Corollary, measured: evaluated against prod BEFORE the exporter exists this expression
# already returns `{} = 0`, i.e. it would fire. That is correct — there is genuinely no
# proof of volume-data protection without it — and the `for: 1h` is what keeps it from
# paging during the reconcile: the PrometheusRule and the kube-state-metrics values ship in
# the SAME Flux Kustomization (platform/monitoring), and the KSM rollout completes in
# minutes. If this ever pages an hour after a monitoring reconcile, the exporter did not
# come up — check `kubectl -n monitoring logs deploy/...-kube-state-metrics` for a
# `customresourcedefinitions ... is forbidden` RBAC error before suspecting Velero.
- alert: VeleroNoVolumeDataBackup
expr: |
(
count(
(kube_velero_schedule_protects_volume_data == 1)
and on (schedule)
((time() - velero_backup_last_successful_timestamp{schedule!=""}) < (36 * 3600))
)
or vector(0)
) == 0
for: 1h
labels:
severity: critical
annotations:
summary: "NO Velero schedule has backed up volume DATA in 36h"
description: "No Schedule with spec.template.defaultVolumesToFsBackup=true has succeeded in 36h, so PVC CONTENTS are unprotected off-cluster however healthy the resources-only schedule looks. A metadata-only backup restores objects, not data — R-STATE-03 is not satisfied."
runbook_url: "https://${BASE_DOMAIN}/runbooks/backup-missed"
# =====================================================================================
# Identity — Keycloak (SSO is a critical dependency — docs/15)
# =====================================================================================
---
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: platform-health-identity
namespace: monitoring
labels:
release: kube-prometheus-stack
platform-health: "true"
spec:
groups:
- name: platform-health.identity
rules:
- alert: KeycloakDown
# Scope to the Keycloak SERVER StatefulSet pods (keycloak-0, keycloak-1). The keycloak
# namespace also holds the operator (keycloak-operator-*) and DB (keycloak-db-*) pods, so an
# un-scoped max() would be masked by those being ready — this must reflect the IdP itself.
expr: |
max(kube_pod_status_ready{namespace="keycloak", pod=~"keycloak-[0-9]+", condition="true"}) == 0
or absent(kube_pod_status_ready{namespace="keycloak", pod=~"keycloak-[0-9]+"})
for: 5m
labels:
severity: critical
annotations:
summary: "Keycloak IdP down"
description: "No ready Keycloak pod for 5m — SSO is unavailable and dependent services cannot authenticate (docs/15)."
runbook_url: "https://${BASE_DOMAIN}/runbooks/keycloak-down"
- alert: KeycloakDatabaseDown
# Keycloak's PostgreSQL runs as a CloudNativePG Cluster `keycloak-db` (pods keycloak-db-N).
# DR-critical, Longhorn-backed. (CNPG also exports cnpg_* metrics via its PodMonitor.)
expr: |
max(kube_pod_status_ready{namespace="keycloak", pod=~"keycloak-db-.*", condition="true"}) == 0
or absent(kube_pod_status_ready{namespace="keycloak", pod=~"keycloak-db-.*"})
for: 5m
labels:
severity: critical
annotations:
summary: "Keycloak database unavailable"
description: "Keycloak's PostgreSQL is not ready for 5m — the IdP will fail (docs/15)."
runbook_url: "https://${BASE_DOMAIN}/runbooks/keycloak-db"
# --- The client reconciler (platform/keycloak/client-secret-reconciler.yaml) ---
# WHY THESE EXIST: on 2026-09-06 every OIDC login on prod failed because the realm import
# had stored the LITERAL `(env:...)` placeholder as all 12 client secrets — and every
# signal the cluster offered was GREEN (the import Job succeeded, the CR read Done=True,
# KeycloakDown/KeycloakDatabaseDown were quiet, the conformance test that asserts
# Done=True passed). The only observable was a human failing to log in. The CronJob is
# the healer; these two rules make its SILENCE observable, so the class cannot sit
# undetected again. They are the runtime counterpart of the L2 audit test.
#
# THE CronJob NOW RECONCILES TWO THINGS — client secrets, and the environment-derived
# client URLs (redirectUris, webOrigins, rootUrl, baseUrl, adminUrl) that a BASE_DOMAIN
# migration re-renders in git and the one-shot import never carries to the realm. The
# pair below is EXTENDED to say so rather than DUPLICATED: one CronJob gets one
# Failing/Stale pair, because a second pair keyed to the same job_name regex would fire
# twice for a single cause and train the operator to ignore both. The Job log names which
# pass failed, which client, and for a URL the exact before/after.
#
# UNVERIFIED-ON-CLUSTER at authoring time: no CronJob existed on either cluster yet, so
# these series had no samples to check against. `kube_job_status_failed` /
# `kube_job_status_succeeded` and the `job_name` label WERE confirmed present (the realm
# import Job exports them); the reconciler's own series appear with its first run.
# Confirm with: kube_job_status_succeeded{namespace="keycloak",
# job_name=~"keycloak-client-secret-reconciler-.*"}
- alert: KeycloakClientSecretReconcilerFailing
# A reconciler run failed. Either it found drift it could not repair, or it could not
# reach the admin API. Fires alongside KeycloakDown during an IdP outage — that is
# deliberate (the reconciler genuinely is not running), so treat KeycloakDown as the
# cause when both are lit. Self-clears when the failed Jobs age out (TTL 1h).
expr: |
max(kube_job_status_failed{namespace="keycloak", job_name=~"keycloak-client-secret-reconciler-.*"}) > 0
for: 15m
labels:
severity: warning
annotations:
summary: "Keycloak client reconciler failing"
description: >-
A keycloak-client-secret-reconciler Job has been failing for 15m — an OIDC client's
stored SECRET or its REDIRECT URIs may differ from git (an unresolved import
placeholder, a pre-migration hostname the one-shot realm import never updated, or
an unrepairable drift), and SSO logins for that client will fail — a bad
redirectUri fails with a redirect_uri mismatch at the IdP. Read the Job log: it
names the pass, the client and the exact URL before/after, and never prints a
secret.
runbook_url: "https://${BASE_DOMAIN}/runbooks/keycloak-client-secrets"
- alert: KeycloakClientSecretReconcilerStale
# No SUCCESSFUL run in scope. Catches the failure modes the rule above cannot see: the
# CronJob suspended, never applied by Flux, or unable to schedule a pod at all — i.e.
# the healer being quietly absent, which is exactly the "looks applied and isn't"
# failure this whole change exists to end.
expr: |
absent(kube_job_status_succeeded{namespace="keycloak", job_name=~"keycloak-client-secret-reconciler-.*"} == 1)
for: 2h
labels:
severity: warning
annotations:
summary: "Keycloak client reconciler has not succeeded"
description: >-
No successful keycloak-client-secret-reconciler run in 2h (schedule is every 15m).
The CronJob may be suspended, unapplied, or unable to schedule — client-secret AND
redirect-URI drift would go uncorrected and unnoticed (docs/15). This is the rule
that would catch the healer being quietly absent during a domain migration.
runbook_url: "https://${BASE_DOMAIN}/runbooks/keycloak-client-secrets"
# =====================================================================================
# The monitor's own capacity — don't let the monitor blind itself (docs/10)
# =====================================================================================
---
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: platform-health-monitoring-self
namespace: monitoring
labels:
release: kube-prometheus-stack
platform-health: "true"
spec:
groups:
- name: platform-health.monitoring-self
rules:
- alert: MonitoringVolumeFillingUp
expr: |
(kubelet_volume_stats_available_bytes{namespace="monitoring"}
/ kubelet_volume_stats_capacity_bytes{namespace="monitoring"}) < 0.15
for: 15m
labels:
severity: warning
annotations:
summary: "Monitoring PVC filling up: {{ $labels.persistentvolumeclaim }}"
description: "PVC {{ $labels.persistentvolumeclaim }} (Prometheus/Loki) is under 15% free — retention/PVC size may need raising before the monitor goes blind."
runbook_url: "https://${BASE_DOMAIN}/runbooks/monitoring-capacity"
- alert: PrometheusTargetsMissing
expr: |
(count(up == 0) / count(up)) > 0.10
for: 15m
labels:
severity: warning
annotations:
summary: "More than 10% of Prometheus targets are down"
description: "Over 10% of scrape targets have been down for 15m — a broad monitoring blind spot."
runbook_url: "https://${BASE_DOMAIN}/runbooks/targets-down"
# =====================================================================================
# Automated updates — Flux image-automation + hands-off update health (docs/11, ADR-0009)
# R-UPD-02 (a failed/rolled-back update alerts) + R-OBS. All Flux controllers (incl. the image
# reflector/automation + helm controllers) export the shared `gotk_reconcile_condition` /
# `gotk_suspend_status` series; these rules add UPDATE-SPECIFIC visibility on top of the generic
# platform-health.flux group so a silently-stalled or a rolled-back update is not missed.
# =====================================================================================
---
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: platform-health-automated-updates
namespace: monitoring
labels:
release: kube-prometheus-stack
platform-health: "true"
spec:
groups:
- name: platform-health.automated-updates
rules:
# The write-back engine (ADR-0009) is broken → hands-off image updates SILENTLY stop advancing.
- alert: ImageUpdateAutomationFailing
expr: |
max by (namespace, name) (
gotk_reconcile_condition{kind="ImageUpdateAutomation", type="Ready", status="False"}
) == 1
for: 30m
labels:
severity: warning
annotations:
summary: "Flux image-update-automation failing: {{ $labels.name }}"
description: "ImageUpdateAutomation {{ $labels.namespace }}/{{ $labels.name }} cannot reconcile (git write-back) for 30m — stable image advances have stopped (R-UPD-02). Check the git write credential."
runbook_url: "https://${BASE_DOMAIN}/runbooks/image-automation"
# An ImagePolicy/ImageRepository can't resolve the latest stable tag (registry rate-limit /
# auth / bad range) → that service stops receiving updates even though automation is healthy.
- alert: ImagePolicyStalled
expr: |
max by (namespace, name, kind) (
gotk_reconcile_condition{kind=~"ImagePolicy|ImageRepository", type="Ready", status="False"}
) == 1
for: 1h
labels:
severity: warning
annotations:
summary: "Image scan/policy stalled: {{ $labels.kind }}/{{ $labels.name }}"
description: "{{ $labels.kind }} {{ $labels.namespace }}/{{ $labels.name }} cannot resolve its stable tag for 1h (registry rate-limit / creds / range) — no longer receiving updates (R-UPD-01)."
runbook_url: "https://${BASE_DOMAIN}/runbooks/image-policy-stalled"
# A bad update was health-gated and AUTO-ROLLED-BACK (or is stuck mid-remediation). helm-controller
# holds Ready=False while a failed upgrade remediates/rolls back to the last-good revision
# (HelmRelease.spec.upgrade.remediation + .spec.rollback). This is the R-UPD-03 safety rail firing.
# TODO(bring-up): tighten with the `reason` label (e.g. UpgradeFailed / RolledBack) once confirmed
# against the deployed helm-controller metrics.
- alert: UpdateHealthGateFailed
expr: |
max by (namespace, name) (
gotk_reconcile_condition{kind="HelmRelease", type="Ready", status="False"}
) == 1
for: 10m
labels:
severity: critical
annotations:
summary: "Update health-gate failed / rolled back: {{ $labels.name }}"
description: "HelmRelease {{ $labels.namespace }}/{{ $labels.name }} failed its health-gated upgrade and is rolling back to the last-good revision (bad-update auto-rollback — R-UPD-03). Consider an `update.pin` escape hatch (docs/11)."
runbook_url: "https://${BASE_DOMAIN}/runbooks/update-rollback"