The go-live pack for playground
Goal
Assemble the artefacts you would actually hand an on-call rotation: a Prometheus rules file built only from metric names the operator really exports, a one-page alert-to-runbook-to-first-command map, and a DR drill record showing you restored playground and measured how far back you could reach. Then let a checker prove the thing that matters — that your rules stay silent while a reader soaks past three times maxLagSeconds.
Unit 6 — Backups, disaster recovery, and going live. The capstone.
The goal
Assemble the artefacts you would actually hand an on-call rotation: a Prometheus rules file built only from metric names the operator really exports, a one-page alert-to-runbook-to-first-command map, and a DR drill record showing you restored playground and measured how far back you could reach. Then let a checker prove the thing that matters — that your rules stay silent while a reader soaks past three times maxLagSeconds.
How this works
playground runs three sites: iad and pdx as primary-candidate, and reader with role: read-only. You have already backed it up, verified it, restored it in place and turned on encryption at rest. What is missing is the pack you hand to the rotation.
The pack lives in starter/pack/:
| File | What it is |
|---|---|
alerts.yml | Prometheus alerting rules for playground |
runbooks.yml | alert → runbook anchor → the one command typed first |
drill.json | the record of the DR drill you ran |
starter/golive.py checks all three and replays six metric fixtures from tests/fixtures/ through your rules. Run it:
python3 starter/golive.py
It runs as given and reports twelve problems. Fix them in order.
Your tasks
TODO A — pack/alerts.yml. BloodravenReplicationLagging currently reads every site. Chaos scenario 42 soaks the reader past three times maxLagSeconds with both replication threads still running, and asserts the group stays Ready, no failover fires, no cooldown is consumed, and only the reader endpoint sheds. That is the role model doing its job, not a fault. Exclude the read-only site by label matcher and keep the threshold at 30 — the value playground actually sets in spec.replication.maxLagSeconds — so a genuinely lagging primary-candidate still pages. The shipped CRD default is 300; a rule’s number tracks the group’s spec, never the default.
TODO B — pack/alerts.yml. BloodravenBackupStale reads bloodraven_backup_age_seconds. That metric does not exist. The shipped operator exports a last-success timestamp gauge, so an age has to be derived from it with time() -. Rewrite the expression against a name in SHIPPED_METRICS.
TODO C — pack/alerts.yml. Add BloodravenKeyringNotSealed. Sealed is the steady state; the metric is a one-hot gauge over the phase label, so alert when the sealed series reads 0.
TODO D — pack/runbooks.yml. Three alerts have no usable entry. Each needs an anchor of the form runbook.md#<slug> and a firstCommand starting with kubectl. The plugin has exactly seven subcommands — status, promote, reclone, backup, verify-backup, version, help — and it only writes resources the operator already reads, never talking to MySQL. kubectl bloodraven status playground is the sensible default.
TODO E — pack/drill.json. Fill proved, assumed, applicationSideAlertOwner and handoverNote. Use only these terms:
proved:artifact-loads,sanity-check-passed,restore-in-place-completed,reader-endpoint-returned,dns-record-updatedassumed:logical-equivalence-with-live-primary,application-traffic-cutover,cross-cluster-split-brain-detection,dns-propagation-time
A Succeeded verification proves the artifact loads and your scalar assertion held. It never proves logical equivalence with the live primary or an application-level rehearsal of traffic cutover, so both of those belong in assumed on every honest record. applicationSideAlertOwner is a named human: no shipped alert fires for “the application is still broken after a successful failover”, so somebody owns that one by name or nobody does. handoverNote is one line saying what this pack will not tell the rotation.
TODO F — starter/golive.py. Implement check_metric_allowlist(rules). Return sorted (alert, metric) tuples for every metric outside SHIPPED_METRICS and ALLOWED_FOREIGN_METRICS. promeval.metric_names_in(expr) pulls the names out for you.
TODO G — starter/golive.py. Implement check_runbook_coverage(rules, runbooks). Return sorted (alert, problem) tuples for every alert with no entry, an empty or malformed anchor, an empty firstCommand, or a firstCommand that does not start with kubectl. The wording of problem is yours; which alerts you flag is graded.
What the scaffolding is for
starter/promeval.py is a small PromQL-subset evaluator. It handles <selector> <cmp> <number>, absent(...), time() - <selector> <cmp> <number> and increase(<selector>[<window>]) <cmp> <number>, with =, !=, =~ and !~ matchers. Two simplifications you should know about: evaluation is instantaneous, so a rule’s for: is never simulated (it is still required on every paging rule, because a rule without one pages on a single bad scrape); and increase(m[15m]) reads a pre-computed series out of the fixture’s increases block rather than computing anything.
golive.py already does argument parsing, YAML and JSON loading, the coverage and drill checks, evaluation and report formatting. Only TODO F and TODO G are yours.
A fixture verdict grades Alert@site keys, not bare alert names, so an alert that fires for the wrong site is a MISMATCH.
Expected output
When the pack is finished:
[metrics] clean
[runbooks] clean
[coverage] clean
[drill] clean
[owner] application-side alerting owned by: <a named human>
fixture replay
----------------------------------------------
fixture candidate-lagging: 1 firing, expected 1 OK
fixture operator-down: 1 firing, expected 1 OK
fixture post-failover-divergence: 5 firing, expected 5 OK
fixture primary-lost: 2 firing, expected 2 OK
fixture reader-soak-3x: 0 firing, expected 0 OK
fixture split-brain-resolved: 2 firing, expected 2 OK
RESULT: READY
Exit code 0.
Rules
- Use only metric names the shipped operator exports.
SHIPPED_METRICSingolive.pyis the list; do not add to it from memory. A rule against a metric that does not exist is a rule that can never fire. - Do not edit anything under
tests/. The fixtures are the grading input. - Keep the
BloodravenReplicationLaggingthreshold at30, which is whatplaygroundsets. The backup-staleness and failover-window thresholds are SLOs you choose, not Bloodraven defaults. - Do not remove a rule to silence it. The reader exclusion has to be an exclusion.
BloodravenFailoverOccurredcarriesseverity: info. It says the operator finished, not that traffic recovered.- There is no
Failovercondition reason to match on. The failover row of the decision matrix emitsReason="Degraded".
Steps
-
1. Run the checker and read the twelve problems Run
python3 starter/golive.pybefore changing anything. It loads nine alert rules, eight runbook entries and the drill record, then replays six fixtures. Read the whole report: two checks are unimplemented, one required alert is missing, the drill record claims nothing and hands over nothing, and two fixtures mismatch. TheSPURIOUS BloodravenReplicationLagging@readerline underreader-soak-3xis the one this project is about. Done when:python3 starter/golive.pyexits 1 and prints a line startingRESULT: NOT READY. -
2. Implement the metric allowlist and the runbook coverage check (TODO F, TODO G) In
starter/golive.py, replace the tworeturn Nonestubs.check_metric_allowlist(rules)returns sorted(alert, metric)tuples for every metric outsideSHIPPED_METRICS | ALLOWED_FOREIGN_METRICS; usepromeval.metric_names_in(expr)to extract them.check_runbook_coverage(rules, runbooks)returns sorted(alert, problem)tuples for every alert with no entry, an anchor that is empty or does not matchANCHOR_RE, an emptyfirstCommand, or afirstCommandthat does not start withkubectl. Both return[]when clean. The report will now tell you the truth about the other files. Done when:python3 starter/golive.pyprints neither[metrics] not implemented (TODO F)nor[runbooks] not implemented (TODO G), and its[metrics]line namesBloodravenBackupStale -> bloodraven_backup_age_seconds. -
3. Rebuild BloodravenBackupStale on a real metric and add BloodravenKeyringNotSealed (TODO B, TODO C)
bloodraven_backup_age_secondsdoes not exist, so the rule can never fire — the exact failure mode the checker exists to catch. The operator exportsbloodraven_backup_last_success_timestamp_secondsper(group, profile); derive the age withtime() -and keep the 24-hour SLO. Then addBloodravenKeyringNotSealed:bloodraven_keyring_phaseis a one-hot gauge over thephaselabel andSealedis the steady state, so alert when the sealed series reads0. Any other phase means the site is running with a writable keyring or failed to escrow one. Done when:python3 starter/golive.pyprints[metrics] cleanand[coverage] clean, and thepost-failover-divergencefixture line reads5 firing, expected 5 OK. -
4. Make the lag alert ignore the reader on purpose (TODO A) This is the centrepiece. Add a site-label exclusion for the
read-onlysite toBloodravenReplicationLaggingand leave the threshold at30. Do not delete the rule and do not raise the threshold —pdxat 64 seconds behind is a genuine RPO drift on a promotable candidate and must still page. Remember what the threshold is and is not:maxLagSecondsdrives only theReplicationLaggingcondition. It is not a promotion gate, so a candidate past it is still promoted, because no writable site at all is almost always worse. Done when:python3 starter/golive.pyprints bothfixture reader-soak-3x: 0 firing, expected 0 OKandfixture candidate-lagging: 1 firing, expected 1 OK. -
5. Finish the alert-to-runbook-to-first-command map (TODO D) Three alerts have no usable entry:
BloodravenDivergentTransactionshas none,BloodravenPITRArchiveLagginghas an emptyfirstCommand, andBloodravenKeyringNotSealedis the alert you just added. Give each an anchor of the formrunbook.md#<slug>and one command starting withkubectl. Every command must be something a rotation member can run cold, which is why the plugin is safe to hand over: it only writes resources the operator already reads and never talks to MySQL. Done when:python3 starter/golive.pyprints[runbooks] clean. -
6. Write the drill record honestly (TODO E) Fill
proved,assumed,applicationSideAlertOwnerandhandoverNoteinpack/drill.jsonusing only the vocabulary in the instructions. Your verification returnedSucceededand your in-place restore reachedSucceededthrough an RFC 3339 confirm token, so the artifact loads and the sanity check held. Neither proves logical equivalence with the live primary, and neither rehearses an application traffic cutover — both belong inassumed. Name a human for the application-side alert: Bloodraven cannot see your pool, your driver’srejectReadOnlyhandling, or your JVM’s DNS cache, so no shipped alert fires when the application is still broken after a successful failover. Then writehandoverNote: one line on what this pack will not tell the rotation. Done when:python3 starter/golive.pyprints[drill] cleanand an[owner]line that is notapplication-side alerting owned by: (nobody). -
7. Green the whole pack and hand it over Run the checker one last time against every fixture.
RESULT: READYmeans your rules page for the four incident fixtures, stay silent for the soaked reader, use only metrics that exist, carry a first command each, and sit behind a drill record that separates proof from assumption. Read your ownhandoverNoteback and decide whether you would sign it. That statement — whatplaygroundwill and will not do for an on-call rotation — is the deliverable of the whole course. Done when:python3 starter/golive.pyexits 0 and printsRESULT: READY.
How this is graded
Four machine-run test cases (weights 30 / 30 / 25 / 15) replay the fixtures in tests/fixtures/ through your rules and inspect the two checks you implement. The adversarial case is reader_soak_stays_silent: it fails a rules file that pages for the scenario 42 reader soak, and it also fails one that buys silence by deleting the rule or raising the threshold.
A human grades five criteria against rubric.md.
Steps
How it is graded
| Criterion | What earns it | Weight |
|---|---|---|
| The alert set discriminates real loss from designed behaviour | All ten required alerts are present. BloodravenReplicationLagging excludes the read-only site by label matcher while keeping the threshold at 30 — the value playground sets — so the scenario 42 soak is silent and a 64-second pdx still pages. The rule was excluded, not deleted, not defanged by a raised threshold, and not narrowed to a hard-coded single site. BloodravenFailoverOccurred carries severity: info; every paging rule carries a non-zero for:. Full marks require both halves — silence on the reader and noise on the candidate. | 30 |
| Every rule is built from a metric the shipped operator exports | No expression references a name outside SHIPPED_METRICS plus Prometheus' own up. BloodravenBackupStale derives an age from bloodraven_backup_last_success_timestamp_seconds with time() - rather than an invented age gauge, and BloodravenKeyringNotSealed reads the one-hot bloodraven_keyring_phase{phase="sealed"} == 0. check_metric_allowlist is implemented and catches a planted bad metric rather than returning an empty list unconditionally. | 20 |
| The runbook map gets on-call to a command in thirty seconds | Every alert in the rules file has an entry with a well-formed runbook.md#<slug> anchor and one kubectl command. The commands are real: they use the seven subcommands the plugin actually has, and where a generic kubectl bloodraven status playground is not the right first move the entry says what is. check_runbook_coverage flags a removed entry and an emptied firstCommand and reports each exactly once. | 20 |
| The drill record separates what was proved from what was assumed | proved and assumed both use the supplied vocabulary and are non-empty. logical-equivalence-with-live-primary and application-traffic-cutover appear in assumed and never in proved. backupSourceSite is not the read-only site and backupSourceReason is one of override, replica-preferred, primary-fallback. applicationSideAlertOwner names a human, and handoverNote states one thing the pack will not tell the rotation. | 15 |
| Craft: the pack reads like something you would hand over | Rule names and annotations say what the alert means at 3am, not what the expression computes. The two implemented checks are short, return the documented shape, and do not crash on a malformed or missing entry — a missing runbook key is a finding, not a KeyError. The report runs clean with no stray debug output. A second engineer could read alerts.yml and runbooks.yml and take the pager without asking a question. | 15 |
| Total | 100 |
Test cases
| Test | Checks | Expected | Weight |
|---|---|---|---|
| reader_soak_stays_silent | Catches a shortcut: alerting on bloodraven_replication_lag_seconds with no site-label exclusion, or silencing it by deleting the rule or raising the threshold. Replays the scenario 42 soak (reader at 91s, both threads running, group Ready) and requires zero firing alerts, then replays candidate-lagging (pdx at 64s) and requires exactly BloodravenReplicationLagging@pdx. A blanket suppression fails the second half; a missing exclusion fails the first. | PASS | 30 |
| real_loss_still_pages | Correctness on the canonical inputs. Replays four incident fixtures — no writable site with a stopped receiver thread, a post-failover group with 7 divergent transactions and an unsealed keyring and a stale backup and an archiver backlog, an operator-down scrape with a perfectly healthy data plane, and an auto-resolved split brain — and requires the exact Alert@site set for each. Firing for the wrong site is a failure. | PASS | 30 |
| only_shipped_metrics_and_full_runbook_map | Structural: the two checks must exist and work, not just return empty. Asserts all ten required alerts are present, that check_metric_allowlist clears the finished rules but returns exactly [('BogusAlert', 'bloodraven_backup_age_seconds')] for a planted rule, and that check_runbook_coverage clears the finished map but flags a removed entry and an emptied firstCommand exactly once each. | PASS | 25 |
| drill_record_separates_proved_from_assumed | Grades the DR drill record. logical-equivalence-with-live-primary and application-traffic-cutover must sit in assumed and never in proved, both lists must use the supplied vocabulary, backupSourceSite must not be the read-only site, backupSourceReason must be one of the three reason strings, restore.confirm must parse as RFC 3339, and applicationSideAlertOwner must name someone. | PASS | 15 |
Starter files
2 files
golive.py
#!/usr/bin/env python3
"""go-live pack checker for the failover group `playground`.
Reads three artefacts out of ``pack/`` and reports whether the pack is
fit to hand to an on-call rotation:
pack/alerts.yml Prometheus alerting rules
pack/runbooks.yml alert -> runbook anchor -> first command
pack/drill.json the DR drill record
Then it replays every fixture under ``tests/fixtures/`` through your
rules and compares what fired against what should have fired.
Run it:
python3 starter/golive.py
Everything below is wired except the two functions marked TODO F and
TODO G. Argument parsing, YAML/JSON loading, evaluation and report
formatting are done.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from datetime import datetime
from pathlib import Path
import yaml
sys.path.insert(0, str(Path(__file__).resolve().parent))
import promeval # noqa: E402
# ---------------------------------------------------------------------------
# Grounded reference data. Do not add to these lists from memory.
# ---------------------------------------------------------------------------
# Every metric name the shipped operator (v1.0.0) actually exports and
# that this course teaches you to alert on. A rule referencing anything
# else is a rule that can never fire.
SHIPPED_METRICS = {
"bloodraven_site_state",
"bloodraven_replication_lag_seconds",
"bloodraven_replication_running",
"bloodraven_failovers_total",
"bloodraven_divergent_transactions",
"bloodraven_split_brain_auto_resolve_total",
"bloodraven_primary_reassert_total",
"bloodraven_poll_latency_seconds",
"bloodraven_archiver_backlog_files",
"bloodraven_backup_last_success_timestamp_seconds",
"bloodraven_keyring_phase",
"bloodraven_dns_flips_total",
"bloodraven_state_transitions_total",
}
# `up` is Prometheus' own scrape-health series, not a Bloodraven metric.
# It is the only non-Bloodraven name this pack is allowed to use.
ALLOWED_FOREIGN_METRICS = {"up"}
# The minimum alert set for `playground`.
REQUIRED_ALERTS = [
"BloodravenOperatorDown",
"BloodravenNoWritableSite",
"BloodravenSplitBrainResolved",
"BloodravenReplicationLagging",
"BloodravenReplicationDown",
"BloodravenDivergentTransactions",
"BloodravenBackupStale",
"BloodravenPITRArchiveLagging",
"BloodravenKeyringNotSealed",
"BloodravenFailoverOccurred",
]
# Alerts that must not page. `bloodraven_failovers_total` tells you the
# operator finished, not that traffic recovered.
INFO_ONLY_ALERTS = {"BloodravenFailoverOccurred"}
PAGING_SEVERITIES = {"critical", "warning"}
# Vocabulary for the drill record. Anything outside it is rejected, so
# two people writing a drill record produce comparable claims.
PROVED_VOCABULARY = {
"artifact-loads",
"sanity-check-passed",
"restore-in-place-completed",
"reader-endpoint-returned",
"dns-record-updated",
}
ASSUMED_VOCABULARY = {
"logical-equivalence-with-live-primary",
"application-traffic-cutover",
"cross-cluster-split-brain-detection",
"dns-propagation-time",
}
# A verification proves the artifact loads and your scalar assertion
# held. These two are the things it never proves, so they belong in
# `assumed` on every honest drill record.
ASSUMED_REQUIRED = {
"logical-equivalence-with-live-primary",
"application-traffic-cutover",
}
BACKUP_SOURCE_REASONS = {"override", "replica-preferred", "primary-fallback"}
# `playground` runs three sites. `reader` carries role: read-only, so it can
# neither be promoted nor source a backup.
READ_ONLY_SITES = {"reader"}
ANCHOR_RE = re.compile(r"^runbook\.md#[a-z0-9-]+$")
# ---------------------------------------------------------------------------
# Loading (wired)
# ---------------------------------------------------------------------------
def load_rules(path):
"""Flatten a Prometheus rules file into a list of rule dicts."""
doc = yaml.safe_load(Path(path).read_text()) or {}
rules = []
for group in doc.get("groups", []) or []:
for rule in group.get("rules", []) or []:
if "alert" not in rule:
continue
rules.append(
{
"alert": rule["alert"],
"expr": rule.get("expr", ""),
"for": rule.get("for", ""),
"labels": rule.get("labels") or {},
"annotations": rule.get("annotations") or {},
"group": group.get("name", ""),
}
)
return rules
def load_runbooks(path):
doc = yaml.safe_load(Path(path).read_text()) or {}
return doc.get("runbooks") or {}
def load_drill(path):
return json.loads(Path(path).read_text())
# ---------------------------------------------------------------------------
# TODO F and TODO G — the two checks you implement
# ---------------------------------------------------------------------------
def check_metric_allowlist(rules):
"""Report every rule that references a metric the operator does not export.
Return a sorted list of ``(alert_name, metric_name)`` tuples, one per
offending metric, and an empty list when every rule is clean.
Use ``promeval.metric_names_in(expr)`` to pull the metric names out of
an expression. A name is acceptable when it is in ``SHIPPED_METRICS``
or in ``ALLOWED_FOREIGN_METRICS``; anything else is a finding.
TODO F
"""
return None
def check_runbook_coverage(rules, runbooks):
"""Report every alert whose runbook entry is missing or unusable.
Return a sorted list of ``(alert_name, problem)`` tuples and an empty
list when the map is complete. An entry is usable when all of these
hold:
* the alert has an entry in ``runbooks`` at all
* ``anchor`` is a non-empty string matching ``ANCHOR_RE``
* ``firstCommand`` is a non-empty string starting with ``kubectl``
The ``problem`` string is yours to word; nothing grades it. What is
graded is which alerts you flag.
TODO G
"""
return None
# ---------------------------------------------------------------------------
# Checks that are already wired
# ---------------------------------------------------------------------------
def check_coverage(rules):
"""Required alerts present, paging rules debounced, info alerts not paging."""
problems = []
by_name = {r["alert"]: r for r in rules}
for name in REQUIRED_ALERTS:
if name not in by_name:
problems.append(f"missing required alert: {name}")
for rule in sorted(rules, key=lambda r: r["alert"]):
name = rule["alert"]
severity = str(rule["labels"].get("severity", "")).lower()
if name in INFO_ONLY_ALERTS:
if severity != "info":
problems.append(
f"{name} must carry severity: info — it reports that the operator "
f"finished, not that traffic recovered (found {severity or 'none'})"
)
continue
if severity not in PAGING_SEVERITIES:
problems.append(
f"{name} has severity {severity or 'none'}; expected one of "
+ ", ".join(sorted(PAGING_SEVERITIES))
)
if not str(rule["for"]).strip() or str(rule["for"]).strip() in {"0", "0s", "0m"}:
problems.append(f"{name} has no for: duration — it pages on one bad scrape")
return problems
def check_drill(drill):
"""The drill record must separate what was proved from what was assumed."""
problems = []
proved = drill.get("proved") or []
assumed = drill.get("assumed") or []
if not proved:
problems.append("proved[] is empty — a drill that proved nothing is not a drill")
if not assumed:
problems.append("assumed[] is empty — every drill leaves something unproved")
for item in proved:
if item not in PROVED_VOCABULARY:
problems.append(f"proved[] entry {item!r} is outside the vocabulary")
for item in assumed:
if item not in ASSUMED_VOCABULARY:
problems.append(f"assumed[] entry {item!r} is outside the vocabulary")
for item in sorted(ASSUMED_REQUIRED):
if item not in assumed:
problems.append(
f"assumed[] must contain {item!r} — a Succeeded verification never proves it"
)
if item in proved:
problems.append(f"proved[] claims {item!r}, which no verification can prove")
reason = drill.get("backupSourceReason", "")
if reason not in BACKUP_SOURCE_REASONS:
problems.append(
f"backupSourceReason {reason!r} is not one of "
+ ", ".join(sorted(BACKUP_SOURCE_REASONS))
)
site = drill.get("backupSourceSite", "")
if site in READ_ONLY_SITES:
problems.append(
f"backupSourceSite {site!r} is a read-only site, which cannot be a backup source"
)
elif not site:
problems.append("backupSourceSite is empty")
confirm = str((drill.get("restore") or {}).get("confirm", ""))
try:
datetime.fromisoformat(confirm.replace("Z", "+00:00"))
except ValueError:
problems.append(
f"restore.confirm {confirm!r} is not an RFC 3339 timestamp — the in-place "
"restore token is rejected unless it parses and is strictly greater than "
"status.restoreInPlace.confirmTokenUsed"
)
if not str(drill.get("earliestReachablePoint", "")).strip():
problems.append("earliestReachablePoint is empty — say how far back you can reach")
if not str(drill.get("applicationSideAlertOwner", "")).strip():
problems.append(
"applicationSideAlertOwner is empty — no shipped alert fires for "
"'the application is still broken after a successful failover'"
)
if not str(drill.get("handoverNote", "")).strip():
problems.append(
"handoverNote is empty — say in one line what this pack will not tell "
"the rotation"
)
return problems
def alert_key(entry):
"""Identify a firing series as ``Alert`` or ``Alert@site``.
Fixtures list what should fire using these keys, so an alert that
fires for the wrong site is a mismatch rather than a pass.
"""
labels = entry.get("labels") or {}
scope = (
labels.get("site")
or labels.get("target_site")
or labels.get("prefer_site")
or ""
)
return f"{entry['alert']}@{scope}" if scope else entry["alert"]
def firing_keys(rules, fixture):
"""The sorted set of ``Alert``/``Alert@site`` keys a fixture produces."""
return sorted({alert_key(entry) for entry in evaluate(rules, fixture)})
def evaluate(rules, fixture):
"""Return the firing series for every rule, as a list of dicts."""
firing = []
for rule in rules:
try:
result = promeval.eval_expr(rule["expr"], fixture)
except promeval.ExprError as exc:
firing.append(
{"alert": rule["alert"], "labels": {}, "value": 0.0, "error": str(exc)}
)
continue
for series in result:
firing.append(
{
"alert": rule["alert"],
"labels": series["labels"],
"value": series["value"],
}
)
return firing
# ---------------------------------------------------------------------------
# Report (wired)
# ---------------------------------------------------------------------------
def _fmt_labels(labels):
if not labels:
return ""
inner = ",".join(f'{k}="{v}"' for k, v in sorted(labels.items()))
return "{" + inner + "}"
def report(pack_dir, fixtures_dir, out=sys.stdout):
rules = load_rules(pack_dir / "alerts.yml")
runbooks = load_runbooks(pack_dir / "runbooks.yml")
drill = load_drill(pack_dir / "drill.json")
problems = 0
print("go-live pack for `playground`", file=out)
print("=" * 46, file=out)
print(f"{len(rules)} alert rules, {len(runbooks)} runbook entries", file=out)
print("", file=out)
metric_findings = check_metric_allowlist(rules)
if metric_findings is None:
print("[metrics] not implemented (TODO F)", file=out)
problems += 1
elif metric_findings:
print(
f"[metrics] {len(metric_findings)} reference(s) to a metric the operator "
"does not export:",
file=out,
)
for alert, metric in metric_findings:
print(f" {alert} -> {metric}", file=out)
problems += len(metric_findings)
else:
print("[metrics] clean", file=out)
runbook_findings = check_runbook_coverage(rules, runbooks)
if runbook_findings is None:
print("[runbooks] not implemented (TODO G)", file=out)
problems += 1
elif runbook_findings:
print(f"[runbooks] {len(runbook_findings)} alert(s) with no usable runbook entry:", file=out)
for alert, problem in runbook_findings:
print(f" {alert}: {problem}", file=out)
problems += len(runbook_findings)
else:
print("[runbooks] clean", file=out)
coverage = check_coverage(rules)
if coverage:
for problem in coverage:
print(f"[coverage] {problem}", file=out)
problems += len(coverage)
else:
print("[coverage] clean", file=out)
drill_problems = check_drill(drill)
if drill_problems:
for problem in drill_problems:
print(f"[drill] {problem}", file=out)
problems += len(drill_problems)
else:
print("[drill] clean", file=out)
owner = str(drill.get("applicationSideAlertOwner", "")).strip()
print(f"[owner] application-side alerting owned by: {owner or '(nobody)'}", file=out)
print("", file=out)
print("fixture replay", file=out)
print("-" * 46, file=out)
for path in sorted(fixtures_dir.glob("*.json")):
fixture = promeval.load_fixture(path)
firing = evaluate(rules, fixture)
keys = firing_keys(rules, fixture)
expected = sorted(set(fixture["expectedAlerts"]))
verdict = "OK" if keys == expected else "MISMATCH"
if verdict == "MISMATCH":
problems += 1
print(
f" fixture {fixture['id']}: {len(keys)} firing, "
f"expected {len(expected)} {verdict}",
file=out,
)
for entry in sorted(firing, key=lambda f: (f["alert"], str(f["labels"]))):
marker = "FIRING " if alert_key(entry) in expected else "SPURIOUS"
print(
f" {marker} {alert_key(entry)}{_fmt_labels(entry['labels'])} "
f"= {entry['value']:g}",
file=out,
)
for missing in [k for k in expected if k not in keys]:
print(f" MISSING {missing}", file=out)
print("", file=out)
if problems:
print(f"RESULT: NOT READY ({problems} problem(s))", file=out)
else:
print("RESULT: READY", file=out)
return 1 if problems else 0
def main(argv=None):
here = Path(__file__).resolve().parent
parser = argparse.ArgumentParser(description="check the go-live pack for `playground`")
parser.add_argument("--pack", type=Path, default=here / "pack")
parser.add_argument(
"--fixtures", type=Path, default=here.parent / "tests" / "fixtures"
)
args = parser.parse_args(argv)
return report(args.pack, args.fixtures)
if __name__ == "__main__":
raise SystemExit(main())
promeval.py
"""A deliberately small PromQL-subset evaluator.
This file is scaffolding. You do not need to change it, but you do need
to know what it supports, because it decides whether your alert rules
fire against the fixtures.
Supported expression shapes (whitespace is flexible):
<selector> <cmp> <number>
absent(<selector>)
absent(<selector> <cmp> <number>)
time() - <selector> <cmp> <number>
increase(<selector>[<duration>]) <cmp> <number>
A selector is ``metric_name`` or ``metric_name{label="v",other!="w"}``.
Matchers support ``=``, ``!=``, ``=~`` and ``!~``; the regex forms are
anchored (``re.fullmatch``), exactly like Prometheus.
Comparisons are ``>``, ``>=``, ``<``, ``<=``, ``==``, ``!=``.
Two deliberate simplifications, so you know what the checker is and is
not proving:
* Evaluation is instantaneous. A rule's ``for:`` duration is never
simulated — a fixture is one scrape, not a window. ``for:`` is still
required on every paging rule, because a rule without one pages on a
single bad scrape.
* ``increase(m[15m])`` does not compute anything. It reads the
pre-computed series the fixture stores under ``increases["15m"]``.
The window in the expression must match a key in that block.
Fixture format (JSON)::
{
"id": "primary-lost",
"title": "...",
"now": 1786550400,
"samples": [{"name": "...", "labels": {...}, "value": 1}],
"increases": {"15m": [{"name": "...", "labels": {...}, "value": 1}]},
"expectedAlerts": ["BloodravenNoWritableSite"]
}
"""
from __future__ import annotations
import json
import re
from pathlib import Path
CMP_OPS = {
">": lambda a, b: a > b,
">=": lambda a, b: a >= b,
"<": lambda a, b: a < b,
"<=": lambda a, b: a <= b,
"==": lambda a, b: a == b,
"!=": lambda a, b: a != b,
}
_FUNCS = {"absent", "increase", "rate", "time", "sum", "max", "min", "avg", "count"}
_SELECTOR = re.compile(r"^([a-zA-Z_:][a-zA-Z0-9_:]*)\s*(?:\{(.*)\})?$", re.S)
_MATCHER = re.compile(r'([a-zA-Z_][a-zA-Z0-9_]*)\s*(=~|!~|!=|=)\s*"([^"]*)"')
_ABSENT = re.compile(r"^absent\s*\(\s*(.*?)\s*\)\s*$", re.S)
_TIME = re.compile(
r"^time\s*\(\s*\)\s*-\s*(.+?)\s*(>=|<=|==|!=|>|<)\s*(-?[0-9.]+)\s*$", re.S
)
_INCREASE = re.compile(
r"^increase\s*\(\s*(.+?)\s*\[\s*([0-9]+[smhdw])\s*\]\s*\)\s*"
r"(>=|<=|==|!=|>|<)\s*(-?[0-9.]+)\s*$",
re.S,
)
_COMPARISON = re.compile(r"^(.+?)\s*(>=|<=|==|!=|>|<)\s*(-?[0-9.]+)\s*$", re.S)
class ExprError(ValueError):
"""Raised when an expression is outside the supported subset."""
def load_fixture(path):
"""Load a metric fixture from JSON."""
data = json.loads(Path(path).read_text())
data.setdefault("samples", [])
data.setdefault("increases", {})
data.setdefault("expectedAlerts", [])
return data
def metric_names_in(expr):
"""Return the sorted metric names an expression references.
Label blocks and range selectors are stripped first, so label names
and durations are never mistaken for metric names. Function names
(``absent``, ``increase``, ``time`` ...) are excluded.
"""
stripped = re.sub(r"\{[^}]*\}", "", str(expr))
stripped = re.sub(r"\[[^\]]*\]", "", stripped)
names = set()
for match in re.finditer(r"[a-zA-Z_:][a-zA-Z0-9_:]*", stripped):
name = match.group(0)
if name in _FUNCS:
continue
names.add(name)
return sorted(names)
def _parse_selector(text):
match = _SELECTOR.match(text.strip())
if not match:
raise ExprError(f"not a selector: {text!r}")
name = match.group(1)
raw = match.group(2) or ""
matchers = []
consumed = 0
for m in _MATCHER.finditer(raw):
matchers.append((m.group(1), m.group(2), m.group(3)))
consumed += len(m.group(0))
if raw.strip(" ,") and consumed == 0:
raise ExprError(f"unparsable label matchers in {text!r}")
return name, matchers
def _matches(sample, name, matchers):
if sample.get("name") != name:
return False
labels = sample.get("labels", {})
for key, op, value in matchers:
actual = labels.get(key, "")
if op == "=" and actual != value:
return False
if op == "!=" and actual == value:
return False
if op == "=~" and not re.fullmatch(value, actual):
return False
if op == "!~" and re.fullmatch(value, actual):
return False
return True
def _select(samples, text):
name, matchers = _parse_selector(text)
return [s for s in samples if _matches(s, name, matchers)]
def _compare(series, op, threshold):
fn = CMP_OPS[op]
return [s for s in series if fn(float(s["value"]), float(threshold))]
def eval_expr(expr, fixture):
"""Evaluate one expression against one fixture.
Returns the result vector: a list of ``{"labels": ..., "value": ...}``
entries. An empty list means the rule does not fire.
"""
expr = str(expr).strip()
absent = _ABSENT.match(expr)
if absent:
inner = eval_expr(absent.group(1), fixture)
return [] if inner else [{"labels": {}, "value": 1.0}]
timed = _TIME.match(expr)
if timed:
now = float(fixture.get("now", 0))
series = _select(fixture["samples"], timed.group(1))
shifted = [
{"labels": s.get("labels", {}), "value": now - float(s["value"])}
for s in series
]
return _compare(shifted, timed.group(2), timed.group(3))
increased = _INCREASE.match(expr)
if increased:
window = increased.group(2)
pool = fixture.get("increases", {}).get(window)
if pool is None:
raise ExprError(
f"fixture {fixture.get('id')!r} has no increases block for window {window!r}"
)
series = _select(pool, increased.group(1))
out = [{"labels": s.get("labels", {}), "value": float(s["value"])} for s in series]
return _compare(out, increased.group(3), increased.group(4))
compared = _COMPARISON.match(expr)
if compared:
series = _select(fixture["samples"], compared.group(1))
out = [{"labels": s.get("labels", {}), "value": float(s["value"])} for s in series]
return _compare(out, compared.group(2), compared.group(3))
series = _select(fixture["samples"], expr)
return [{"labels": s.get("labels", {}), "value": float(s["value"])} for s in series]
Projects are not auto-graded here. The rubric and the test cases above are the grading contract — run them yourself on your own machine.