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:

ExitVerdictCondition
0OKthe Degraded condition is not True
1DEGRADEDDegraded is True and status.activeSite is set
2CRITICALDegraded is True and status.activeSite is empty
3the 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:

  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 replica chained off another 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 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:

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

Steps

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

CriterionWhat earns itWeight
The verdict and exit code come from the group's conditionsverdict 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 completeis_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 lagformat_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 groupartefacts/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 statusThe 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
Total100

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.

Erase saved progress?

This erases all quiz scores, reading progress, project checklists, and your name on the certificate. It cannot be undone, and it affects only this course in this browser.