brstatus — the one-screen status reader
Goal
Write a tool that turns a MysqlFailoverGroup status into a one-screen summary and a meaningful exit code, so that from Unit 2 onward you can tell at a glance whether playground is healthy — and so you learn, the hard way, that a lagging reader is not an unhealthy group.
Unit 1 — Meet the group · type: code-notebook · Python 3.13, standard library only
Goal
Write a tool that turns a MysqlFailoverGroup status into a one-screen summary and a meaningful
exit code, so that from Unit 2 onward you can tell at a glance whether playground is healthy — and so
you learn, the hard way, that a lagging reader is not an unhealthy group.
How this works
playground is your three-site failover group: iad and pdx are PrimaryCandidate, reader is
ReadOnly. From Unit 2 onward you will look at its status constantly, and
kubectl get mysqlfailovergroup playground -o json is 400 lines. brstatus squeezes it to one screen:
playground/bloodraven-playground active=iad ready=True degraded=False(Healthy)
SITE ROLE STATE REPL LAG SERVING
iad PrimaryCandidate Writable NotApplicable unknown no
pdx PrimaryCandidate ReadOnly Healthy 0s yes
reader ReadOnly ReadOnly Healthy 0s yes
VERDICT: OK
And it exits with a code you can act on:
| Exit | Verdict | Condition |
|---|---|---|
| 0 | OK | the Degraded condition is not True |
| 1 | DEGRADED | Degraded is True and status.activeSite is set |
| 2 | CRITICAL | Degraded is True and status.activeSite is empty |
| 3 | — | the input was not one MysqlFailoverGroup |
Exit 2 is the shape of split brain and of no-primary: in both, no site is the unambiguous authority,
so status.activeSite is empty.
Everything runs against JSON fixtures in tests/fixtures/. No cluster is needed to finish the code.
Your tasks
Open starter/brstatus.py. Run it first, before you change anything:
python starter/brstatus.py tests/fixtures/playground-healthy.json
It runs. It is also wrong in three ways, and each one is a TODO.
TODO A — format_lag. status.sites[].secondsBehindSource is a pointer, and it is absent
whenever the operator has no replication reading for that site: the active primary, a site it could
not poll, a replica whose threads are stopped. Return "unknown" when it is absent or null and
"<n>s" otherwise. The starter prints 0s for absent, which is how a detached replica ends up
looking perfectly caught up.
TODO B — is_serving. Decide whether a site is currently behind mysql-playground-replicas, the
group read endpoint. That Service selects on three labels — instance, role=replica, and
healthy=true — and the operator’s rule for stamping healthy depends on the site’s role.
For a site whose role is ReadOnly, all five must hold together:
sourceConvergenceStateisConvergedreplicationisHealthyorLaggingsecondsBehindSourceis present (not null)canonical_host(sourceHost)equalsexpected_source_host(...)for the active site — a replica chained off another replica does not countsecondsBehindSourceis at or undereffective_readonly_max_lag(spec)
For every other role, healthy=true as soon as state is Writable or ReadOnly. There is no
lag gate on those sites at all. That asymmetry is real and it is the point of this project.
TODO C — verdict. Return (word, exit_code) from the table above. Read the Degraded
condition the operator already wrote — its reason is one of Healthy, SiteUnhealthy, SplitBrain,
NoPrimary, TotalLoss, or a replication reason such as ReplicationLagging. Do not re-derive
group health from the site rows.
Then do the cluster half: with playground up, capture a live status and read it with your own tool.
mkdir -p artefacts
kubectl -n bloodraven-playground get mysqlfailovergroup playground -o json > artefacts/playground-live.json
python starter/brstatus.py artefacts/playground-live.json
What the scaffolding is for
You do not have to write any of this, but you do have to call it:
site_role(site_spec)applies the CRD default —spec.sites[].roleis optional and defaults toPrimaryCandidate.effective_max_lag(spec)isspec.replication.maxLagSeconds, defaulting to 300 when absent. An explicit0is kept, and means the replica must be fully caught up.effective_readonly_max_lag(spec)isspec.replication.readOnlyMaxLagSeconds. It has no default of its own: absent inheritsmaxLagSeconds, but an explicit0is meaningful and means the reader must report zero lag..get("readOnlyMaxLagSeconds") or ...gets that wrong.condition(status, "Degraded")pulls one entry out ofstatus.conditions.canonical_host()andexpected_source_host()compare replication source hosts the way the operator does — lowercased,:3306stripped, trailing dot stripped.- Inside
is_serving, two guards are already written for you: an emptystatus.activeSitesheds every endpoint, and the active site itself is never serving, because it carriesrole=primaryrather thanrole=replica. render()sizes the columns. Print whatever you like around it; the tests parse cells, not widths.
Expected output
With all three TODOs done, tests/fixtures/playground-reader-soaking.json — where reader is 300
seconds behind a 30 second threshold — must produce this, and exit 0:
playground/bloodraven-playground active=iad ready=True degraded=False(Healthy)
SITE ROLE STATE REPL LAG SERVING
iad PrimaryCandidate Writable NotApplicable unknown no
pdx PrimaryCandidate ReadOnly Healthy 0s yes
reader ReadOnly ReadOnly Lagging 300s no
VERDICT: OK
And tests/fixtures/playground-candidate-lagging.json — where pdx is 300 seconds behind the same
threshold — must exit 1, with pdx still marked SERVING yes.
One site 300 seconds behind, two opposite answers. Work out why before you write the code.
Run everything:
python tests/test_brstatus.py
Rules
- Standard library only. No
kubernetesclient, nopyyaml, no network. - Do not edit anything in
tests/. Fixtures and checks are the grader’s copy. - Keep the function names
format_lag,is_servingandverdict— a structural check looks for them. - Every threshold comes from the object you were handed. Do not hard-code
30; the shipped default is 300 and the playground overrides it. secondsBehindSourceof0andsecondsBehindSourceabsent are different facts. Never collapse them.
Steps
- Run the starter and find the lie. Run
python starter/brstatus.py tests/fixtures/playground-healthy.jsonbefore changing anything. Look at theiadrow: it is the writable primary, it reports no replication lag at all, and the tool prints0s. Done when: the command runs to completion and theiadrow shows0sin the LAG column. - TODO A — absent lag is not zero lag. Complete
format_lag. Done when: forplayground-healthy.jsontheiadrow showsunknownandpdxshows0s. - TODO B — the reader rule and the candidate rule are different rules. Complete
is_serving. Done when: forplayground-reader-soaking.jsonthereaderrow showsSERVING noandpdxshowsyes; forplayground-candidate-lagging.jsonthepdxrow showsSERVING yes. - TODO C — the verdict and the exit code. Complete
verdict. Done when:brstatus.pyexits 0 onplayground-healthy.json, 1 onplayground-candidate-lagging.json, and 2 onplayground-no-primary.json, whose last line readsVERDICT: CRITICAL. - Point it at the real
playground. Captureartefacts/playground-live.jsonfrom the running playground and read it with your own tool. Done when: the file exists, contains"kind": "MysqlFailoverGroup", andpython starter/brstatus.py artefacts/playground-live.jsonexits 0, 1 or 2 rather than 3. - Write down the inversion. Add a comment at the bottom of
starter/brstatus.py, on a line starting# READER-LAG:, naming the fixture that exits 0 and saying in one sentence why a lagging reader leaves the group healthy while a lagging primary candidate does not. Done when:starter/brstatus.pycontains a line beginning# READER-LAG:that namesplayground-reader-soaking.json.
Grading
Four automated checks carry 100 points between them, and a human marks the five criteria in
rubric.md. Read the rubric before you start — the craft criterion is worth 20 and is
the one people leave on the table.
Without Python — the jq route
If you would rather not write Python, the reading half of this project is a jq filter, and it is
closer to what you would actually type at 03:00. It will not teach you the is_serving asymmetry —
that is what the Python is for — but it gets you a one-screen summary today:
kubectl -n bloodraven-playground get mysqlfailovergroup playground -o json | jq -r '
.spec as $spec |
.status as $st |
($spec.replication.maxLagSeconds // 300) as $maxlag |
($spec.replication.readOnlyMaxLagSeconds // $maxlag) as $rolag |
([$st.conditions[]? | select(.type=="Degraded")][0] // {}) as $deg |
"\(.metadata.name)/\(.metadata.namespace) active=\($st.activeSite // "-") " +
"ready=\([$st.conditions[]?|select(.type=="Ready")][0].status // "-") " +
"degraded=\($deg.status // "-")(\($deg.reason // "-"))",
(["SITE","ROLE","STATE","REPL","LAG"] | @tsv),
( $st.sites[]? as $s
| ([$spec.sites[] | select(.name == $s.name)][0].role // "PrimaryCandidate") as $role
| [ $s.name, $role, ($s.state // "-"),
($s.replication // "-"),
(if $s.secondsBehindSource == null then "unknown"
else "\($s.secondsBehindSource)s" end) ] | @tsv )
' | column -t
Three things in that filter are the same three traps the Python version is built around, and they are worth reading rather than pasting.
// 300 on maxLagSeconds, and // $maxlag on readOnlyMaxLagSeconds. The first is the CRD
default. The second is inheritance, not a default — and note that // in jq treats false and
null as absent but not 0, which is exactly the behaviour both fields need: an explicit 0 is
meaningful and must survive.
if $s.secondsBehindSource == null then "unknown". Never // 0. Absent and zero are different
facts, and collapsing them is how a detached replica ends up looking perfectly caught up.
$spec.sites[] | select(.name == $s.name) | .role. Role is not in status; it lives in the spec,
and joining the two yourself is the whole reason a lagging reader reads differently from a lagging
candidate.
What this does not give you is SERVING — whether a site is currently behind
mysql-playground-replicas — because that needs the five-conjunct rule and a canonical host
comparison. If you want it in shell, that is the exercise; if you want it explained, that is TODO B.
Steps
How it is graded
| Criterion | What earns it | Weight |
|---|---|---|
| The verdict and exit code come from the group's conditions | verdict reads the Degraded condition out of status.conditions and combines it with status.activeSite to return exactly ("OK", 0), ("DEGRADED", 1) or ("CRITICAL", 2). Full marks require that no per-site field — lag, replication, state — is consulted inside verdict. Award 14 if the three codes are right but group health is partly re-derived from the site rows; award 0 if any fixture returns the wrong code. | 28 |
| Reader endpoint eligibility is role-aware and complete | is_serving branches on site_role(...). The ReadOnly branch tests all five conjuncts — converged source, replication Healthy or Lagging, non-null lag, canonical direct source host, and lag at or under effective_readonly_max_lag(spec). Every other role is served on state alone with no lag gate. Award 14 if the five conjuncts are right but the same gate is wrongly applied to PrimaryCandidate sites, or if a conjunct is missing; award 0 if the reader threshold is effective_max_lag instead. | 27 |
| Absent lag is distinguished from zero lag | format_lag returns unknown for an absent or null secondsBehindSource and <n>s otherwise, and is_serving treats a null lag as disqualifying rather than as 0. Award 8 if only one of the two places is right. | 15 |
| The tool was run against the live playground group | artefacts/playground-live.json is present, is a single MysqlFailoverGroup captured from the running playground rather than a copied fixture (its metadata.creationTimestamp, metadata.uid and status.sites[].lastSeenTime are populated), and the submission shows brstatus output for it. | 10 |
| Craft: the output stays one screen and the code survives a thin status | The header, table and VERDICT: line are intact and readable, absent optional fields (state, secondsBehindSource, conditions, sourceHost) produce a cell rather than a traceback, unreadable input exits 3 with a message on stderr, and the three completed functions carry a comment a colleague could follow — naming the rule, not restating the code. Deduct for debug prints left in the output, for hard-coded thresholds, and for rewriting scaffolding that was already correct. | 20 |
| Total | 100 |
Test cases
| Test | Checks | Expected | Weight |
|---|---|---|---|
| healthy_group_summary | Correctness on the canonical input. Runs the tool against playground-healthy.json and checks the whole screen: header active=iad ready=True degraded=False(Healthy), the writable primary rendered as LAG unknown and SERVING no, both followers SERVING yes, VERDICT: OK, exit code 0. | PASS | 30 |
| lagging_reader_is_not_an_unhealthy_group | Catches a shortcut: applying one lag rule to every site and folding site lag into group health. Two fixtures, both with a site 300s behind a 30s threshold. In playground-reader-soaking.json the ReadOnly reader is behind — the group must exit 0 while the reader shows SERVING no. In playground-candidate-lagging.json the PrimaryCandidate replica is behind — the group must exit 1 while that replica shows SERVING yes. A uniform lag gate, or a verdict derived from the site rows, gets one of the two backwards. This test also feeds a group whose iad site omits spec.sites[].role, which must default to PrimaryCandidate. | PASS | 30 |
| awkward_status_null_lag_and_lost_authority | Generality on awkward status. A detached reader with secondsBehindSource absent must render unknown and SERVING no without degrading the group; an explicit readOnlyMaxLagSeconds: 0 must not fall back to maxLagSeconds; and both authority-loss shapes — playground-no-primary.json and playground-split-brain.json — must exit 2 with every site shown as not serving. | PASS | 20 |
| verdict_reads_conditions_and_reader_threshold | Structural. Parses brstatus.py and asserts the required constructs are where they belong: verdict mentions the Degraded condition and reaches status.conditions, and never mentions secondsBehindSource; is_serving calls effective_readonly_max_lag and branches on the string ReadOnly; format_lag can return unknown. | PASS | 20 |
Starter files
1 files
brstatus.py
#!/usr/bin/env python3
"""brstatus - one screen of truth about a MysqlFailoverGroup.
python brstatus.py <group.json>
It reads the JSON of one MysqlFailoverGroup, exactly as printed by
kubectl -n bloodraven-playground get mysqlfailovergroup playground -o json
and prints a header line, one line per site, and a verdict. The exit code
carries the verdict so you can put brstatus in a loop or a check script:
0 OK the group is not degraded
1 DEGRADED the group is degraded and still has an active site
2 CRITICAL the group is degraded and has no active site
3 the input could not be read as a MysqlFailoverGroup
Three functions are stubbed out. Find TODO A, TODO B and TODO C.
"""
from __future__ import annotations
import json
import sys
# spec.sites[].role is enum-validated and defaults to PrimaryCandidate.
DEFAULT_ROLE = "PrimaryCandidate"
# spec.replication.maxLagSeconds defaults to 300. An object built outside
# admission may omit it, so the tool applies the same default the operator does.
DEFAULT_MAX_LAG_SECONDS = 300
COLUMNS = ("SITE", "ROLE", "STATE", "REPL", "LAG", "SERVING")
# ---------------------------------------------------------------------------
# Scaffolding. Already wired. Read it, call it, do not rewrite it.
# ---------------------------------------------------------------------------
def load_group(path):
"""Load a MysqlFailoverGroup from a JSON file. Raises ValueError if it
is not one - a list from `kubectl get -o json` without a name is the
usual mistake."""
with open(path, "r", encoding="utf-8") as handle:
obj = json.load(handle)
if not isinstance(obj, dict) or obj.get("kind") != "MysqlFailoverGroup":
raise ValueError(
"expected one MysqlFailoverGroup object, got kind=%r "
"(name the group: kubectl get mysqlfailovergroup playground -o json)"
% (obj.get("kind") if isinstance(obj, dict) else type(obj).__name__)
)
return obj
def site_role(site_spec):
"""The site's effective role, applying the CRD default."""
return site_spec.get("role") or DEFAULT_ROLE
def effective_max_lag(spec):
"""spec.replication.maxLagSeconds, or the API default when absent. An
explicit 0 is kept: it means the replica must be fully caught up."""
replication = spec.get("replication") or {}
value = replication.get("maxLagSeconds")
if value is None:
return DEFAULT_MAX_LAG_SECONDS
return int(value)
def effective_readonly_max_lag(spec):
"""spec.replication.readOnlyMaxLagSeconds. It has no default of its own:
absent inherits maxLagSeconds, but an explicit 0 is meaningful and means
the reader must report zero lag."""
replication = spec.get("replication") or {}
if replication.get("readOnlyMaxLagSeconds") is not None:
return int(replication["readOnlyMaxLagSeconds"])
return effective_max_lag(spec)
def condition(status, condition_type):
"""One entry of status.conditions by type, or None."""
for entry in status.get("conditions") or []:
if entry.get("type") == condition_type:
return entry
return None
def canonical_host(host):
"""Compare replication source hosts the way the operator does: lowercase,
trimmed, without the :3306 suffix and without a trailing dot."""
host = (host or "").strip().lower()
if host.endswith(":3306"):
host = host[: -len(":3306")]
return host.rstrip(".")
def expected_source_host(group_name, namespace, active_site):
"""The one source host a converged follower is allowed to have: the
internal Service of the active site."""
return "mysql-%s-%s-internal.%s.svc.cluster.local" % (
group_name,
active_site,
namespace,
)
def site_status_by_name(status):
return {entry.get("name"): entry for entry in status.get("sites") or []}
# ---------------------------------------------------------------------------
# TODO A - render the lag cell.
# ---------------------------------------------------------------------------
def format_lag(site_status):
"""Return the LAG cell for one site.
status.sites[].secondsBehindSource is a pointer. It is absent whenever
the operator has no replication reading for the site at all - the active
primary, a site it could not poll, a replica whose threads are stopped.
Absent is not zero, and printing it as zero is how a detached replica
ends up looking perfectly caught up.
TODO A: return "unknown" when secondsBehindSource is absent or null, and
"<n>s" otherwise (for example "0s", "300s").
"""
return "%ss" % (site_status.get("secondsBehindSource") or 0)
# ---------------------------------------------------------------------------
# TODO B - decide whether a site is serving reads.
# ---------------------------------------------------------------------------
def is_serving(site_spec, site_status, group):
"""Is this site currently behind the group read endpoint,
mysql-<group>-replicas?
That Service selects on three labels: instance, role=replica and
healthy=true. The operator stamps healthy on each pod, and the rule it
uses depends on the site's role.
TODO B: replace the placeholder below with the operator's own rule.
* For a site whose role is "ReadOnly", healthy=true needs all five of
these to hold at once:
1. sourceConvergenceState is "Converged"
2. replication is "Healthy" or "Lagging" (Lagging only means past
maxLagSeconds; the reader threshold is conjunct 5)
3. secondsBehindSource is present (not null)
4. canonical_host(sourceHost) equals expected_source_host(...) for
the active site - a chained replica does not count
5. secondsBehindSource is at or under effective_readonly_max_lag(spec)
* For every other role, healthy=true as soon as state is "Writable" or
"ReadOnly". There is no lag gate on those sites. Read that sentence
twice before you write the code.
"""
status = group.get("status") or {}
spec = group.get("spec") or {}
active = status.get("activeSite") or ""
# Invalid or incomplete authority deliberately sheds every endpoint.
if not active:
return False
# The active primary carries role=primary, so it never matches the
# role=replica selector on mysql-<group>-replicas.
if site_status.get("name") == active:
return False
group_name = (group.get("metadata") or {}).get("name", "")
namespace = (group.get("metadata") or {}).get("namespace", "")
expected = expected_source_host(group_name, namespace, active)
role = site_role(site_spec)
return site_status.get("state") == "ReadOnly"
# ---------------------------------------------------------------------------
# TODO C - the verdict and the exit code.
# ---------------------------------------------------------------------------
def verdict(group):
"""Return (word, exit_code) for the group.
The operator has already done this work. Every poll it writes a Degraded
condition whose reason is one of Healthy, SiteUnhealthy, SplitBrain,
NoPrimary or TotalLoss, plus the replication reasons
ReplicationBroken, ReplicationLagging, ReplicationError and
ReplicationSourceMismatch. Read the condition. Do not re-derive group
health from the site rows.
TODO C:
* Degraded absent, or its status is not "True" -> ("OK", 0)
* Degraded is "True" and status.activeSite is set -> ("DEGRADED", 1)
* Degraded is "True" and status.activeSite is empty -> ("CRITICAL", 2)
"""
status = group.get("status") or {}
return ("OK", 0)
# ---------------------------------------------------------------------------
# Scaffolding again: rendering and the entry point.
# ---------------------------------------------------------------------------
def build_rows(group):
spec = group.get("spec") or {}
status = group.get("status") or {}
observed = site_status_by_name(status)
rows = []
for site_spec in spec.get("sites") or []:
name = site_spec.get("name", "")
site_status = observed.get(name, {"name": name})
rows.append(
(
name,
site_role(site_spec),
site_status.get("state") or "Unknown",
site_status.get("replication") or "Unknown",
format_lag(site_status),
"yes" if is_serving(site_spec, site_status, group) else "no",
)
)
return rows
def render(group, rows, word):
metadata = group.get("metadata") or {}
status = group.get("status") or {}
ready = condition(status, "Ready")
degraded = condition(status, "Degraded")
ready_cell = ready.get("status", "Unknown") if ready else "Unknown"
if degraded:
degraded_cell = "%s(%s)" % (
degraded.get("status", "Unknown"),
degraded.get("reason", "none"),
)
else:
degraded_cell = "Unknown(none)"
lines = [
"%s/%s active=%s ready=%s degraded=%s"
% (
metadata.get("name", "?"),
metadata.get("namespace", "?"),
status.get("activeSite") or "none",
ready_cell,
degraded_cell,
)
]
if degraded and degraded.get("status") == "True" and degraded.get("message"):
lines.append("ALERT: %s" % degraded["message"])
widths = [len(head) for head in COLUMNS]
for row in rows:
for index, cell in enumerate(row):
widths[index] = max(widths[index], len(cell))
template = " ".join("%%-%ds" % width for width in widths).rstrip()
lines.append((template % COLUMNS).rstrip())
for row in rows:
lines.append((template % row).rstrip())
lines.append("VERDICT: %s" % word)
return "\n".join(lines)
def main(argv):
if len(argv) != 2:
print("usage: brstatus.py <group.json>", file=sys.stderr)
return 3
try:
group = load_group(argv[1])
except (OSError, ValueError, json.JSONDecodeError) as err:
print("brstatus: %s" % err, file=sys.stderr)
return 3
word, code = verdict(group)
print(render(group, build_rows(group), word))
return code
if __name__ == "__main__":
sys.exit(main(sys.argv))
Projects are not auto-graded here. The rubric and the test cases above are the grading contract — run them yourself on your own machine.