diff --git a/README.md b/README.md index ad866e0..58ec338 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ A local-first community sensor network: Nodes share readings over a local Mesh t scripts/sandbox.sh # needs uv and pnpm; the first run downloads the Cache Pack and builds the dashboard ``` -It opens `http://localhost:8000`. Pick the Bhote Koshi Event in the Sandbox Panel and press Start, or jump straight to "Surge reaches the border". +It opens `http://127.0.0.1:8000`. Pick the Bhote Koshi Event in the Sandbox Panel and press Start, or jump straight to "Surge reaches the border". ## How it's built diff --git a/node/AGENTS.md b/node/AGENTS.md index 2f060fa..3bf51f7 100644 --- a/node/AGENTS.md +++ b/node/AGENTS.md @@ -32,5 +32,6 @@ Area gotchas that are still true. Claude Code loads this file (through `CLAUDE.m - **Mesh decode and delivery go through `mesh.decode_message` and `deliver_to_listeners`** for both Meshes; add rules there. - **A Sensor sends an Event's extra `tracks` (made-up Events only) only when `NODE_MEASURES` names the kind**; the default names the flood four, so only the Sandbox (`SANDBOX_MEASURES`) or a Node told so sends `air_temperature`, `humidity`, `wind_speed`. - **A Camera is a Sensor whose `NODE_MEASURES` names `camera`**: each share bumps its picture number and redraws its one in-memory SVG (`camera.py`, fed only a water depth, so nothing about where it is can leak). The Sandbox Hub reads Sensors' pictures in-process at `/api/sandbox/sensors//picture`; `MeshedNodes.record_readings()` hears what is on the Mesh. +- **App file locations live in `app_files.py`** (dashboard, Regions, built and downloaded Cache Packs); a test that needs other packs patches `sandbox.BUILT_PACKS_DIR` / `DOWNLOADED_PACKS_DIR`. `storm-shield-sandbox-app` is the no-argument app start: port 8000 or a free one, log and failure page in the temp folder; uvicorn exits with `SystemExit` when it cannot bind, so the app catches that too. Networking and Pi lessons (firewalls, TCP peers) are in `pi/AGENTS.md`. diff --git a/node/pyproject.toml b/node/pyproject.toml index aef53cb..5fae047 100644 --- a/node/pyproject.toml +++ b/node/pyproject.toml @@ -16,6 +16,7 @@ dev = ["pytest", "ruff", "mypy"] [project.scripts] storm-shield-node = "storm_shield.main:run" storm-shield-sandbox = "storm_shield.sandbox:run" +storm-shield-sandbox-app = "storm_shield.sandbox_app:main" [build-system] requires = ["hatchling"] diff --git a/node/src/storm_shield/app_files.py b/node/src/storm_shield/app_files.py new file mode 100644 index 0000000..3de37fa --- /dev/null +++ b/node/src/storm_shield/app_files.py @@ -0,0 +1,24 @@ +"""Where the app's own files live: the built dashboard, the Regions and the Cache Packs. + +A repo checkout keeps them beside `node/`; a bundled app (PyInstaller) unpacks them under +`sys._MEIPASS`. Everything that needs one of these folders asks here, so the bundle needs no +other change. +""" + +import sys +from pathlib import Path + + +def _app_root() -> Path: + bundle = getattr(sys, "_MEIPASS", None) + if getattr(sys, "frozen", False) and bundle: + return Path(bundle) + return Path(__file__).resolve().parents[3] + + +APP_ROOT = _app_root() +DASHBOARD_DIR = APP_ROOT / "dashboard" / "dist" +REGIONS_DIR = APP_ROOT / "regions" +# Cache Packs: the one a build command wrote, and the one downloaded from a Release on an earlier run. +BUILT_PACKS_DIR = APP_ROOT / "node" / "build" +DOWNLOADED_PACKS_DIR = APP_ROOT / ".demo" / "pack" diff --git a/node/src/storm_shield/region.py b/node/src/storm_shield/region.py index 6a05c8b..1591851 100644 --- a/node/src/storm_shield/region.py +++ b/node/src/storm_shield/region.py @@ -5,8 +5,7 @@ from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator, model_validator -# Resolved from this file, not the cwd, so the Node starts from any directory. -REGIONS_DIR = Path(__file__).resolve().parents[3] / "regions" +from storm_shield.app_files import REGIONS_DIR as REGIONS_DIR # noqa: PLC0414 (re-exported: tests and scripts read it here) Hazard = Literal["flash_flood", "storm_surge"] diff --git a/node/src/storm_shield/sandbox.py b/node/src/storm_shield/sandbox.py index b762d9a..5be584f 100644 --- a/node/src/storm_shield/sandbox.py +++ b/node/src/storm_shield/sandbox.py @@ -20,6 +20,7 @@ from fastapi.testclient import TestClient from starlette.types import Receive, Scope, Send +from storm_shield.app_files import APP_ROOT, BUILT_PACKS_DIR, DASHBOARD_DIR, DOWNLOADED_PACKS_DIR from storm_shield.cache_pack import CachePack, InvalidCachePack, download_source from storm_shield.community_reading import Position from storm_shield.main import create_app @@ -39,7 +40,7 @@ SuggestedSite, UnknownSensor, ) -from storm_shield.settings import Settings +from storm_shield.settings import LOOPBACK_HOST, Settings logger = logging.getLogger(__name__) @@ -51,8 +52,7 @@ # it towards the spec's 1 km. SANDBOX_RADIO_RANGE_M = 40_000.0 DOWNLOAD_TIMEOUT_SECONDS = 120 -REPO = Path(__file__).resolve().parents[3] -BUILT_DASHBOARD = REPO / "dashboard" / "dist" / "index.html" +BUILT_DASHBOARD = DASHBOARD_DIR / "index.html" class NodeSite(NamedTuple): @@ -558,10 +558,10 @@ def pack_for(region: str, client: httpx.Client, override: str = "") -> Path: """The Region's Cache Pack: the one its build command wrote, else the one fetched from its Release on an earlier run, else the Release itself. Says which command to run when none is there.""" - built = REPO / "node" / "build" / region / "cache-pack.zip" + built = BUILT_PACKS_DIR / region / "cache-pack.zip" if built.is_file(): return built - downloaded = REPO / ".demo" / "pack" / region / "cache-pack.zip" + downloaded = DOWNLOADED_PACKS_DIR / region / "cache-pack.zip" if downloaded.is_file(): return downloaded build_command = f"scripts/build-cache-pack.sh {region}" @@ -594,7 +594,7 @@ def build_dashboard_if_missing() -> None: if BUILT_DASHBOARD.is_file(): return logger.info("building the dashboard (once)") - dashboard = REPO / "dashboard" + dashboard = APP_ROOT / "dashboard" try: for command in (["pnpm", "install", "--frozen-lockfile"], ["pnpm", "build"]): subprocess.run(command, cwd=dashboard, check=True, capture_output=True) @@ -628,21 +628,32 @@ def run() -> None: ) arguments = parser.parse_args() try: - with httpx.Client(timeout=DOWNLOAD_TIMEOUT_SECONDS, follow_redirects=True) as client: - cache_pack_url = Settings().cache_pack_url - - def find_pack(region: str) -> Path: - return pack_for(region, client, cache_pack_url) - - pack = arguments.pack or find_pack(arguments.region) - build_dashboard_if_missing() - with Sandbox(pack, arguments.region, find_pack=find_pack) as sandbox: - server = uvicorn.Server(uvicorn.Config(sandbox.hub, port=arguments.port)) - url = f"http://localhost:{arguments.port}" - logger.info("Sandbox dashboard: %s (everything in it is SIMULATED)", url) - threading.Thread( - target=_open_browser_when_up, args=(server, url), daemon=True - ).start() - server.run() + serve(arguments.port, arguments.region, arguments.pack) except SandboxError as error: raise SystemExit(f"storm-shield-sandbox: {error}") from None + + +def sandbox_server_config(app: Any, port: int) -> uvicorn.Config: + """Always loopback: the Sandbox is for the one person at this computer, and nothing here or + on the command line widens it.""" + # No log_config: uvicorn's own sends its lines to stderr, which the app has no terminal for; + # without it they reach the root logger, and so the app's log file. + return uvicorn.Config(app, host=LOOPBACK_HOST, port=port, log_config=None) + + +def serve(port: int, region: str, pack: Path | None = None) -> None: + """Start the Sandbox on `region`, open the browser once it answers, and serve until stopped.""" + with httpx.Client(timeout=DOWNLOAD_TIMEOUT_SECONDS, follow_redirects=True) as client: + cache_pack_url = Settings().cache_pack_url + + def find_pack(region: str) -> Path: + return pack_for(region, client, cache_pack_url) + + pack = pack or find_pack(region) + build_dashboard_if_missing() + with Sandbox(pack, region, find_pack=find_pack) as sandbox: + server = uvicorn.Server(sandbox_server_config(sandbox.hub, port)) + url = f"http://{LOOPBACK_HOST}:{port}" + logger.info("Sandbox dashboard: %s (everything in it is SIMULATED)", url) + threading.Thread(target=_open_browser_when_up, args=(server, url), daemon=True).start() + server.run() diff --git a/node/src/storm_shield/sandbox_app.py b/node/src/storm_shield/sandbox_app.py new file mode 100644 index 0000000..fd1de79 --- /dev/null +++ b/node/src/storm_shield/sandbox_app.py @@ -0,0 +1,113 @@ +"""The Sandbox as a double-clickable app: no arguments, and any problem ends up on a page. + +Someone who opens it has no terminal, so it picks the port itself, opens the dashboard of a +Sandbox that is already running instead of starting a second one, logs to a file in the temp +folder and, when it cannot start, opens a page that says why. +""" + +import html +import logging +import socket +import sys +import tempfile +import traceback +import webbrowser +from pathlib import Path + +import httpx + +from storm_shield.settings import LOOPBACK_HOST, Settings + +logger = logging.getLogger(__name__) + +PREFERRED_PORT = 8000 +LOG_FILE_NAME = "storm-shield-sandbox.log" +FAILURE_PAGE_NAME = "storm-shield-sandbox-failed.html" + + +def log_file() -> Path: + return Path(tempfile.gettempdir()) / LOG_FILE_NAME + + +def sandbox_is_running_on(port: int) -> bool: + try: + state = httpx.get(f"http://{LOOPBACK_HOST}:{port}/api/state", timeout=2).json() + return bool(state["sandbox"]) + except (httpx.HTTPError, ValueError, KeyError, TypeError): + return False + + +def _bindable(port: int) -> bool: + with socket.socket() as probe: + try: + probe.bind((LOOPBACK_HOST, port)) + except OSError: + return False + return True + + +def free_port() -> int: + with socket.socket() as probe: + probe.bind((LOOPBACK_HOST, 0)) + return int(probe.getsockname()[1]) + + +class _LastServerError(logging.Handler): + """Remembers uvicorn's last error line: when it cannot bind it exits with just a number, and + the reason ("address already in use") is only in what it logged.""" + + def __init__(self) -> None: + super().__init__(level=logging.ERROR) + self.message = "" + + def emit(self, record: logging.LogRecord) -> None: + self.message = record.getMessage() + + +def _reason(error: BaseException, server_error: str) -> str: + if isinstance(error, SystemExit): + return server_error or f"the server stopped while starting (exit code {error.code})" + return str(error) or type(error).__name__ + + +def write_failure_page(reason: str, log: Path) -> Path: + # A fresh folder, so a file planted at a predictable name in the shared temp folder is not followed. + page = Path(tempfile.mkdtemp(prefix="storm-shield-")) / FAILURE_PAGE_NAME + page.write_text( + "Storm Shield Sandbox could not start" + "

Storm Shield Sandbox could not start

" + f"

{html.escape(reason)}

" + f"

The full log is at {html.escape(str(log))}

", + encoding="utf-8", + ) + return page + + +def main() -> None: + log = log_file() + logging.basicConfig(level=logging.INFO, filename=log, force=True) + logging.getLogger("httpx").setLevel(logging.WARNING) + last_server_error = _LastServerError() + logging.getLogger("uvicorn.error").addHandler(last_server_error) + port = PREFERRED_PORT + # Whatever went wrong, the person double-clicking has no terminal: it all has to reach the page. + # That includes importing the Sandbox, which reads its Region, and uvicorn exiting with + # SystemExit when it cannot bind. + try: + if sandbox_is_running_on(PREFERRED_PORT): + webbrowser.open(f"http://{LOOPBACK_HOST}:{PREFERRED_PORT}") + return + from storm_shield.sandbox import serve + + port = PREFERRED_PORT if _bindable(PREFERRED_PORT) else free_port() + serve(port, Settings().region) + except (Exception, SystemExit) as error: # noqa: BLE001 + logger.error("the Sandbox could not start\n%s", traceback.format_exc()) + # Two quick launches both find 8000 free and the slower one loses the bind: the winner is + # the Sandbox to open. + if port == PREFERRED_PORT and sandbox_is_running_on(PREFERRED_PORT): + webbrowser.open(f"http://{LOOPBACK_HOST}:{PREFERRED_PORT}") + return + reason = _reason(error, last_server_error.message) + webbrowser.open(write_failure_page(reason, log).as_uri()) + sys.exit(1) diff --git a/node/src/storm_shield/settings.py b/node/src/storm_shield/settings.py index 0674574..2cea3a1 100644 --- a/node/src/storm_shield/settings.py +++ b/node/src/storm_shield/settings.py @@ -4,6 +4,11 @@ from pydantic import Field from pydantic_settings import BaseSettings, SettingsConfigDict +from storm_shield.app_files import DASHBOARD_DIR + +# The Sandbox listens here and nowhere wider: it is for the one person at this computer. +LOOPBACK_HOST = "127.0.0.1" + Role = Literal["sensor", "hub"] MeshKind = Literal["reticulum", "memory"] ReticulumInterface = Literal["auto", "loopback", "tcp"] @@ -26,8 +31,8 @@ class Settings(BaseSettings): # Kilometres down the river from the Lhende-Kyirong confluence (negative above it); blank for a # Node that is not on the river, which then cannot start a Community Warning. river_km: float | None = None - # Resolved from this file, not the cwd, so the Node starts from any directory. - dashboard_dir: Path = Path(__file__).resolve().parents[3] / "dashboard" / "dist" + # Resolved from the app's files, not the cwd, so the Node starts from any directory. + dashboard_dir: Path = DASHBOARD_DIR # "memory" is a Node that hears only itself; tests and the dashboard dev server use it. mesh: MeshKind = "reticulum" # "auto" finds Nodes on the local Wi-Fi (the demo). "loopback" joins Nodes on this one machine, diff --git a/node/tests/test_sandbox.py b/node/tests/test_sandbox.py index 8694a2e..351c729 100644 --- a/node/tests/test_sandbox.py +++ b/node/tests/test_sandbox.py @@ -116,7 +116,10 @@ def test_a_node_outside_the_sandbox_reports_no_sandbox(meshed_nodes: MeshedNodes @pytest.fixture def repo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: """An empty checkout, so a test decides which packs are on disk.""" - monkeypatch.setattr(sandbox_module, "REPO", tmp_path / "repo") + monkeypatch.setattr(sandbox_module, "BUILT_PACKS_DIR", tmp_path / "repo" / "node" / "build") + monkeypatch.setattr( + sandbox_module, "DOWNLOADED_PACKS_DIR", tmp_path / "repo" / ".demo" / "pack" + ) return tmp_path / "repo" diff --git a/node/tests/test_sandbox_app.py b/node/tests/test_sandbox_app.py new file mode 100644 index 0000000..2c3f8ed --- /dev/null +++ b/node/tests/test_sandbox_app.py @@ -0,0 +1,209 @@ +import logging +import os +import subprocess +import sys +import tempfile +import threading +import webbrowser +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path + +import pytest + +from storm_shield import app_files, sandbox_app +from storm_shield import sandbox as sandbox_module +from storm_shield.sandbox import sandbox_server_config +from storm_shield.settings import LOOPBACK_HOST + +_log = logging.getLogger("storm_shield.sandbox_app") + + +class _Answers(BaseHTTPRequestHandler): + body = b'{"sandbox": {"anything": 1}}' + + def do_GET(self) -> None: + self.send_response(200) + self.end_headers() + self.wfile.write(self.body) + + def log_message(self, format: str, *args: object) -> None: + pass + + +def _serving(body: bytes) -> tuple[HTTPServer, int]: + handler = type("Handler", (_Answers,), {"body": body}) + server = HTTPServer((LOOPBACK_HOST, 0), handler) + threading.Thread(target=server.serve_forever, daemon=True).start() + return server, int(server.server_address[1]) + + +@pytest.fixture +def opened(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> list[str]: + urls: list[str] = [] + + def record(url: str) -> bool: + urls.append(url) + return True + + monkeypatch.setattr(webbrowser, "open", record) + monkeypatch.setattr(tempfile, "tempdir", str(tmp_path)) + return urls + + +@pytest.fixture +def started(monkeypatch: pytest.MonkeyPatch) -> list[int]: + ports: list[int] = [] + monkeypatch.setattr(sandbox_module, "serve", lambda port, region: ports.append(port)) + return ports + + +def _prefer(monkeypatch: pytest.MonkeyPatch, port: int) -> None: + monkeypatch.setattr(sandbox_app, "PREFERRED_PORT", port) + + +def test_a_running_sandbox_is_opened_and_no_second_is_started( + monkeypatch: pytest.MonkeyPatch, opened: list[str], started: list[int] +) -> None: + server, port = _serving(b'{"sandbox": {"nodes": 5}}') + _prefer(monkeypatch, port) + sandbox_app.main() + server.shutdown() + assert opened == [f"http://127.0.0.1:{port}"] + assert started == [] + + +def test_something_else_on_the_port_leaves_the_port_alone_and_serves_elsewhere( + monkeypatch: pytest.MonkeyPatch, opened: list[str], started: list[int] +) -> None: + server, port = _serving(b'{"sandbox": null}') + _prefer(monkeypatch, port) + sandbox_app.main() + server.shutdown() + assert len(started) == 1 and started[0] not in (0, port) + assert opened == [] + + +def test_a_free_preferred_port_is_used( + monkeypatch: pytest.MonkeyPatch, opened: list[str], started: list[int] +) -> None: + port = sandbox_app.free_port() + _prefer(monkeypatch, port) + sandbox_app.main() + assert started == [port] + + +def test_a_start_failure_opens_a_page_with_the_error_and_the_logs_path( + monkeypatch: pytest.MonkeyPatch, opened: list[str], tmp_path: Path +) -> None: + def fail(port: int, region: str) -> None: + raise RuntimeError("no here") + + monkeypatch.setattr(sandbox_module, "serve", fail) + _prefer(monkeypatch, sandbox_app.free_port()) + with pytest.raises(SystemExit): + sandbox_app.main() + (page_url,) = opened + page = Path(page_url.removeprefix("file://")).read_text() + assert "no <Cache Pack> here" in page + assert str(tmp_path / sandbox_app.LOG_FILE_NAME) in page + + +def test_a_port_that_cannot_be_bound_still_opens_the_failure_page( + monkeypatch: pytest.MonkeyPatch, opened: list[str] +) -> None: + def cannot_bind(port: int, region: str) -> None: + raise SystemExit(1) + + monkeypatch.setattr(sandbox_module, "serve", cannot_bind) + _prefer(monkeypatch, sandbox_app.free_port()) + with pytest.raises(SystemExit): + sandbox_app.main() + (page_url,) = opened + assert "exit code 1" in Path(page_url.removeprefix("file://")).read_text() + + +def test_a_bind_lost_to_a_sandbox_that_just_started_opens_that_sandbox( + monkeypatch: pytest.MonkeyPatch, opened: list[str] +) -> None: + server, port = _serving(b'{"sandbox": {"nodes": 5}}') + _prefer(monkeypatch, port) + answers = iter([False, True]) + monkeypatch.setattr(sandbox_app, "sandbox_is_running_on", lambda _port: next(answers)) + monkeypatch.setattr(sandbox_app, "_bindable", lambda _port: True) + + def lost_the_race(port: int, region: str) -> None: + raise SystemExit(3) + + monkeypatch.setattr(sandbox_module, "serve", lost_the_race) + sandbox_app.main() + server.shutdown() + assert opened == [f"http://127.0.0.1:{port}"] + + +def test_the_servers_own_reason_for_not_binding_is_on_the_page( + monkeypatch: pytest.MonkeyPatch, opened: list[str] +) -> None: + def cannot_bind(port: int, region: str) -> None: + logging.getLogger("uvicorn.error").error("address already in use") + raise SystemExit(3) + + monkeypatch.setattr(sandbox_module, "serve", cannot_bind) + _prefer(monkeypatch, sandbox_app.free_port()) + with pytest.raises(SystemExit): + sandbox_app.main() + (page_url,) = opened + assert "address already in use" in Path(page_url.removeprefix("file://")).read_text() + + +def test_a_region_that_cannot_be_read_opens_the_failure_page_through_the_real_entry_point( + tmp_path: Path, +) -> None: + opened_url = tmp_path / "opened-url" + program = ( + "import webbrowser\n" + f"webbrowser.open = lambda url: open({str(opened_url)!r}, 'w').write(url)\n" + "from storm_shield import sandbox_app\n" + "sandbox_app.main()\n" + ) + environment = {**os.environ, "NODE_REGION": "nowhere", "TMPDIR": str(tmp_path)} + result = subprocess.run( + [sys.executable, "-c", program], + check=False, + env=environment, + capture_output=True, + timeout=60, + ) + assert result.returncode == 1 + page = Path(opened_url.read_text().removeprefix("file://")).read_text() + assert "nowhere" in page + assert str(tmp_path / sandbox_app.LOG_FILE_NAME) in page + assert "nowhere" in (tmp_path / sandbox_app.LOG_FILE_NAME).read_text() + + +def test_the_app_logs_to_a_file_in_the_temp_folder( + monkeypatch: pytest.MonkeyPatch, opened: list[str], started: list[int], tmp_path: Path +) -> None: + _prefer(monkeypatch, sandbox_app.free_port()) + monkeypatch.setattr(sandbox_module, "serve", lambda port, region: _log.info("serving")) + sandbox_app.main() + logging.shutdown() + assert "serving" in (tmp_path / sandbox_app.LOG_FILE_NAME).read_text() + + +def test_the_sandbox_binds_loopback_only() -> None: + config = sandbox_server_config(object(), 8000) + assert config.host == "127.0.0.1" + assert config.log_config is None + + +def test_the_dashboard_and_regions_are_found_beside_the_checkout() -> None: + assert (app_files.REGIONS_DIR / "nepal" / "region.json").is_file() + assert app_files.DASHBOARD_DIR == app_files.APP_ROOT / "dashboard" / "dist" + + +def test_a_bundled_app_finds_them_in_its_bundle( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setattr(sys, "frozen", True, raising=False) + monkeypatch.setattr(sys, "_MEIPASS", str(tmp_path), raising=False) + assert app_files._app_root() == tmp_path