#!/usr/bin/env python3 """Seed a tlog-derived route via satellite-provider's Route API (AZ-838). Second deliverable of Epic AZ-835 (C2). Reads a Mavlink ``.tlog`` file, extracts a route via AZ-836's :func:`extract_route_from_tlog`, then hands the resulting :class:`RouteSpec` to :class:`SatelliteProviderRouteClient` which: 1. Pre-emptively validates the request body against AZ-809 rules. 2. POSTs ``/api/satellite/route`` with ``requestMaps=true``. 3. Polls ``GET /api/satellite/route/{id}`` until ``mapsReady=true`` or a terminal failure status. 4. Verifies coverage via ``POST /api/satellite/tiles/inventory``. This script is intended to run from the gps-denied-onboard repo root against a running ``satellite-provider`` (typically the Jetson e2e harness's service). It does NOT spin up the service itself and does NOT modify any satellite-provider code or configuration. Required environment (loaded from ``.env.test`` if not exported):: SATELLITE_PROVIDER_URL e.g. https://satellite-provider:8080 SATELLITE_PROVIDER_API_KEY a valid HS256 JWT (mint with scripts/mint_dev_jwt.py) SATELLITE_PROVIDER_TLS_INSECURE optional, "1" to accept self-signed dev certs JWT_SECRET / JWT_ISSUER / JWT_AUDIENCE required only if --auto-mint-jwt is passed Usage:: # mint a JWT then seed using a Derkachi tlog export SATELLITE_PROVIDER_API_KEY="$(python scripts/mint_dev_jwt.py)" python tests/fixtures/derkachi_c6/seed_route.py \ --tlog tests/fixtures/derkachi_c6/derkachi.tlog # dry-run: extract route, print planned payload + sha256, no HTTP python tests/fixtures/derkachi_c6/seed_route.py \ --tlog tests/fixtures/derkachi_c6/derkachi.tlog --dry-run # write a JSON summary for downstream consumers (CI / fixture) python tests/fixtures/derkachi_c6/seed_route.py \ --tlog tests/fixtures/derkachi_c6/derkachi.tlog \ --output-summary /tmp/route_seed.json Exit codes:: 0 route reached mapsReady=true and inventory verification passed 71 config malformed (tlog unreadable / no waypoints extracted) 72 required env var missing 73 satellite-provider unreachable (network / TLS error) 74 route request rejected (HTTP 4xx + ProblemDetails) 75 HTTP 5xx OR route terminal failure (mapsReady never reached) 76 inventory verification mismatch (zero tiles present) """ from __future__ import annotations import argparse import json import os import subprocess import sys from pathlib import Path try: import httpx # noqa: F401 -- imported eagerly so missing-dep exits 72 except ImportError as exc: sys.stderr.write( f"ERROR: httpx not installed: {exc}\n" "Run `pip install -e .[dev]` from the repo root.\n" ) sys.exit(72) from gps_denied_onboard.components.c11_tile_manager.errors import ( RouteTerminalFailureError, RouteTransientError, RouteValidationError, ) from gps_denied_onboard.components.c11_tile_manager.route_client import ( SatelliteProviderRouteClient, ) from gps_denied_onboard.replay_input.tlog_route import extract_route_from_tlog _DEFAULT_MAX_WAYPOINTS = 10 _DEFAULT_REGION_SIZE_M = 500.0 _DEFAULT_ZOOM_LEVEL = 18 _DEFAULT_TLOG = Path("tests/fixtures/derkachi_c6/derkachi.tlog") _MIN_INVENTORY_PRESENT = 1 def _load_env_file(path: Path) -> dict[str, str]: """Parse a KEY=VALUE env file. Honours quoting; ignores comments.""" if not path.is_file(): return {} out: dict[str, str] = {} for raw in path.read_text("utf-8").splitlines(): line = raw.strip() if not line or line.startswith("#") or "=" not in line: continue key, _, value = line.partition("=") out[key.strip()] = value.strip().strip('"').strip("'") return out def _resolve_env(name: str, env_file_values: dict[str, str]) -> str | None: return os.environ.get(name) or env_file_values.get(name) def _auto_mint_jwt() -> str | None: """Run ``scripts/mint_dev_jwt.py`` and return the printed JWT. Mirrors what an operator would do manually: ``export SATELLITE_PROVIDER_API_KEY=$(python scripts/mint_dev_jwt.py)``. Returns ``None`` if the script fails or is missing — the caller surfaces that as exit 72 (missing env). """ script = Path("scripts/mint_dev_jwt.py") if not script.is_file(): sys.stderr.write( f"ERROR: --auto-mint-jwt requested but {script} not found.\n" ) return None try: result = subprocess.run( [sys.executable, str(script)], capture_output=True, text=True, check=False, ) except OSError as exc: sys.stderr.write(f"ERROR: --auto-mint-jwt failed to launch: {exc}\n") return None if result.returncode != 0: sys.stderr.write( f"ERROR: --auto-mint-jwt exited with rc={result.returncode}; " f"stderr={result.stderr.strip()!r}\n" ) return None token = result.stdout.strip() if not token: sys.stderr.write("ERROR: --auto-mint-jwt produced empty output.\n") return None return token def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--tlog", type=Path, default=_DEFAULT_TLOG, help=( f"Path to the .tlog file to extract a route from " f"(default: {_DEFAULT_TLOG})." ), ) parser.add_argument( "--max-waypoints", type=int, default=_DEFAULT_MAX_WAYPOINTS, help=( f"Maximum waypoints to keep after Douglas-Peucker decimation " f"(default: {_DEFAULT_MAX_WAYPOINTS}, see AZ-836)." ), ) parser.add_argument( "--region-size-meters", type=float, default=_DEFAULT_REGION_SIZE_M, help=( f"Per-waypoint region size in metres " f"(default: {_DEFAULT_REGION_SIZE_M}; AZ-809 range " f"[100, 10000])." ), ) parser.add_argument( "--zoom-level", type=int, default=_DEFAULT_ZOOM_LEVEL, help=( f"Web-Mercator zoom for the route " f"(default: {_DEFAULT_ZOOM_LEVEL}; AZ-809 range [0, 22])." ), ) parser.add_argument( "--name", type=str, default=None, help=( "Optional human-readable name. Default: " "- (deterministic per RouteSpec)." ), ) parser.add_argument( "--description", type=str, default=None, help="Optional free-text description (max 1000 chars per AZ-809).", ) parser.add_argument( "--env-file", type=Path, default=Path(".env.test"), help="Fallback env file (default: .env.test in CWD).", ) parser.add_argument( "--output-summary", type=Path, default=None, help="Optional path to write a JSON summary of the seeding run.", ) parser.add_argument( "--dry-run", action="store_true", help=( "Extract route and print planned payload + sha256 without " "submitting any HTTP request." ), ) parser.add_argument( "--auto-mint-jwt", action="store_true", help=( "Run scripts/mint_dev_jwt.py to mint a fresh JWT instead of " "reading SATELLITE_PROVIDER_API_KEY from env." ), ) args = parser.parse_args() if not args.tlog.is_file(): sys.stderr.write(f"ERROR: tlog not found: {args.tlog}\n") return 71 try: spec = extract_route_from_tlog( args.tlog, max_waypoints=args.max_waypoints, ) except (FileNotFoundError, ValueError, RuntimeError) as exc: sys.stderr.write( f"ERROR: failed to extract route from {args.tlog}: {exc}\n" ) return 71 print( f"[plan] tlog: {args.tlog}\n" f"[plan] waypoints (after decimation): {len(spec.waypoints)}\n" f"[plan] region_size_meters: {args.region_size_meters}\n" f"[plan] zoom_level: {args.zoom_level}\n" f"[plan] suggested_region_size_meters (from RouteSpec): " f"{spec.suggested_region_size_meters}" ) env_file_values = _load_env_file(args.env_file) sp_url = _resolve_env("SATELLITE_PROVIDER_URL", env_file_values) tls_insecure = ( _resolve_env("SATELLITE_PROVIDER_TLS_INSECURE", env_file_values) == "1" ) if args.auto_mint_jwt: jwt_token = _auto_mint_jwt() else: jwt_token = _resolve_env("SATELLITE_PROVIDER_API_KEY", env_file_values) if args.dry_run: # Build the planned body with a placeholder URL/JWT — the # client never makes an HTTP call in dry-run mode but does # run pre-emptive validation, so an OOR field still surfaces. client = SatelliteProviderRouteClient( base_url=sp_url or "https://placeholder.invalid", jwt=jwt_token or "placeholder", tls_insecure=tls_insecure, ) try: body, sha256 = client.build_planned_payload( spec, name=args.name, region_size_meters=args.region_size_meters, zoom_level=args.zoom_level, description=args.description, ) except RouteValidationError as exc: sys.stderr.write( f"ERROR: pre-emptive validation rejected request: {exc}\n" f" field_errors={json.dumps(exc.field_errors, indent=2)}\n" ) return 74 print("\n[dry-run] planned payload:") print(json.dumps(body, indent=2, sort_keys=True)) print(f"\n[dry-run] payload sha256: {sha256}") return 0 if not sp_url: sys.stderr.write("ERROR: SATELLITE_PROVIDER_URL not set (env or .env.test).\n") return 72 if not jwt_token: sys.stderr.write( "ERROR: SATELLITE_PROVIDER_API_KEY not set. Mint with:\n" " python scripts/mint_dev_jwt.py\n" "or pass --auto-mint-jwt.\n" ) return 72 client = SatelliteProviderRouteClient( base_url=sp_url, jwt=jwt_token, tls_insecure=tls_insecure, ) print( f"\n[submit] satellite-provider: {sp_url} " f"(tls_insecure={tls_insecure})" ) try: result = client.seed_route( spec, name=args.name, region_size_meters=args.region_size_meters, zoom_level=args.zoom_level, description=args.description, ) except RouteValidationError as exc: sys.stderr.write( f"ERROR: route POST rejected (4xx): {exc}\n" f" http_status={exc.http_status}\n" f" field_errors={json.dumps(exc.field_errors, indent=2)}\n" ) return 74 except RouteTransientError as exc: cause = exc.__cause__ sys.stderr.write( f"ERROR: satellite-provider transient failure: {exc}\n" ) if cause is not None: sys.stderr.write(f" cause: {type(cause).__name__}: {cause}\n") # Distinguish unreachable (no cause / connection-level) from # 5xx so operators can read the exit code without parsing logs. if cause is not None and isinstance( cause, (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout) ): return 73 return 75 except RouteTerminalFailureError as exc: sys.stderr.write( f"ERROR: route did not complete: {exc}\n" f" route_id={exc.route_id}\n" f" detail={json.dumps(exc.detail, indent=2) if exc.detail else None}\n" ) return 75 print( f"\n[done] route_id={result.route_id}\n" f"[done] terminal_status={result.terminal_status}\n" f"[done] maps_ready={result.maps_ready}\n" f"[done] tile_count={result.tile_count}\n" f"[done] elapsed_ms={result.elapsed_ms}\n" f"[done] payload_sha256={result.submitted_payload_sha256}" ) if result.tile_count < _MIN_INVENTORY_PRESENT: sys.stderr.write( "ERROR: inventory verification reported zero tiles present " "for the route's coverage. The server reached mapsReady=true " "but the inventory call returned no matches — this typically " "indicates a coverage / projection mismatch and warrants " "investigation.\n" ) return 76 if args.output_summary: summary = { "tlog": str(args.tlog), "sp_url": sp_url, "tls_insecure": tls_insecure, "spec": { "waypoint_count": len(spec.waypoints), "suggested_region_size_meters": spec.suggested_region_size_meters, "source_tlog": str(spec.source_tlog) if spec.source_tlog else None, }, "request": { "name": args.name, "region_size_meters": args.region_size_meters, "zoom_level": args.zoom_level, "description": args.description, }, "result": { "route_id": str(result.route_id), "terminal_status": result.terminal_status, "maps_ready": result.maps_ready, "tile_count": result.tile_count, "elapsed_ms": result.elapsed_ms, "submitted_payload_sha256": result.submitted_payload_sha256, }, } args.output_summary.parent.mkdir(parents=True, exist_ok=True) args.output_summary.write_text(json.dumps(summary, indent=2)) print(f"[done] summary written to {args.output_summary}") return 0 if __name__ == "__main__": raise SystemExit(main())