mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 23:11:12 +00:00
c3a1ebc754
ci/woodpecker/push/02-build-push Pipeline failed
Operator-side HTTP client + CLI that takes a RouteSpec from AZ-836 and onboards it via satellite-provider's POST /api/satellite/route: pre-emptive AZ-809 validation, request submission, polling until mapsReady, and POST /api/satellite/tiles/inventory verify. Lives in c11_tile_manager (shared parent-suite HTTP/JWT plumbing, shared BUILD_C11_TILE_MANAGER gate); error hierarchy split off SatelliteProviderRouteError to keep the tile path and route path independent. 30 unit tests + 1 RUN_E2E-gated integration test. Pre-emptive validator tracks the actual AZ-809 server bounds (points [2,500], zoom [0,22]) instead of the AZ-838 spec's narrower client-only bounds; flagged as F1 in batch_107_cycle3_report.md for user decision (accept-and-update-spec / revert-to-spec). Co-authored-by: Cursor <cursoragent@cursor.com>
405 lines
14 KiB
Python
405 lines
14 KiB
Python
#!/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: "
|
|
"<tlog-stem>-<short-hash> (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())
|