brdecide — the failover predictor
Goal
(Optional — see the brief for the shorter jq route and what skipping it costs you.) Build a tool that takes a failover-group status plus a clock and prints the decision the operator would take — the action, the alert, the Reason string, and whether cooldown will let it run. Check your own mental model against the real table now, on your terms, instead of at 3am while an incident checks it for you.
Unit 2 — How the operator decides · project · code-notebook · Python
Optional. This is a drill, not a dependency: nothing in Units 3–7 requires you to have built
brdecide. What you give up by skipping it is the one thing reading cannot give you — being made to run the table on inputs you did not choose, including the four-site group, the writable reader that preemptsTotalLoss, and the history record stamped inside the future-clock grace. If you skip it, at least readtests/fixtures/and predict each verdict out loud before opening the harness’s expectations; that is the same exercise at a tenth of the cost.
Goal
Build a tool that takes a failover-group status plus a clock and prints the decision the operator
would take — the action, the alert, the Reason string, and whether cooldown will let it run. Check
your own mental model against the real table now, on your terms, instead of at 3am while an incident
checks it for you.
Learning goals
- Implement the cross-site evaluation table in evaluation order, fence-first return included.
- Separate the decision from its execution gate by applying
failoverCooldownonly where the operator does.
How this works
You have run the cross-site table in your head. Now write it down, where a machine can check it.
brdecide reads a MysqlFailoverGroup object — the same JSON kubectl get mysqlfailovergroup playground -o json gives you — plus a clock reading, and prints what the operator would do with it. Nothing
talks to a cluster. Every input is a fixture in tests/fixtures/, so the answers are reproducible and
you can check yours against a known one.
python starter/brdecide.py --status tests/fixtures/playground-iad-down.json --now 2026-08-12T12:04:16Z
python starter/brdecide.py --status tests/fixtures/playground-iad-down-cooldown.json --now 2026-08-12T12:04:16Z --json
The starter runs as given. It prints Reason (unset) and coreCount 0, because the four functions
that matter are empty.
The running example is playground: iad and pdx as primary-candidate, reader as
role: read-only. Some fixtures add a fourth site to make a point.
Your tasks
TODO A — the pre-pass. In tally(), walk the observations once. Increment coreCount for every
site whose role is not read-only — a dr-only site counts, an unknown state counts. Route any
site that is writable while its role is not primary-candidate into fenceSites and skip it, so it
never reaches a tally. Skip role: read-only sites entirely. Everything left lands in the tally for
its state; unknown lands in none of them.
TODO B — the rows. In evaluate_cross_site(), evaluate the rows in the order the code does and
return at the first one that fires: fence-first, TotalLoss, SplitBrain, Failover, NoPrimary, Degraded,
Healthy. Exact conditions, alert strings and Reason values are in the docstring. Two of them are
easy to get wrong: the failover row needs three conjuncts at once — zero writable and at least one
unreachable and at least one read-only — and it is the only acting row that sets no alert.
TODO C — the history. In rehydrate_last_failover(), choose between the two durable copies of
lastFailover: the status subresource and the bloodraven.shipstream.io/last-failover annotation.
Discard either one stamped more than 5 minutes ahead of now, install the later of what survives, and
give ties to status.
TODO D — the gate. In apply_gate(), build willRun. Fencing a writable non-promotable site runs
every poll. A promotion is the one thing failoverCooldown gates. Report cooldownRemaining whenever
there is a record to measure against, even when nothing is waiting on it.
Then run python3 tests/harness.py until it prints PASS.
What the scaffolding is for
Everything peripheral is wired: argument parsing, loading the object, the join of spec.sites[].role
to status.sites[].state (a site absent from status is unknown, not missing), Go duration parsing,
reading both history copies, rank_promotion_candidates(), and both output formats. The --json
output is what the tests read, so keep its keys as new_action() defines them.
status.conditions is in the fixtures because it is in a real object. It is the previous poll’s
verdict — sometimes stale by design. Do not read your answer out of it.
Expected output
brdecide — bloodraven-playground/playground at 2026-08-12T12:04:16Z
sites iad=unreachable(primary-candidate) pdx=read-only(primary-candidate) reader=read-only(read-only)
coreCount 2
tallies writable=[] readOnly=['pdx'] unreachable=['iad']
fenceSites -
Reason Degraded
Alert (none)
SplitBrain no
Candidates pdx (tiebreak order — GTID freshness picks the winner)
lastFailover 2026-08-12T12:02:46Z → pdx (from status)
cooldown 5m0s configured, 3m30s remaining
BLOCKED promotion blocked by cooldown
willRun (nothing)
Rules
evaluate_cross_site()stays pure. No clock, no history, no cooldown — the realEvalCrossSitenever consults any of them, and a test asserts yours does not either.- Use only the five reason strings the operator actually emits:
Healthy,Degraded,SplitBrain,NoPrimary,TotalLoss. The docs name a sixth,Failover. It does not exist. - Alert strings are verbatim, including the comma-space joins.
- Standard library only. Do not edit
tests/.
Steps
- Count the sites the way the matrix counts them — fill in TODO A. Done when
playground-healthy.jsonprints"coreCount": 2,"writable": ["iad"],"readOnly": ["pdx"];playground-reader-writable.jsonprints"fenceSites": ["reader"]withreaderin no tally; andplayground-dr-only.jsonprints"coreCount": 3with"readOnly": ["lhr"]. - Evaluate the rows in order, fence-first at the top — done when
playground-healthy.json,playground-peer-down.json,playground-split-brain.json,playground-total-loss.jsonandplayground-reader-writable-split-brain.jsonprintHealthy,Degraded,SplitBrain,TotalLossandDegraded, and the last prints"splitBrain": false. - Make the failover row demand all three conjuncts — done when
playground-iad-down.jsonprints"promotionCandidates": ["pdx"]with"alert": null,playground-all-read-only.jsonprintsNoPrimarywithNO PRIMARY: both sites are read-only, andplayground-dr-only.jsonprints"promotionCandidates": []. - Rehydrate the history from whichever copy survived — done when
playground-history-conflict.jsonprints"lastFailoverSource": "annotation", andplayground-history-skewed.jsonandplayground-history-tie.jsonboth print"lastFailoverSource": "status". - Gate the promotion, and nothing else — done when
playground-iad-down-cooldown.jsonprints"promotionBlockedBy": "cooldown","cooldownRemaining": 210.0,"willRun": []and still"promotionCandidates": ["pdx"]; andplayground-reader-writable-cooldown.jsonprints"willRun": ["fence:reader"]with"promotionBlockedBy": null. - Run the whole fixture set —
python3 tests/harness.pyexits 0 and printsPASS. - Record the reason string the docs get wrong — add a
# NOTE:comment tobrdecide.pynaming both theFailoverthe docs promise and theDegradedthe code emits.
Grading
See rubric.md for the four weighted criteria and what full marks look like on each.
The automated checks are the four testCases in project.json; they run the same functions
tests/harness.py runs.
Steps
How it is graded
| Criterion | What earns it | Weight |
|---|---|---|
| The rows are evaluated in the operator's order, with the fence-first return above everything | evaluate_cross_site() is a sequence of guarded early returns in the order fence-first, TotalLoss, SplitBrain, Failover, NoPrimary, Degraded, Healthy — not an unordered set of cases or a lookup keyed on a state tuple. Full marks require: the fence-first branch returning before the TotalLoss and SplitBrain rows can be reached; the failover row demanding zero writable, at least one unreachable and at least one read-only simultaneously; the failover row setting PromotionCandidates and Reason but no alert; both NoPrimary messages present with the two-site variant conditioned on exactly two read-only and zero unreachable; and every alert string reproduced verbatim, comma-space joins included. Deduct for any invented reason string outside the five the operator emits. | 35 |
| The cooldown gates the promotion and nothing else, and the history it measures against is rehydrated correctly | apply_gate() blocks only promote, and only when a promotion was selected, a record exists, and the elapsed time is under the cooldown; fence:<site> entries are emitted regardless of the cooldown state. cooldownRemaining is reported whenever a record exists, including when nothing is waiting on it, and negative elapsed time counts as inside the window. rehydrate_last_failover() discards a copy stamped more than five minutes ahead of now, installs the later of what survives, and gives ties to status. The candidate list and Reason survive a blocked promotion unchanged — the table still ran. | 25 |
| The pre-pass tallies sites by role the way the matrix does | tally() increments coreCount for every non-read-only role, so a dr-only site counts toward coreCount and lands in a tally while a read-only site does neither. A writable non-primary-candidate site goes to fenceSites and is skipped before any tally sees it. unknown sites count toward coreCount and appear in no tally. Deduct if dr-only is treated as a reader, if a fenced site is double-counted in the writable tally, or if the promotion candidate list can contain a non-primary-candidate site. | 20 |
| Craft: the decision is separated from its gate, and the output is readable under pressure | evaluate_cross_site() takes only observations and priorities and touches no clock, history or cooldown, so the table can be reasoned about and tested on its own. The default text report is scannable at 3am: the reason, the alert and what will actually run are each on their own labelled line, and a blocked promotion is visibly distinct from a decision with nothing to do. A malformed or unreadable --status file produces a one-line diagnostic on stderr and a non-zero exit, not a traceback. Comments explain the non-obvious rules — why the failover row sets no alert, why the tie goes to status — rather than restating the code. | 20 |
| Total | 100 |
Test cases
| Test | Checks | Expected | Weight |
|---|---|---|---|
| canonical_playground_decisions | The eight canonical states of playground — healthy, primary up with a peer down, primary gone with a live replica, a writable reader, a writable reader alongside two writable candidates, split brain, all read-only, and total loss — each produce the right Reason, the verbatim alert string, the right tallies and the right willRun. Includes a fixture whose status.conditions still carries the previous poll's stale Healthy reason, so echoing the condition back fails here. | PASS | 40 |
| awkward_topologies_and_history | Generality on inputs the two-site mental model does not cover: a four-site group with a dr-only replica that counts toward coreCount but can never be promoted, a writable reader that preempts TotalLoss, an all-unknown startup, a sitePriorities list that reorders the tiebreak, a site absent from status.sites entirely, and history records that are skewed, tied, or stamped inside the future-clock grace. | PASS | 20 |
| cooldown_gates_promotion_only | Catches a shortcut: wrapping the whole decision in the cooldown instead of the single call it actually guards. A writable reader ten seconds into a 30 s cooldown must still emit fence:reader — fencing a non-promotable site is not gated. A promotion ninety seconds into a 5 m cooldown must still report its Reason and candidate list while withholding only promote. Also catches reading lastFailover from the status copy alone: one fixture's annotation is an hour newer, and the status copy on its own would let a blocked promotion run. | PASS | 25 |
| table_stays_pure | Structural: evaluate_cross_site(observations, site_priorities) must still exist with exactly that signature, its executable body must reference no clock, cooldown or failover history, and calling it directly must return Degraded with candidates ['pdx'] and no alert. apply_gate() must be where the cooldown lives. This is the real EvalCrossSite contract — the function is pure, and split-brain resolution and the anti-flap gate are layered on by the caller. | PASS | 15 |
Starter files
1 files
brdecide.py
#!/usr/bin/env python3
"""brdecide — predict the cross-site decision the Bloodraven operator would take.
Feed it a MysqlFailoverGroup object (``kubectl get mysqlfailovergroup playground -o json``)
and a clock. It prints the action, the alert, the ``Reason`` string that reaches
``status.conditions``, and whether ``spec.failoverCooldown`` will let the promotion run.
python starter/brdecide.py --status tests/fixtures/playground-healthy.json
python starter/brdecide.py --status tests/fixtures/playground-iad-down-cooldown.json \
--now 2026-08-12T12:04:16Z --json
Everything peripheral is already wired: argument parsing, loading the object, joining
spec roles to status states, Go duration parsing, candidate ranking, and both output
formats. Four gaps are yours — TODO A, TODO B, TODO C, TODO D.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
# --- Grounded constants -----------------------------------------------------
# Site roles: api/v1alpha1/types.go — enum primary-candidate;dr-only;read-only.
ROLE_PRIMARY_CANDIDATE = "primary-candidate"
ROLE_DR_ONLY = "dr-only"
ROLE_READ_ONLY = "read-only"
# The four per-site states: internal/state/machine.go.
STATE_UNKNOWN = "unknown"
STATE_WRITABLE = "writable"
STATE_READ_ONLY = "read-only"
STATE_UNREACHABLE = "unreachable"
# spec.failoverCooldown default "5m"; the operator falls back to the same value
# when the pointer is nil.
DEFAULT_FAILOVER_COOLDOWN = 300.0
# FailoverClockSkewGrace = 5 * time.Minute.
FAILOVER_CLOCK_SKEW_GRACE = 300.0
# The two durable annotation keys, written as a pair by JSON merge patch.
LAST_FAILOVER_ANNOTATION = "bloodraven.shipstream.io/last-failover"
LAST_FAILOVER_TARGET_ANNOTATION = "bloodraven.shipstream.io/last-failover-target"
@dataclass(frozen=True)
class Observation:
"""One site at one poll cycle: its name, its configured role, its state."""
name: str
role: str
state: str
def new_action() -> dict:
"""The empty CrossSiteAction. Fill these keys in evaluate_cross_site()."""
return {
"coreCount": 0,
"writable": [],
"readOnly": [],
"unreachable": [],
"fenceSites": [],
"promotionCandidates": [],
"splitBrain": False,
"alert": None,
"reason": "",
}
# ===========================================================================
# TODO A — the pre-pass: coreCount, fence routing, the three tallies
# ===========================================================================
def tally(observations: list[Observation]) -> dict:
"""Walk the observations once and return the partial action.
Set ``coreCount``, ``fenceSites``, ``writable``, ``readOnly`` and
``unreachable`` (name lists, in observation order). Leave the rest alone.
Three rules, in this order, for every observation:
1. ``coreCount`` increments for every site whose role is **not**
``read-only``. A ``dr-only`` site counts. An ``unknown`` state still
counts — the site is part of the topology.
2. A site that is ``writable`` while its role is **not**
``primary-candidate`` goes to ``fenceSites`` and is skipped: it never
reaches a tally.
3. A site whose role is ``read-only`` is skipped entirely.
Everything that survives lands in the tally for its state. ``unknown``
sites land in no tally at all.
"""
action = new_action()
# TODO A: implement the three rules above.
return action
# ===========================================================================
# TODO B — the rows, in evaluation order
# ===========================================================================
def evaluate_cross_site(observations: list[Observation], site_priorities: list[str]) -> dict:
"""Return the CrossSiteAction for one poll cycle.
This function is **pure**: no clock, no failover history, no cooldown. It
mirrors EvalCrossSite in internal/state/matrix.go, which never considers
history or policy beyond the supplied priorities.
Evaluate the rows in this order and return at the first one that fires:
1. fence-first — ``fenceSites`` non-empty:
alert ``writable non-promotable site requires fencing (<sites>)``
(comma-space joined), reason ``Degraded``.
2. TotalLoss — ``len(unreachable) == coreCount``:
alert ``TOTAL LOSS: all sites are unreachable``, reason ``TotalLoss``.
3. SplitBrain — ``len(writable) > 1``: ``splitBrain`` True,
alert ``SPLIT BRAIN: <n> sites are writable (<sites>)``,
reason ``SplitBrain``.
4. Failover — ``len(writable) == 0`` and ``len(unreachable) > 0`` and
``len(readOnly) > 0`` and ranking yields at least one candidate:
set ``promotionCandidates``, reason ``Degraded``, **no alert**.
5. NoPrimary — still no writable site: reason ``NoPrimary``, alert
``NO PRIMARY: both sites are read-only`` when exactly two read-only
sites and zero unreachable, otherwise
``NO PRIMARY: no writable site available``.
6. Degraded — exactly one writable and at least one unreachable:
alert ``<unreachable sites> unreachable while <writable site> is primary``,
reason ``Degraded``.
7. Healthy — reason ``Healthy``, no alert.
Use rank_promotion_candidates() for row 4; it is already written.
"""
action = tally(observations)
# TODO B: evaluate the rows above, in order, and return at the first hit.
return action
# ===========================================================================
# TODO C — rehydrate the failover history from its two durable copies
# ===========================================================================
def rehydrate_last_failover(status_record, annotation_record, now):
"""Pick which durable copy of the failover history the operator believes.
``status_record`` and ``annotation_record`` are each ``(timestamp, target)``
with ``timestamp`` a timezone-aware datetime or None.
Return ``(timestamp, target, source)`` where ``source`` is ``"status"``,
``"annotation"`` or None.
* Discard either copy stamped more than FAILOVER_CLOCK_SKEW_GRACE ahead of
``now``. A future-dated record would wedge promotion indefinitely.
* Of what survives, install the **later** one.
* Ties go to status: equal timestamps describe the same promotion.
* Nothing left → ``(None, None, None)``.
"""
# TODO C: implement the skew guard, the later-copy rule and the tie rule.
return (None, None, None)
# ===========================================================================
# TODO D — the execution gate
# ===========================================================================
def apply_gate(action: dict, last_failover, now: datetime, cooldown: float) -> dict:
"""Decide what actually runs this poll, and report the cooldown timer.
Return a dict with exactly these three keys:
``willRun`` list of strings, in order: one ``fence:<site>``
per entry in ``action["fenceSites"]``, then
``promote`` when a promotion is selected and not
blocked.
``promotionBlockedBy`` ``"cooldown"`` or None.
``cooldownRemaining`` seconds left on the timer, as a float.
The cooldown is enforced in exactly one place: immediately before the
promotion call. So it can only ever block ``promote``. Fencing a writable
non-promotable site is not gated by it — that runs every poll.
Blocked when a promotion is selected, ``last_failover`` is set, and
``now - last_failover < cooldown``. Negative elapsed time (a record stamped
in the future but inside the skew grace) counts as still active.
``cooldownRemaining`` is ``max(0.0, cooldown - elapsed)`` whenever
``last_failover`` is set — report the timer even when nothing is waiting on
it — and ``0.0`` when it is not.
"""
# TODO D: build willRun, set promotionBlockedBy, compute cooldownRemaining.
return {"willRun": [], "promotionBlockedBy": None, "cooldownRemaining": 0.0}
# ===========================================================================
# Scaffolding below this line. You should not need to change any of it.
# ===========================================================================
_DURATION_RE = re.compile(r"(\d+(?:\.\d+)?)(ms|us|ns|h|m|s)")
_DURATION_UNITS = {
"ns": 1e-9,
"us": 1e-6,
"ms": 1e-3,
"s": 1.0,
"m": 60.0,
"h": 3600.0,
}
def parse_go_duration(text: str | None, default: float) -> float:
"""Parse a Go duration string such as "5m", "30s" or "1m30s" into seconds."""
if not text:
return default
parts = _DURATION_RE.findall(text)
if not parts:
raise ValueError(f"cannot parse duration {text!r}")
return sum(float(value) * _DURATION_UNITS[unit] for value, unit in parts)
def parse_rfc3339(text: str | None):
"""Parse an RFC3339 timestamp into a timezone-aware UTC datetime, or None."""
if not text:
return None
cleaned = text.strip().replace("Z", "+00:00")
parsed = datetime.fromisoformat(cleaned)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
def format_rfc3339(moment) -> str | None:
if moment is None:
return None
return moment.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def load_group(path: str) -> dict:
with open(path, "r", encoding="utf-8") as handle:
group = json.load(handle)
if group.get("kind") != "MysqlFailoverGroup":
raise ValueError(f"{path}: not a MysqlFailoverGroup object")
return group
def observations_from(group: dict) -> list[Observation]:
"""Join spec.sites (role) to status.sites (state), in declared site order.
A site the operator has not reported on yet is `unknown`, not missing.
"""
states = {
site.get("name"): site.get("state") or STATE_UNKNOWN
for site in group.get("status", {}).get("sites", [])
}
observations = []
for site in group.get("spec", {}).get("sites", []):
name = site.get("name")
if not name:
raise ValueError("spec.sites[] entry with no name")
observations.append(
Observation(
name=name,
role=site.get("role") or ROLE_PRIMARY_CANDIDATE,
state=states.get(name, STATE_UNKNOWN),
)
)
if len(observations) < 2:
raise ValueError("spec.sites has fewer than 2 entries")
return observations
def read_history(group: dict):
"""Return the two durable copies as ((ts, target), (ts, target))."""
status = group.get("status", {})
annotations = group.get("metadata", {}).get("annotations", {}) or {}
status_record = (
parse_rfc3339(status.get("lastFailover")),
status.get("lastFailoverTarget") or None,
)
annotation_record = (
parse_rfc3339(annotations.get(LAST_FAILOVER_ANNOTATION)),
annotations.get(LAST_FAILOVER_TARGET_ANNOTATION) or None,
)
return status_record, annotation_record
def rank_promotion_candidates(read_only: list[str], observations: list[Observation],
site_priorities: list[str]) -> list[str]:
"""Order the read-only primary-candidates: sitePriorities first, then declared order.
Mirrors RankPromotionCandidates. Non-primary-candidate sites are dropped, so a
`dr-only` replica is never returned. This list is the **tiebreaker** only:
the operator ranks it by GTID freshness before promoting anything.
"""
roles = {obs.name: obs.role for obs in observations}
eligible = [name for name in read_only if roles.get(name) == ROLE_PRIMARY_CANDIDATE]
out: list[str] = []
for name in site_priorities or []:
if name in eligible and name not in out:
out.append(name)
for name in eligible:
if name not in out:
out.append(name)
return out
def decide(group: dict, now: datetime) -> dict:
"""Run the table, rehydrate the history, apply the gate. The whole tool."""
observations = observations_from(group)
spec = group.get("spec", {})
site_priorities = (spec.get("splitBrainPolicy") or {}).get("sitePriorities") or []
cooldown = parse_go_duration(spec.get("failoverCooldown"), DEFAULT_FAILOVER_COOLDOWN)
action = evaluate_cross_site(observations, site_priorities)
status_record, annotation_record = read_history(group)
last_failover, last_target, source = rehydrate_last_failover(
status_record, annotation_record, now
)
gate = apply_gate(action, last_failover, now, cooldown)
meta = group.get("metadata", {})
report = {
"group": f"{meta.get('namespace', 'default')}/{meta.get('name', '?')}",
"now": format_rfc3339(now),
"sites": [{"name": o.name, "role": o.role, "state": o.state} for o in observations],
}
report.update(action)
report.update(
{
"lastFailover": format_rfc3339(last_failover),
"lastFailoverTarget": last_target,
"lastFailoverSource": source,
"cooldown": cooldown,
}
)
report.update(gate)
return report
def _human_seconds(seconds: float) -> str:
seconds = float(seconds)
if seconds >= 60:
minutes, rest = divmod(seconds, 60)
return f"{int(minutes)}m{rest:g}s"
return f"{seconds:g}s"
def render_text(report: dict) -> str:
lines = [f"brdecide — {report['group']} at {report['now']}", ""]
sites = " ".join(f"{s['name']}={s['state']}({s['role']})" for s in report["sites"])
lines.append(f" sites {sites}")
lines.append(f" coreCount {report['coreCount']}")
lines.append(
" tallies writable={} readOnly={} unreachable={}".format(
report["writable"] or "[]", report["readOnly"] or "[]",
report["unreachable"] or "[]",
)
)
lines.append(f" fenceSites {report['fenceSites'] or '-'}")
lines.append("")
lines.append(f" Reason {report['reason'] or '(unset)'}")
lines.append(f" Alert {report['alert'] or '(none)'}")
lines.append(f" SplitBrain {'yes' if report['splitBrain'] else 'no'}")
candidates = report["promotionCandidates"]
if candidates:
lines.append(
" Candidates {} (tiebreak order — GTID freshness picks the winner)".format(
", ".join(candidates)
)
)
else:
lines.append(" Candidates -")
lines.append("")
if report["lastFailover"]:
lines.append(
" lastFailover {} → {} (from {})".format(
report["lastFailover"], report["lastFailoverTarget"] or "?",
report["lastFailoverSource"],
)
)
lines.append(
" cooldown {} configured, {} remaining".format(
_human_seconds(report["cooldown"]),
_human_seconds(report["cooldownRemaining"]),
)
)
else:
lines.append(" lastFailover (none recorded)")
lines.append(f" cooldown {_human_seconds(report['cooldown'])} configured")
if report["promotionBlockedBy"]:
lines.append(f" BLOCKED promotion blocked by {report['promotionBlockedBy']}")
lines.append(f" willRun {', '.join(report['willRun']) or '(nothing)'}")
return "\n".join(lines)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--status", required=True,
help="path to a MysqlFailoverGroup JSON object")
parser.add_argument("--now", default=None,
help="RFC3339 clock reading (default: the real clock)")
parser.add_argument("--json", action="store_true",
help="emit the decision as JSON instead of a report")
args = parser.parse_args(argv)
try:
group = load_group(args.status)
now = parse_rfc3339(args.now) or datetime.now(timezone.utc)
report = decide(group, now)
except (OSError, ValueError, KeyError) as err:
print(f"brdecide: {err}", file=sys.stderr)
return 2
print(json.dumps(report, indent=2) if args.json else render_text(report))
return 0
if __name__ == "__main__":
sys.exit(main())
Projects are not auto-graded here. The rubric and the test cases above are the grading contract — run them yourself on your own machine.