HOMELAB-2479: absolute free-bytes floors for the three factory volumes (+ promtool proof) #867
No reviewers
Labels
No labels
operator-review
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Homelab/infra-core!867
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "plane/HOMELAB-2479-volume-floor"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Absolute free-bytes cover for the three factory volumes (HOMELAB-2479)
Prepares the ruleset in
working-files/infra-stability/orch15/volume-floor-rules.mdas a PR, witha promtool test that proves each floor fires. Nothing is applied by this PR; the rules reach the
cluster through the
monitoring-rulesArgoCD app once merged.Files
core/manifests/monitoring/rules/volume-floor-alerts.yamlvolume-floor.rules, same labels/annotations conventions andld_*disposition contract as the neighbouring filescore/manifests/monitoring/rule-tests/volume-floor.test.yamlcore/manifests/monitoring/rules/infra-alerts.yamlPersistentVolumeCritical(> 90 %) scoped off exactly three PVCs;PersistentVolumeFillingUp(> 75 %) and every other PVC untouchedThe scoping is a single
exprchange on the one rule being scoped (plus itsld_note), not a rewriteof the file: it is separable if the reviewer prefers it in its own PR, but leaving it out ships two
contradicting instruments on the same volume (a
severity: info"> 90 % full" clause alongside aseverity: criticalbyte floor).The rules
forforgejo/gitea-shared-storageForgejoDataVolumeFloorLowavailable_bytes{…} < 4e9forgejo/gitea-shared-storageForgejoDataVolumeFloorCriticalavailable_bytes{…} < 2e9omniroute/omniroute-dataOmniRouteDataVolumeHeadroomWarningavailable_bytes{…} < 6e9harbor/harbor-registryHarborRegistryVolumeProjectedFullpredict_linear(available_bytes{…}[6h], 96*3600) < 0harbor/harbor-registryHarborRegistryVolumeFloorLowavailable_bytes{…} < 5e9harbor/harbor-registryHarborRegistryVolumeFloorCriticalavailable_bytes{…} < 3e9OmniRouteDataVolumeLowHeadroom(3e9, critical,omniroute-alerts.yaml:87-101) is kept unchangedand not duplicated; this PR adds the 6 GiB warning leg above it.
ROUTING — read this before judging the severities
The three
criticallegs route to the same receiver as every other critical:alert-intake(matcher-less,
continue: true) and thenpi-alert-receiver→10.10.10.5:8089→ Signal.Today that path ends at the Signal bridge — it does not reach a phone.
signal-clihas been maskedand unregistered since 2026-11-01, so a firing on these legs is delivered to a bridge that has no
registered destination. The routing is correct and these criticals start working the day the phone
path is fixed — this is a condition of the approval, and the same caveat is written into the file and
into two of the
ld_notes so a future reader cannot conclude the page path is healthy. Nothing in thisPR depends on the phone path existing; the floors also record, and the records are what the dashboards
read.
A
warningon these volumes matches no paging route at all (record-only,digests_emitted = 0), whichis why each volume also has a
criticalleg.Measured arithmetic, per volume
All figures MEASURED on 2026-11-06T02:59:44Z by the measuring worker through the prometheus apiserver
proxy (
kube-prometheus-stack-prometheus:9090, buildinfo3.4.0), fromkubelet_volume_stats_capacity_bytesandkubelet_volume_stats_available_bytes; rates fromquery_rangeover the windows named. GiB = 2^30.forgejo/gitea-shared-storage— ramp with a sawtooth, currently flatkubelet_volume_stats_capacity_bytes{namespace="forgejo",persistentvolumeclaim="gitea-shared-storage"}= 15 744 311 296 B = 14.663 GiB (
.status.capacity15 GiB; grows to 25 GiB only if prod #1141 lands).kubelet_volume_stats_available_bytes{…}= 1 452 260 000 B ≈ 1.353 GiB → 90.77 % used.5.260 GiBat 2026-11-03T15:20Z →1.353 GiBat 2026-11-06T02:59Z =3.907 GiB / 2.485 d = 1.572 GiB/day → rounding up, 1.6 GiB/day.
1.385 → 1.353 GiB= 0.032 GiB — FLAT, which is why the floor is the right instrumentand the rate is not.
4 GiB is 31× the largest single artifact dir (
131.3 MiB) so ordinary writes cannot trip it.predict_linearREJECTED on this volume. The series is a sawtooth: a window containing the+4.90 GiB release at 2026-11-03T14:10Z has a positive slope, so
predict_linear(...) < 0wassilent when the volume had 0.50 GiB left (44 min from empty); a window with no release (the last 10 h,
+0.005 GiB/h) projects zero fill forever while the volume sits at 90.77 %. A linear fit over a step is
noise in both directions.
predict_linearis used in this PR only where the series supports it —harbor, below.
omniroute/omniroute-data— flat with 1.2–2.15 GiB stepskubelet_volume_stats_capacity_bytes{namespace="omniroute",persistentvolumeclaim="omniroute-data"}= 10 478 252 032 B = 9.759 GiB (
.spec.resources.requests.storage = 20Giwhile.status.capacity.storage = 10Gi— the 10 → 20 GiB resize is still refused/pending).kubelet_volume_stats_available_bytes{…}≈ 4.238 GiB → 56.6 % used.4.554 GiB → 4.323 GiBover 14 h = 0.396 GiB/day. The actual threat is a step:−2.151 GiBbetween 18:59 and 20:59Z on 2026-11-05 (four.migration-snapshot-*copies of2 312 716 288 B), then+2.108 GiBbetween 00:59 and 02:59Z on 2026-11-06 (reclaim; process UNKNOWN).5 six-hourly repair copies = 30 h (6 ÷ 1.2 GiB). At the steady rate alone it would be 15.2 days — which
is exactly why the steady rate is not the design number. Existing critical 3 GiB = 2.5 copies = 15 h.
1.0–1.2 GiB whatever the capacity is. Re-expressed as a percentage it would move (at 20 GiB, "15 % free"
is still 3 GiB but "10 %" is 2 GiB and "20 %" is 4 GiB) — so it must not be re-expressed.
predict_linear: a 6 h window containing the+2.108 GiBreclaim projects forward growth and issilent at 2.09 GiB free; one containing the
−2.151 GiBburst pages falsely. The step already has aleading detector (
OmniRouteRepairBackupWritten,delta(...[1h]) > 858993459).harbor/harbor-registry— the one clean rampkubelet_volume_stats_capacity_bytes{namespace="harbor",persistentvolumeclaim="harbor-registry"}≈ 48.915 GiB;
kubelet_volume_stats_available_bytes{…}≈ 5.445 GiB → 88.9 % used(43.47 GiB / 48.92 GiB).
6.443 → 5.493 GiBover 18 h = 1.267 GiB/day = 0.0528 GiB/h; last 24 h6.443 → 5.445= 0.998 GiB/day. No sample in 24 h is above its predecessor — the series ismonotonic (a GC would be a step down; none was observed and none is scheduled inside a day).
predict_linearhorizon X = 96 h on a [6h] window: fires whenavailable < 0.0528 × 96 = 5.07 GiB, the same instant as the 5 GiB floor at today's rate, and earlierthan the floor the moment the rate rises (at 2.53 GiB/day it fires at 10.1 GiB, ~2 days sooner). 96 h is
16 periods of the 6 h window, so a 0.41 GiB blob burst cannot dominate the fit.
largest single blob push (0.41 GiB in 2 h), so a push alone cannot trip the paging leg.
Tests — MEASURED
core/manifests/monitoring/rule-tests/volume-floor.test.yaml, run with the repo's own harness(
rule-tests/promtool-test.sh, promtool v3.4.0, the cluster's version):Isolated run of this file alone (
promtool test rules): SUCCESS, exit 0.Node/
promtool check rulesoutput is above. What each case asserts (fired = the alert is present withthese labels and these annotations; quiet =
exp_alerts: []):kubelet_volume_stats_available_bytes)3500000000flatForgejoDataVolumeFloorLowFIRES (severity=warning),…Criticalquiet1452260000flatForgejoDataVolumeFloorCriticalFIRES (severity=critical),…Lowalso fires12450000000flat, capacity268435456005500000000flatOmniRouteDataVolumeHeadroomWarningFIRES6500000000flat5700000000−1000000/minHarborRegistryVolumeProjectedFullFIRES;…FloorLowstill quiet (5.28 GB free)5445000000flat 8 h4800000000flatHarborRegistryVolumeFloorLowFIRES,…Criticalquiet2500000000flatHarborRegistryVolumeFloorCriticalFIRES6000000000flatThe assertions are live, not tautological — verified by mutation, MEASURED:
4e9 → 1e6in the rule file → both forgejo positive cases FAIL (exit 1);scripts/monitoring/check_alert_contracts.py(the CI gate):--annotation-names-onlyok;diff-scoped against
origin/liveok — "every added or modified rule carries a completeld_*disposition contract" (7 changed rules: the 6 new +
PersistentVolumeCritical). Its unit suite:61 tests, OK.
legacy-rules.txtuntouched (it only shrinks; neither new file is listed).What this does NOT do
absent()companion: a detached PVC emits nokubelet_volume_statsat all, so these rules gosilent, not green. Noted in the file header as a follow-up.
for: 10m/15mmakes the floor a trend instrument. A 9 GiB write inside onescrape gap can still fill a volume before any of these fire.
kubectlwrite. ThePersistentVolumeCriticalscoping takeseffect only when this merges and ArgoCD syncs.
Verdict of record —
ld-reviewer— head34b1cea98a54185f3c0dd6df948c2455e95c1728: APPROVEmodel: omniroute/deepseek-flash
session: orch15-volfloor-seat1
Class: monitoring rules (routine) — one verdict, no second reviewer required.
This seat did not write the change. Everything below is my own measurement: fresh checkout of the PR head, promtool v3.4.0 (revision 546b1d24, the cluster's build), no cluster write, nothing applied, no ArgoCD action.
Rules vs the approved design — match, in bytes.
gitea-shared-storage warn 4e9 / crit 2e9, absolute bytes. omniroute-data warn 6e9 added above the existing 3e9 critical, which is not duplicated. harbor-registry predict_linear on [6h] with a 96 * 3600 horizon plus floors 5e9 / 3e9. All six expressions read
kubelet_volume_stats_available_bytesand compare raw bytes to a byte constant; the harbor projection compares bytes to 0. No ratio anywhere, no unit mixing inside an expression.for:is 15m/10m/30m as designed.severity:critical on the two edges and the harbor top, warning elsewhere. Rule selection labelsrelease: kube-prometheus-stackandrole: alert-rulespresent;namespace: monitoring.Each floor fires on a synthetic series — measured, not taken from the body.
Repo harness
core/manifests/monitoring/rule-tests/promtool-test.sh: exit 0, 39 SUCCESS blocks across all 15 rule files. The PR's 10 cases split out and run one at a time: 10/10 SUCCESS. My own boundary probes, written independently: 15/15 PASS — 3999999999, 1999999999, 5999999999, 4999999999 and 2999999999 each fire; 4e9, 2e9, 6e9, 5e9 and 3e9 exactly each stay quiet; the forgejo low leg is quiet at eval_time 14m, so the 15mfor:is real. Harbor projection: 5.7e9 and 5.0e9 declining at 1 MB/min fire; flat 5.445e9, rising 1 MB/min and 7.0e9 declining all stay quiet. Mutation on a copy of the extracted group, one per rule, six of six detected: 4e9 to 1e9, 2e9 to 1e6, 6e9 to 1e9, 5e9 to 1e9, 3e9 to 1e6 each break their positive case, and 96 * 3600 to 6 * 3600 breaks the projection case. The tests are load-bearing, including the horizon constant.Scoping of
PersistentVolumeCritical— exactly the intended scope.The whole diff of
infra-alerts.yamlis that rule'sexprplus itsld_note. The new expr addspersistentvolumeclaim!="gitea-shared-storage",!="omniroute-data"and!="harbor-registry"to both the numerator and the denominator vector;* 100 > 90,for: 10m,severity: infoand every annotation are byte-identical to before.PersistentVolumeFillingUpat line 13 andPersistentVolumeFillingFastat line 49 are untouched. Measured on synthetic series: gitea, harbor and omniroute each at 95 percent are silent toPersistentVolumeCritical, whilePersistentVolumeFillingUpstill fires on gitea at 95.0 percent; an unrelatedmonitoring/prometheus-dbat 95 percent still fires with the right labels, and at 80 percent stays quiet.Routing — same receiver as every other critical, and the PR says so unsoftened.
prodapps/monitoring-rules.yaml:15haspath: core/manifests/monitoring/ruleswith no recurse, so this new file is picked up andrule-tests/is not applied to the cluster; no Application change is needed, as designed. Route tree inprodvalues/kube-prometheus-stack/values.yamlat b346895c: route one isalert-intake, matcher-less withcontinue: true; theseverity = "critical"catch-all sits above the non-critical suppressions and sends topi-alert-receiver. The three critical legs carryseverity: criticaland need no other matcher, so they land where every other critical lands; the warning legs matchalert-intakeonly. No route, silence or inhibition is added, andinhibit_ruleskey onequal: [alertname, namespace], so the three differently named harbor legs cannot inhibit each other. The body and the file header both state plainly that the page path today ends at the Signal bridge (signal-climasked and unregistered); that agrees with the independentorch15/paging-last-hop.mdread (lastsignal_sent2026-11-01T19:18:45Z, 8090 refusing since). It is not softened and the critical legs are not downgraded.The body's arithmetic re-derives.
15744311296 B = 14.663 GiB; 1452260000 free of it = 90.77 percent used; 5.260 to 1.353 GiB over 2.485 d = 1.57 GiB/day; 10478252032 B = 9.759 GiB with 4.238 GiB free = 56.6 percent; harbor 6.443 to 5.493 over 18 h = 1.267 GiB/day = 0.0528 GiB/h, times 96 h = 5.07 GiB; 5 / 1.267 = 94.7 h, 3 / 1.267 = 56.8 h, 4 / 1.572 = 61.1 h, 2 / 1.572 = 30.5 h. Each figure matches
volume-floor-rules.mdand the source fields it names. Contract gate:check_alert_contracts.py --annotation-names-onlyok at 134 rules, and againstorigin/livechanged=7, legacy=27, "every added or modified rule carries a complete ld_ contract", rc 0 — matches the body.Nothing unrelated.
The PR has one commit and three files: the new rule file, the new test file, and the two-hunk edit to
infra-alerts.yaml. No other path touched.Two concerns, posted as concerns and not blockers.
One: thresholds are decimal bytes but the summaries and the runway arithmetic treat them as GiB. 4e9 is 3.725 GiB, so the real runway from the firing point is 56.9 h, not the stated 61.1 h; 2e9 is 28.4 h, not 30.5 h; 6e9 is 4.66 repair copies, not 5; 5e9 is 88.2 h, not 94.7 h; 3e9 is 52.9 h, not 56.8 h. That overstates runway by roughly 5 to 7 percent in every annotation. The values are exactly the approved design's values and the margins stay large, so this is a wording and precision follow-up (write "4 GB (3.7 GiB)", or use 4*2**30), not a defect in the cover; it is worth its own small change because the whole point here is that the number on the alert is the number that matters. Two: the body says these follow "the same labels/annotations conventions as the neighbouring files", but the descriptions are deliberately static rather than templated with
$labels/humanize $value— the file header discloses this and the body does not. The trade is right, since promtool asserts annotation equality; just say it in the body.Unverified by this seat: live cluster rule objects and receiver liveness (routing was verified from
prodGit, and the Signal-bridge end state from another worker's measurement, not mine); that the floors fire on the real series after merge (arithmetic, not observation, because nothing is applied); Forgejo CI for this head, which reported no runs for 867.blockers resolved