site_live_check

Check what marola.dev actually serves, not what the build believed it wrote.

Two silent failures got past everything else and were both found by a human looking at the page: docs/ sat on the site-data branch for hours while /docs 404'd, and every Florianópolis beach read "no data" while the board still named IMA/SC as its source. Both builds were green.

The gap is that every existing gate checks inputs — fixtures, schemas, a stub DOM. Nothing looked at the published result. This does, and it is deliberately about the data being present, not about HTTP 200s.

scripts/site_live_check.py                     # check https://marola.dev
scripts/site_live_check.py --base http://localhost:8000
scripts/site_live_check.py --self-test
  1#!/usr/bin/env python3
  2"""Check what marola.dev actually serves, not what the build believed it wrote.
  3
  4Two silent failures got past everything else and were both found by a human looking at the page:
  5docs/ sat on the site-data branch for hours while /docs 404'd, and every Florianópolis beach read
  6"no data" while the board still named IMA/SC as its source. Both builds were green.
  7
  8The gap is that every existing gate checks inputs — fixtures, schemas, a stub DOM. Nothing looked
  9at the published result. This does, and it is deliberately about the *data being present*, not
 10about HTTP 200s.
 11
 12    scripts/site_live_check.py                     # check https://marola.dev
 13    scripts/site_live_check.py --base http://localhost:8000
 14    scripts/site_live_check.py --self-test
 15"""
 16
 17from __future__ import annotations
 18
 19import argparse
 20import json
 21import sys
 22import urllib.error
 23import urllib.request
 24
 25DEFAULT_BASE = "https://marola.dev"
 26SCHEMA = 1
 27# A beach that must exist and must carry water data, per area with a provider. Named rather than
 28# "any beach", so a board that silently shrinks to two beaches still fails.
 29CANARIES = {"floripa": "Praia do Campeche"}
 30
 31
 32def fetch(url: str, timeout: int = 30):
 33    with urllib.request.urlopen(url, timeout=timeout) as r:  # noqa: S310 - fixed https base
 34        return json.loads(r.read().decode("utf-8"))
 35
 36
 37def check_board(area: str, day: str, board: dict) -> list[str]:
 38    """Every invariant that should hold for one published day, as failure strings."""
 39    bad = []
 40    where = f"{area}/{day}"
 41    if board.get("schema") != SCHEMA:
 42        bad.append(f"{where}: schema {board.get('schema')}, the page understands {SCHEMA}")
 43    beaches = board.get("beaches") or []
 44    if not beaches:
 45        bad.append(f"{where}: no beaches")
 46        return bad
 47
 48    water_source = (board.get("sources") or {}).get("water")
 49    if water_source:
 50        # The label SiteBuilder writes when the provider returned nothing — the outage marker.
 51        if "no data returned" in water_source:
 52            bad.append(f"{where}: water provider {water_source!r} returned nothing")
 53        with_water = [b for b in beaches if (b.get("water") or {}).get("points")]
 54        if not with_water:
 55            bad.append(
 56                f"{where}: sources.water is {water_source!r} but no beach has a sampling point"
 57            )
 58
 59    canary = CANARIES.get(area)
 60    if canary:
 61        named = [b for b in beaches if b.get("name") == canary]
 62        if not named:
 63            bad.append(f"{where}: {canary} is missing from the board")
 64        elif water_source and not (named[0].get("water") or {}).get("points"):
 65            bad.append(f"{where}: {canary} has no water sampling point")
 66
 67    for b in beaches:
 68        if not (b.get("best") or {}).get("hour"):
 69            bad.append(f"{where}: {b.get('name')} has no best hour")
 70            break
 71    return bad
 72
 73
 74def check_site(base: str, get=fetch) -> list[str]:
 75    bad = []
 76    try:
 77        areas = get(f"{base}/data/areas.json")["areas"]
 78    except Exception as e:  # noqa: BLE001 - any failure here is the same story
 79        return [f"cannot read {base}/data/areas.json: {e}"]
 80    if not areas:
 81        return [f"{base}: areas.json lists no areas"]
 82    for a in areas:
 83        area = a["id"]
 84        try:
 85            latest = get(f"{base}/data/{area}/latest.json")
 86            for d in latest.get("days") or []:
 87                board = get(f"{base}/data/{area}/{d['file']}")
 88                bad += check_board(area, d["day"], board)
 89        except Exception as e:  # noqa: BLE001
 90            bad.append(f"{area}: cannot read a board: {e}")
 91    return bad
 92
 93
 94def self_test() -> int:
 95    fails = 0
 96
 97    def ok(got, want, label):
 98        nonlocal fails
 99        if got == want:
100            print(f"  ok   {label}")
101        else:
102            fails += 1
103            print(f"  FAIL {label} — got {got!r}, want {want!r}")
104
105    good = {
106        "schema": 1,
107        "sources": {"water": "IMA/SC"},
108        "beaches": [
109            {
110                "name": "Praia do Campeche",
111                "best": {"hour": "10:00"},
112                "water": {"points": [{"id": "P06"}]},
113            }
114        ],
115    }
116    ok(check_board("floripa", "d", good), [], "a healthy board passes")
117
118    # The exact shape marola.dev served while IMA was down: a named provider, zero points.
119    blank = json.loads(json.dumps(good))
120    blank["beaches"][0]["water"]["points"] = []
121    msgs = check_board("floripa", "d", blank)
122    ok(len(msgs), 2, "a provider with no points fails, and the canary fails too")
123    ok(
124        any("no beach has a sampling point" in m for m in msgs),
125        True,
126        "and names the provider it expected points from",
127    )
128
129    outage = json.loads(json.dumps(blank))
130    outage["sources"]["water"] = "IMA/SC (no data returned)"
131    ok(
132        any("returned nothing" in m for m in check_board("floripa", "d", outage)),
133        True,
134        "SiteBuilder's own outage label is recognised",
135    )
136
137    nowater = json.loads(json.dumps(good))
138    nowater["sources"]["water"] = None
139    nowater["beaches"][0]["water"]["points"] = []
140    ok(
141        check_board("rio", "d", nowater),
142        [],
143        "an area with no water provider is not expected to have points",
144    )
145
146    missing = json.loads(json.dumps(good))
147    missing["beaches"][0]["name"] = "Somewhere Else"
148    ok(
149        any("missing from the board" in m for m in check_board("floripa", "d", missing)),
150        True,
151        "the canary beach disappearing is a failure, not a shrug",
152    )
153
154    ok(
155        any("schema" in m for m in check_board("floripa", "d", {**good, "schema": 2})),
156        True,
157        "a schema the page cannot read fails",
158    )
159    ok(
160        any("no beaches" in m for m in check_board("x", "d", {"schema": 1})),
161        True,
162        "an empty board fails",
163    )
164
165    def broken(_url):
166        raise urllib.error.URLError("down")
167
168    ok(
169        len(check_site("https://example.invalid", get=broken)),
170        1,
171        "an unreachable site is one clear failure",
172    )
173
174    if fails:
175        print(f"site_live_check self-test: {fails} failure(s)", file=sys.stderr)
176        return 1
177    print("site_live_check self-test: ok")
178    return 0
179
180
181def main(argv=None) -> int:
182    ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
183    ap.add_argument("--base", default=DEFAULT_BASE)
184    ap.add_argument("--self-test", action="store_true")
185    args = ap.parse_args(argv)
186    if args.self_test:
187        return self_test()
188
189    bad = check_site(args.base.rstrip("/"))
190    if not bad:
191        print(f"site_live_check: {args.base} ok")
192        return 0
193    print(f"site_live_check: {len(bad)} problem(s) at {args.base}", file=sys.stderr)
194    for m in bad:
195        print(f"  - {m}", file=sys.stderr)
196    return 1
197
198
199if __name__ == "__main__":
200    sys.exit(main())
DEFAULT_BASE = 'https://marola.dev'
SCHEMA = 1
CANARIES = {'floripa': 'Praia do Campeche'}
def fetch(url: str, timeout: int = 30):
33def fetch(url: str, timeout: int = 30):
34    with urllib.request.urlopen(url, timeout=timeout) as r:  # noqa: S310 - fixed https base
35        return json.loads(r.read().decode("utf-8"))
def check_board(area: str, day: str, board: dict) -> list[str]:
38def check_board(area: str, day: str, board: dict) -> list[str]:
39    """Every invariant that should hold for one published day, as failure strings."""
40    bad = []
41    where = f"{area}/{day}"
42    if board.get("schema") != SCHEMA:
43        bad.append(f"{where}: schema {board.get('schema')}, the page understands {SCHEMA}")
44    beaches = board.get("beaches") or []
45    if not beaches:
46        bad.append(f"{where}: no beaches")
47        return bad
48
49    water_source = (board.get("sources") or {}).get("water")
50    if water_source:
51        # The label SiteBuilder writes when the provider returned nothing — the outage marker.
52        if "no data returned" in water_source:
53            bad.append(f"{where}: water provider {water_source!r} returned nothing")
54        with_water = [b for b in beaches if (b.get("water") or {}).get("points")]
55        if not with_water:
56            bad.append(
57                f"{where}: sources.water is {water_source!r} but no beach has a sampling point"
58            )
59
60    canary = CANARIES.get(area)
61    if canary:
62        named = [b for b in beaches if b.get("name") == canary]
63        if not named:
64            bad.append(f"{where}: {canary} is missing from the board")
65        elif water_source and not (named[0].get("water") or {}).get("points"):
66            bad.append(f"{where}: {canary} has no water sampling point")
67
68    for b in beaches:
69        if not (b.get("best") or {}).get("hour"):
70            bad.append(f"{where}: {b.get('name')} has no best hour")
71            break
72    return bad

Every invariant that should hold for one published day, as failure strings.

def check_site(base: str, get=<function fetch>) -> list[str]:
75def check_site(base: str, get=fetch) -> list[str]:
76    bad = []
77    try:
78        areas = get(f"{base}/data/areas.json")["areas"]
79    except Exception as e:  # noqa: BLE001 - any failure here is the same story
80        return [f"cannot read {base}/data/areas.json: {e}"]
81    if not areas:
82        return [f"{base}: areas.json lists no areas"]
83    for a in areas:
84        area = a["id"]
85        try:
86            latest = get(f"{base}/data/{area}/latest.json")
87            for d in latest.get("days") or []:
88                board = get(f"{base}/data/{area}/{d['file']}")
89                bad += check_board(area, d["day"], board)
90        except Exception as e:  # noqa: BLE001
91            bad.append(f"{area}: cannot read a board: {e}")
92    return bad
def self_test() -> int:
 95def self_test() -> int:
 96    fails = 0
 97
 98    def ok(got, want, label):
 99        nonlocal fails
100        if got == want:
101            print(f"  ok   {label}")
102        else:
103            fails += 1
104            print(f"  FAIL {label} — got {got!r}, want {want!r}")
105
106    good = {
107        "schema": 1,
108        "sources": {"water": "IMA/SC"},
109        "beaches": [
110            {
111                "name": "Praia do Campeche",
112                "best": {"hour": "10:00"},
113                "water": {"points": [{"id": "P06"}]},
114            }
115        ],
116    }
117    ok(check_board("floripa", "d", good), [], "a healthy board passes")
118
119    # The exact shape marola.dev served while IMA was down: a named provider, zero points.
120    blank = json.loads(json.dumps(good))
121    blank["beaches"][0]["water"]["points"] = []
122    msgs = check_board("floripa", "d", blank)
123    ok(len(msgs), 2, "a provider with no points fails, and the canary fails too")
124    ok(
125        any("no beach has a sampling point" in m for m in msgs),
126        True,
127        "and names the provider it expected points from",
128    )
129
130    outage = json.loads(json.dumps(blank))
131    outage["sources"]["water"] = "IMA/SC (no data returned)"
132    ok(
133        any("returned nothing" in m for m in check_board("floripa", "d", outage)),
134        True,
135        "SiteBuilder's own outage label is recognised",
136    )
137
138    nowater = json.loads(json.dumps(good))
139    nowater["sources"]["water"] = None
140    nowater["beaches"][0]["water"]["points"] = []
141    ok(
142        check_board("rio", "d", nowater),
143        [],
144        "an area with no water provider is not expected to have points",
145    )
146
147    missing = json.loads(json.dumps(good))
148    missing["beaches"][0]["name"] = "Somewhere Else"
149    ok(
150        any("missing from the board" in m for m in check_board("floripa", "d", missing)),
151        True,
152        "the canary beach disappearing is a failure, not a shrug",
153    )
154
155    ok(
156        any("schema" in m for m in check_board("floripa", "d", {**good, "schema": 2})),
157        True,
158        "a schema the page cannot read fails",
159    )
160    ok(
161        any("no beaches" in m for m in check_board("x", "d", {"schema": 1})),
162        True,
163        "an empty board fails",
164    )
165
166    def broken(_url):
167        raise urllib.error.URLError("down")
168
169    ok(
170        len(check_site("https://example.invalid", get=broken)),
171        1,
172        "an unreachable site is one clear failure",
173    )
174
175    if fails:
176        print(f"site_live_check self-test: {fails} failure(s)", file=sys.stderr)
177        return 1
178    print("site_live_check self-test: ok")
179    return 0
def main(argv=None) -> int:
182def main(argv=None) -> int:
183    ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
184    ap.add_argument("--base", default=DEFAULT_BASE)
185    ap.add_argument("--self-test", action="store_true")
186    args = ap.parse_args(argv)
187    if args.self_test:
188        return self_test()
189
190    bad = check_site(args.base.rstrip("/"))
191    if not bad:
192        print(f"site_live_check: {args.base} ok")
193        return 0
194    print(f"site_live_check: {len(bad)} problem(s) at {args.base}", file=sys.stderr)
195    for m in bad:
196        print(f"  - {m}", file=sys.stderr)
197    return 1