[AZ-777] Phase 1: wire e2e-runner to real satellite-provider + C11 contract adapt

Adapt C11 HttpTileDownloader to the AZ-505 v1.0.0 tile-inventory
contract (POST /api/satellite/tiles/inventory + GET /tiles/{z}/{x}/{y})
and wire the Jetson e2e harness against the real parent-suite
satellite-provider service. Closes Phase 1 of 5 for AZ-777; STOP
gate before Phase 2 (Derkachi catalog seed).

C11 changes:
- _LIST_PATH / _GET_PATH replaced with _INVENTORY_PATH + _TILES_PATH.
- _do_enumerate enumerates bbox tile coords client-side and posts
  chunked inventory requests (5000-entry cap per the contract).
- _download_one_tile parses tile_id_str into (z,x,y) and fetches
  the slippy-map URL.
- Common GET / POST retry+auth ladder consolidated into _send_request.
- New module helpers: _enumerate_bbox_tile_coords,
  _tile_center_latlon, _tile_size_meters_at, _format_tile_id_str,
  _parse_tile_id_str, _chunk_iter.
- _DEFAULT_ESTIMATED_TILE_BYTES (50 KiB) replaces the inventory-side
  estimatedBytes field the v1.0.0 contract dropped.

Tests:
- 14/14 unit tests in tests/unit/c11_tile_manager/test_tile_downloader.py
  rewritten for the new POST inventory + slippy-map GET handler.
  _StubTileWriter rekeyed by call-index (the downloader now derives
  lat/lon from the slippy-map coord, so fixtures can't fabricate
  arbitrary positions).
- New Tier-2 smoke at tests/e2e/satellite_provider/test_smoke.py:
  validates inventory POST schema + drives HttpTileDownloader against
  the real service. Gated by RUN_REPLAY_E2E=1 + tier2.

Compose / env:
- e2e-runner SATELLITE_PROVIDER_URL switched from mock-sat:5100 to
  https://satellite-provider:8080; TLS_INSECURE + Bearer JWT env +
  depends_on satellite-provider added.
- .env.test.example documents SATELLITE_PROVIDER_API_KEY + dev TLS
  bypass security note.
- scripts/mint_dev_jwt.py mints HS256 dev JWTs from env / .env.test.
- pyjwt added to dev extras.

Tracker hygiene:
- AZ-777 row in _dependencies_table.md bumped 5pt -> 8pt to match
  the 2026-05-21 override decision log.

Code review: PASS_WITH_WARNINGS (3 medium/low findings, all deferred
to later AZ-777 phases) -- see batch_104_review.md. Batch report at
batch_104_cycle3_report.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-21 14:52:39 +03:00
parent 544b37fdc9
commit 811b04e605
12 changed files with 1328 additions and 190 deletions
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env python3
"""Mint a dev JWT for the parent-suite satellite-provider (AZ-777).
Reads JWT_SECRET / JWT_ISSUER / JWT_AUDIENCE from environment or from
`.env.test` (when run from the repo root). Prints the JWT to stdout so
the caller can pipe / paste / `export` it.
DEV-ONLY: the same secret signs the JWT and validates it on the
provider side; production deploys retrieve operator JWTs from the admin
API (AZ-690) instead. Mirrors `SatelliteProvider.TestSupport.JwtTokenFactory.Create`
on the .NET side so dev tokens behave identically to integration-test ones.
Usage::
python scripts/mint_dev_jwt.py
python scripts/mint_dev_jwt.py --lifetime-hours 12 --subject e2e-runner
export SATELLITE_PROVIDER_API_KEY="$(python scripts/mint_dev_jwt.py)"
"""
from __future__ import annotations
import argparse
import os
import sys
import uuid
from datetime import datetime, timedelta, timezone
from pathlib import Path
try:
import jwt
except ImportError as exc:
sys.stderr.write(
"ERROR: pyjwt not installed. Run `pip install pyjwt>=2.8,<3.0`\n"
"(or `pip install -e .[dev]` from the repo root) and retry.\n"
f"Underlying ImportError: {exc}\n"
)
sys.exit(72)
_MIN_SECRET_BYTES = 32
def _load_env_file(path: Path) -> dict[str, str]:
"""Parse a minimal 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("#"):
continue
if "=" not in line:
continue
key, _, value = line.partition("=")
key = key.strip()
value = value.strip().strip('"').strip("'")
if key:
out[key] = value
return out
def _resolve(name: str, env_file_values: dict[str, str]) -> str | None:
value = os.environ.get(name)
if value:
return value
return env_file_values.get(name)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--lifetime-hours",
type=float,
default=8.0,
help="Token lifetime in hours (default: 8).",
)
parser.add_argument(
"--subject",
default="gps-denied-onboard-e2e",
help="`sub` claim (default: gps-denied-onboard-e2e).",
)
parser.add_argument(
"--env-file",
default=".env.test",
help="Fallback env file (default: .env.test in CWD).",
)
args = parser.parse_args()
env_file_values = _load_env_file(Path(args.env_file))
secret = _resolve("JWT_SECRET", env_file_values)
issuer = _resolve("JWT_ISSUER", env_file_values)
audience = _resolve("JWT_AUDIENCE", env_file_values)
missing = [
name
for name, value in [
("JWT_SECRET", secret),
("JWT_ISSUER", issuer),
("JWT_AUDIENCE", audience),
]
if not value
]
if missing:
sys.stderr.write(
"ERROR: required env var(s) not set: "
+ ", ".join(missing)
+ f"\n(looked at environment + {args.env_file})\n"
)
return 73
assert secret is not None
if len(secret.encode("utf-8")) < _MIN_SECRET_BYTES:
sys.stderr.write(
f"ERROR: JWT_SECRET is {len(secret)} bytes; HMAC-SHA256 requires "
f">= {_MIN_SECRET_BYTES} bytes per the provider's contract.\n"
)
return 74
now = datetime.now(timezone.utc)
payload = {
"sub": args.subject,
"iss": issuer,
"aud": audience,
"jti": uuid.uuid4().hex,
"iat": int(now.timestamp()),
"nbf": int(now.timestamp()),
"exp": int((now + timedelta(hours=args.lifetime_hours)).timestamp()),
}
token = jwt.encode(payload, secret, algorithm="HS256")
sys.stdout.write(token + "\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())