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):
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