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 primary-candidate, reader is
read-only. 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 primary-candidate writable no unknown no
pdx primary-candidate read-only yes 0s yes
reader read-only read-only yes 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=yes — and the operator’s rule for stamping healthy depends on the site’s role.
For a site whose role is read-only, all five must hold together:
sourceConvergenceStateisConvergedreplicatingis truesecondsBehindSourceis 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=yes as soon as state is writable or read-only. 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, Degraded, 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 toprimary-candidate.effective_max_lag(spec)isspec.replication.maxLagSeconds, defaulting to 300.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 primary-candidate writable no unknown no
pdx primary-candidate read-only yes 0s yes
reader read-only read-only yes 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 // "primary-candidate") as $role
| [ $s.name, $role, ($s.state // "-"),
(if $s.replicating == null then "-" else ($s.replicating|tostring) end),
(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 this field needs: 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, replicating, 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 read-only branch tests all five conjuncts — converged source, replicating, 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 primary-candidate 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[].lastSeen 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 read-only reader is behind — the group must exit 0 while the reader shows SERVING no. In playground-candidate-lagging.json the primary-candidate 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 primary-candidate. | 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 read-only; 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 primary-candidate.
DEFAULT_ROLE = "primary-candidate"
# 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."""
replication = spec.get("replication") or {}
value = replication.get("maxLagSeconds")
if not value:
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=yes. 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 "read-only", healthy=yes needs all five of
these to hold at once:
1. sourceConvergenceState is "Converged"
2. replicating is true
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=yes as soon as state is "writable" or
"read-only". 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") == "read-only"
# ---------------------------------------------------------------------------
# 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, Degraded, 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",
"yes" if site_status.get("replicating") else "no",
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.