# Test Infrastructure **Task**: AZ-406_test_infrastructure **Name**: Blackbox Test Infrastructure Bootstrap (Tier-1 Docker + Tier-2 Jetson harness scaffold) **Description**: Scaffold the e2e blackbox test project — `e2e/` directory, pytest runner, docker-compose.test.yml, mock services, fixture builders, secrets handling, CSV reporter. This is the foundation every blackbox test depends on. **Complexity**: 5 points **Dependencies**: None **Component**: Blackbox Tests (epic AZ-262 / E-BBT) **Tracker**: AZ-406 **Epic**: AZ-262 (E-BBT) ## Problem The product (`gps-denied-onboard`) needs a behavioral verification layer that drives the SUT exclusively through its declared public boundaries (frame source, FC inbound, tile cache mount, FC outbound via SITL, GCS via mavproxy, FDR filesystem). Without a unified test harness — Docker compose for Tier-1, Jetson runner harness for Tier-2, fixture builders, mock Suite Sat Service, MAVLink listener, CSV reporter — every individual blackbox / performance / resilience / security / resource-limit task would re-invent its own scaffolding. This task delivers that shared scaffold once. ## Outcome - A single `cd e2e/docker && docker compose -f docker-compose.test.yml up --build --abort-on-container-exit e2e-runner` command brings up the full Tier-1 environment and runs the full pytest suite (when test tasks land). - A single `./e2e/jetson/run-tier2.sh --fc-adapter --vio-strategy ` runs the Tier-2 hardware-loop with the same CSV reporter contract. - The matrix dimensions `FC_ADAPTER × VIO_STRATEGY × build_kind` (per `environment.md` § CI runner mapping) are first-class CI parameters; CI YAML scaffold provided. - Every external dependency named in `environment.md` (`ardupilot-plane-sitl`, `inav-sitl`, `mock-suite-sat-service`, `mavproxy-listener`) is provisioned by the compose file and reachable inside `e2e-net`. - Egress isolation (`internal: true` on `e2e-net`) is enforced by default, satisfying RESTRICT-SAT-1 / NFT-SEC-02 at the network layer. - A single `pytest`-based runner discovers tests under `e2e/tests/`, parameterizes by `FC_ADAPTER` + `VIO_STRATEGY`, and emits the CSV report at `e2e-results/run-${RUN_ID}/report.csv` with the columns specified in `environment.md` § Reporting. - Fixture builders for `tile-cache-fixture`, `synth-age-tile-set`, `outlier-injection-derkachi`, `blackout-spoof-derkachi`, `multi-segment-derkachi`, `cold-boot-fixture`, `cve-jpeg-fixture`, `mavlink-passkey` exist as separate Dockerized helpers under `tests/fixtures/`. ## Test Project Folder Layout ``` e2e/ ├── docker/ │ ├── docker-compose.test.yml # Tier-1 entrypoint │ ├── docker-compose.tier2-bridge.yml # optional override for Jetson-attached SITLs │ └── secrets/ │ └── mavlink_passkey # Docker-secret mount target (test passkey) ├── jetson/ │ ├── run-tier2.sh # Tier-2 entrypoint │ ├── tier2.service # systemd unit template for SUT lifecycle │ ├── tegrastats_parser.py # parse tegrastats → per-sample CSV rows │ └── jtop_parser.py # parse jetson-stats jtop API → per-sample CSV ├── runner/ │ ├── Dockerfile # e2e-runner image (Python 3.12 + pytest 8.x) │ ├── requirements.txt # pytest, pymavlink, msp_gps_toy bridge, opencv-python>=4.12.0, numpy, scipy, geopy │ ├── conftest.py # session/module/function fixtures, FC_ADAPTER/VIO_STRATEGY parameterization │ ├── reporting/ │ │ ├── csv_reporter.py # pytest plugin emitting environment.md § Reporting columns │ │ └── evidence_bundler.py # collects .tlog, FDR archives, profiler traces, screenshots │ └── helpers/ │ ├── frame_source_replay.py # replay images / video to V4L2 file source │ ├── imu_replay.py # replay data_imu.csv at 10 Hz to FC inbound │ ├── sitl_observer.py # AP/iNav state-read helpers (param read, GPS_RAW_INT, MSP queries) │ ├── mavproxy_tlog_reader.py # parse .tlog from mavproxy-listener │ ├── fdr_reader.py # post-run filesystem read of FDR archive │ └── geo.py # Vincenty / WGS84 geodesic helpers ├── fixtures/ │ ├── tile-cache-builder/ # builds tile-cache-fixture from input_data + curated public subset │ ├── age-injector/ # mutates tile manifest dates → synth-age-tile-set │ ├── injectors/ │ │ ├── outlier.py # outlier-injection-derkachi │ │ ├── blackout_spoof.py # blackout-spoof-derkachi (5/15/35 s windows) │ │ ├── multi_segment.py # multi-segment-derkachi │ │ └── cold_boot.py # cold-boot-fixture (frozen FC pose JSON) │ ├── secrets/ │ │ └── mavlink-test-passkey.txt # 32-byte hex; "TEST ONLY" │ ├── security/ │ │ └── cve-2025-53644.jpg # crafted JPEG for NFT-SEC-04 (license-checked PoC) │ └── mock-suite-sat/ # FastAPI stub for mock-suite-sat-service │ ├── Dockerfile │ └── app.py # 202 on well-formed publish; 400 on malformed └── tests/ ├── positive/ # FT-P-* scenarios ├── negative/ # FT-N-* scenarios ├── performance/ # NFT-PERF-* ├── resilience/ # NFT-RES-* ├── security/ # NFT-SEC-* └── resource_limit/ # NFT-LIM-* ``` ### Layout Rationale - `e2e/docker/` and `e2e/jetson/` separate the two execution tiers, mirroring `environment.md` § Two-tier execution profile. Each tier has its own entrypoint script — the runner image and CSV-reporter contract are shared. - `e2e/runner/helpers/` keeps reusable boundary-driving primitives (frame replay, IMU replay, SITL observers, FDR reader, MAVLink listener) out of individual test modules — every blackbox task imports from here, not from the SUT. - `e2e/fixtures/` holds *fixture builders*, not the data itself. Heavy fixture content (Derkachi video, 60 still images) is bind-mounted from `_docs/00_problem/input_data/` per `test-data.md`. - `e2e/tests//` mirrors the `_docs/02_document/tests/*-tests.md` grouping so a reader can map any spec scenario to its test file. ## Mock Services | Mock Service | Replaces | Endpoints | Behavior | |---|---|---|---| | `mock-suite-sat-service` | Azaion Suite Satellite Service ingest API | `POST /tiles` (publish), `GET /tiles/audit` (test-side audit retrieval) | Returns 202 on well-formed publish; 400 on malformed; logs every received tile + per-tile quality metadata to `/audit/.jsonl`; `GET /audit` reads the log back. Deterministic; same input → same response. | | `ardupilot-plane-sitl` | ArduPilot Plane FC | UDP 14550 (MAVLink) | Real `ardupilot/ardupilot-sitl:plane-stable`; `GPS_TYPE=14`. Tests OBSERVE, do not patch. | | `inav-sitl` | iNav FC | TCP 5760 (MSP2) | Real `inavflight/inav-sitl:9.0.0`; GPS provider configured to MSP per `docs/SITL/SITL.md`. Tests OBSERVE. | | `mavproxy-listener` | QGroundControl GCS | UDP 14551 | Passive MAVLink listener; captures SUT → GCS stream into `/var/log/tlogs/.tlog` for assertions. | ### Mock Control Surface The mock-suite-sat-service exposes: - `POST /mock/config` — test-time behavior control (e.g., simulate downtime, inject 400 errors for negative-path scenarios) - `GET /mock/audit` — returns received tiles + their declared quality metadata for assertion - `POST /mock/reset` — clears audit log between tests for isolation The two SITL services (ArduPilot, iNav) are NOT control-surface mocks — they are real flight-controller stacks running in simulation. Tests interact via standard MAVLink / MSP2 protocols. ## Docker Test Environment ### docker-compose.test.yml structure The full structure is defined in `_docs/02_document/tests/environment.md` § Docker Environment. The test infrastructure task implements that structure verbatim with the following behaviors: - All services on the `e2e-net` bridge network with `internal: true` (no external connectivity — RESTRICT-SAT-1 / NFT-SEC-02). - Volumes: `tile-cache-fixture` (RO mount into SUT), `fdr-output` (RW from SUT, RO from runner), `input-data` (RO bind from `_docs/00_problem/input_data/`), `expected-results` (RO bind from `_docs/00_problem/input_data/expected_results/`). - `fdr-output` sized exactly 64 GB via Docker `--storage-opt size=64g` to enforce AC-NEW-3 capacity at the volume layer (NFT-LIM-02 cross-checks rotation behavior). - `MAVLINK_SIGNING_PASSKEY_FILE` injected as a Docker secret from `e2e/docker/secrets/mavlink_passkey`. - `FC_ADAPTER` and `VIO_STRATEGY` pulled from environment, default `ardupilot` + `okvis2`. CI matrix sets these per job. ### Networks and Volumes | Network / Volume | Type | Purpose | |---|---|---| | `e2e-net` | bridge, `internal: true` | All test traffic; enforces no-external-egress at network layer | | `tile-cache-fixture` | named volume | Pre-built FAISS HNSW index + tile filesystem; built once per CI run | | `fdr-output` | named volume, 64 GB cap | Per-flight FDR write target | | `input-data` | bind mount | RO bind of `_docs/00_problem/input_data/` | | `expected-results` | bind mount | RO bind of `_docs/00_problem/input_data/expected_results/` | ### Tier-2 Bridge Tier-2 runs the SUT on real Jetson hardware with `systemctl start gps-denied-onboard.service`. SITL containers (ArduPilot, iNav) run either on the same Jetson (constrained CPU sharing) OR on a paired x86 host on the same network — `docker-compose.tier2-bridge.yml` provisions the SITL-only subset with the same `e2e-net` definition so the runner observes the same boundaries. ## Test Runner Configuration **Framework**: pytest 8.x **Plugins**: - `pytest-csv` — CSV emission per `environment.md` § Reporting (one row per test) - `pytest-xdist` — parallel test execution within a tier (Tier-1 only; Tier-2 runs serially due to single-Jetson constraint) - `pytest-timeout` — per-test wall-clock budget enforcement (matches the per-scenario `Max execution time` in test specs) - `pymavlink` — MAVLink ground side - `msp_gps_toy` (Rust binary, called via subprocess) — MSP2 ground side - `opencv-python>=4.12.0` — frame source replay (CVE-mitigated per D-CROSS-CVE-1) - `numpy` + `scipy` + `geopy` (Vincenty) — geodesic-distance assertions in WGS84 - `pytest-forked` — `--forked` mode for hermetic-critical scenarios **Entry point**: `pytest e2e/tests/ --csv=e2e-results/run-${RUN_ID}/report.csv --csv-columns="test_id,test_name,traces_to,fc_adapter,vio_strategy,tier,started_at_utc,execution_time_ms,result,error_message,evidence_paths"` ### Fixture Strategy | Fixture | Scope | Purpose | |---|---|---| | `fc_adapter` | session | parametrized over `{ardupilot, inav}`; selects which SITL to bind | | `vio_strategy` | session | parametrized over `{okvis2, klt_ransac}` (production); `vins_mono` only on research-build sessions | | `tile_cache` | session | mounts `tile-cache-fixture` once per session | | `clean_sut` | function | `docker compose restart gps-denied-onboard` between tests; resets `fdr-output` | | `clean_sut_forked` | function (--forked) | full `docker compose down/up` per test; for hermetic-critical scenarios | | `mavproxy_tlog` | function | starts a fresh `.tlog` capture window for the duration of the test | | `fdr_reader` | function | post-run helper for parsing the FDR archive | | `sitl_observer` | function | AP/iNav state-read helper | ### Parameterization Every test file is parameterized by `(fc_adapter, vio_strategy)` unless the spec explicitly skips one or both. The conftest skip-rules: - AC-7.x scenarios: skipped on every run (NOT COVERED per traceability matrix; `pytest.skip(reason="AC-7.x deferred — see traceability matrix")`). - `vins_mono`: skipped on production-build runs (`pytest.skip(reason="vins_mono is research-build-only per D-C1-1-SUB-A")`). - Tier-2-only scenarios (NFT-PERF-01, NFT-LIM-01, NFT-PERF-03, NFT-LIM-04): skipped on Tier-1 with `pytest.skip(reason="Tier-2 only — Jetson hardware required")`. - Chamber-only scenarios (AC-NEW-5 hot-soak portion): skipped on Tier-2 workstation-thermal runs; gated by `--enable-chamber` flag. ## Test Data Fixtures | Data Set | Source | Format | Used By | |---|---|---|---| | `still-image-set-60` | bind mount from `_docs/00_problem/input_data/` | JPEG + CSV GT | FT-P-01, FT-P-03, FT-P-05, FT-P-06, FT-P-15, FT-P-19, NFT-RES-03, NFT-PERF-04 | | `still-image-sat-refs-2` | same | PNG | FT-P-05, FT-P-19 | | `derkachi-fixture` | bind mount | MP4 + CSV | FT-P-02, FT-P-04, FT-P-07, FT-P-10, FT-N-01..04, NFT-PERF-01..02, NFT-RES-01..04, NFT-LIM-02, NFT-LIM-04 | | `tile-cache-fixture` | named volume built by `tests/fixtures/tile-cache-builder/` | FAISS HNSW + tile filesystem | FT-P-01, FT-P-05, FT-P-15..17, FT-P-19, FT-N-05..06, NFT-LIM-03, NFT-PERF-01, NFT-PERF-04, NFT-SEC-01..02 | | `synth-age-tile-set` | built from `tile-cache-fixture` by `age-injector/` | tile filesystem with mutated manifest dates | FT-N-05, FT-N-06 | | `outlier-injection-derkachi` | runtime-generated by `injectors/outlier.py` | tmpfs frame source | FT-N-01 | | `blackout-spoof-derkachi` | runtime-generated by `injectors/blackout_spoof.py` | tmpfs + spoof injector on FC inbound | FT-N-04, NFT-RES-04 | | `multi-segment-derkachi` | runtime-generated by `injectors/multi_segment.py` | tmpfs frame source | FT-P-08 | | `cold-boot-fixture` | static JSON fixture in `tests/fixtures/cold-boot/` | JSON pose snapshot | FT-P-11, NFT-PERF-03 | | `mavlink-passkey` | `tests/fixtures/secrets/mavlink-test-passkey.txt` | 32-byte hex | FT-P-09-AP, NFT-SEC-03 | | `cve-jpeg-fixture` | `tests/fixtures/security/cve-2025-53644.jpg` | crafted JPEG | NFT-SEC-04 | ### Data Isolation Per `test-data.md` § Data Isolation Strategy: - Each test runs against a fresh SUT container (`docker compose restart` between tests, OR `--forked` pytest mode for hermetic-critical scenarios). - `tile-cache-fixture` and `input-data` mounts are read-only — cross-contamination at the SUT input layer is impossible. - `fdr-output` volume is reset between tests (`docker volume rm` + recreate). - Synthetic-injection fixtures generate to per-test tmpfs; never written back to a persistent volume. - For Tier-2: same isolation discipline at the systemd-service level (`systemctl restart`); `/var/azaion/fdr` wiped between tests. ## Test Reporting **Format**: CSV (one row per test) — exactly per `environment.md` § Reporting. **Columns**: `test_id, test_name, traces_to, fc_adapter, vio_strategy, tier, started_at_utc, execution_time_ms, result, error_message, evidence_paths` **Output path**: `e2e-results/run-${RUN_ID}/report.csv` plus a per-run bundle of evidence at `e2e-results/run-${RUN_ID}/evidence/` (assembled by `evidence_bundler.py` from `.tlog` files, FDR archives, screenshots, profiler traces, tegrastats CSV, jtop CSV). ## Acceptance Criteria **AC-1: Test environment starts (Tier-1)** Given a clean checkout of the repo When `cd e2e/docker && docker compose -f docker-compose.test.yml up --build --abort-on-container-exit e2e-runner` is executed Then all services in `environment.md` § Docker Environment start, the e2e-runner image builds, and pytest discovers ≥1 test file (sample test in `e2e/tests/positive/test_smoke.py`). **AC-2: Mock services respond** Given the test environment is running When the e2e-runner POSTs a well-formed tile-publish JSON to `mock-suite-sat-service` Then the service responds 202 and records the tile in its audit log; subsequent `GET /mock/audit` returns the recorded entry. **AC-3: SITL services accept SUT output** Given the test environment is running with a placeholder SUT that emits one valid `GPS_INPUT` (AP) AND one valid `MSP2_SENSOR_GPS` (iNav) When the e2e-runner reads `EK3_SRC1_POSXY` from `ardupilot-plane-sitl` AND queries iNav GPS state via MSP from `inav-sitl` Then both SITLs reflect the test-injected GPS source as primary. **AC-4: CSV report generated with required columns** Given the test runner executes When the test run completes Then `e2e-results/run-${RUN_ID}/report.csv` exists with exactly the columns from `environment.md` § Reporting, and a per-run evidence bundle exists at `e2e-results/run-${RUN_ID}/evidence/`. **AC-5: Egress isolation enforced** Given the test environment is running with `e2e-net.internal: true` When the e2e-runner attempts a TCP connect to `1.1.1.1:443` from inside the SUT container Then the connection fails (network-layer block); no DNS resolution succeeds for non-`e2e-net` names. **AC-6: Tier-2 runner harness contract** Given a Jetson Orin Nano Super with the SUT installed via systemd When `./e2e/jetson/run-tier2.sh --fc-adapter ardupilot --vio-strategy okvis2 --duration 5min` is executed Then the same CSV report format is produced at `e2e-results/run-${RUN_ID}/report.csv`, with `tier=tier2-jetson` for every row, and `tegrastats` + `jtop` per-sample CSVs land in the evidence bundle. **AC-7: Fixture builders are reproducible** Given a clean Docker volume state When `tests/fixtures/tile-cache-builder/build.sh` runs Then the same tile-cache-fixture content is produced bit-for-bit twice in a row (same FAISS index, same tile manifest hashes); same idempotency for `age-injector` and the static JSON fixtures. **AC-8: Parameterization matrix coverage** Given the conftest sets up `(fc_adapter, vio_strategy, tier, build_kind)` parameterization When CI runs the standard matrix Then every produced report row has well-defined values for `fc_adapter`, `vio_strategy`, `tier`; `vins_mono` rows appear only on `build_kind=research` runs; `Tier-2`-only test_ids are SKIP on Tier-1 with the expected reason string. **AC-9: Skips per traceability matrix** Given the e2e-runner starts When the discoverer encounters a test mapped to AC-7.1, AC-7.2, AC-NEW-5 chamber portion, AC-8.6 scene-change subset, RESTRICT-CAM-2, or RESTRICT-HW-2 chamber portion Then those test rows show `result=SKIP` (or `XFAIL` for AC-8.6 scene-change PARTIAL) with an `error_message` referencing the traceability-matrix mitigation entry. ## Constraints - **Public-boundary discipline**: the e2e-runner image MUST NOT import any module from the SUT source tree. The only legal interaction surfaces are MAVLink / MSP2 / HTTP / filesystem — same as a real consumer would have. - **OpenCV pin**: the runner image's OpenCV version MUST be `>=4.12.0` (D-CROSS-CVE-1); pinned in `e2e/runner/requirements.txt`. - **MAVLink-passkey provenance**: the test passkey is a checked-in fixture explicitly labeled "TEST ONLY"; the production passkey path is `/run/secrets/mavlink_passkey` per environment.md and is never the test fixture. - **Tier separation**: Tier-1 and Tier-2 produce IDENTICAL CSV row formats so the same downstream tooling (badge generators, regression detectors) can consume both. - **No internal state probes**: no test may read SUT internal state (GTSAM iSAM2 graph, FAISS in-memory index, internal Python/C++ buffers, logger handles). Only public boundaries + FDR archive + SITL observation are legal evidence sources. ## Risks & Mitigation **Risk 1: Tier-1 runner image build slow / large** - *Risk*: pulling `tensorrt`, `gtsam`, `faiss-gpu`, `opencv-python>=4.12.0` plus dev dependencies into a single image bloats the e2e-runner build to ≥30 min and ≥10 GB. - *Mitigation*: the e2e-runner image is **separate from the SUT image** — the runner only needs ground-side libs (`pymavlink`, `msp_gps_toy`, `opencv-python`, `numpy`, `scipy`, `geopy`, `pytest`). The SUT image is what carries the heavy ML stack. Keep the runner image lean (target ≤2 GB). **Risk 2: SITL containers flaky / non-deterministic timing** - *Risk*: `ardupilot-plane-sitl` and `inav-sitl` boot times vary; tests may race the SITL's parameter-init phase. - *Mitigation*: conftest fixture polls SITL readiness via a known parameter read (e.g., `EKF_ENABLE`) before any test runs. Failure to reach readiness within 60 s fails the SITL fixture, not the individual test. **Risk 3: Mock Suite Sat Service drift from D-PROJ-2 contract** - *Risk*: when the real Suite Sat Service ingest contract ships (D-PROJ-2), the mock may diverge silently. - *Mitigation*: the mock's request/response schema is sourced from the contract sketch in `_docs/_process_leftovers/2026-05-09_satellite-provider-design-tasks.md`; a contract test in NFT-SEC-01 asserts the mock's accepted-fields match that sketch. When the real endpoint ships, the mock is retired (per F7 in traceability matrix). **Risk 4: `--storage-opt size=64g` not portable** - *Risk*: Docker's `--storage-opt size=64g` for volumes requires specific storage drivers (overlay2 with xfs backing); may not work on all CI runners. - *Mitigation*: fallback strategy in the docker-compose: if the volume cannot be size-capped at the Docker layer, the SUT enforces the cap internally per AC-NEW-3 and NFT-LIM-02 verifies via volume-size sampling. CI runner config flagged in the runner README. **Risk 5: `cve-jpeg-fixture` license / distribution** - *Risk*: PoC JPEG for CVE-2025-53644 may have unclear redistribution terms. - *Mitigation*: license-checked at fixture-import time; if license unclear, the fixture is generated programmatically following the published PoC structure (no copyrighted bytes); generation script is itself part of `tests/fixtures/security/`. ## Document Dependencies - `_docs/02_document/tests/environment.md` — full Docker environment spec (services, networks, volumes, secrets, ports) - `_docs/02_document/tests/test-data.md` — fixture sources, formats, isolation strategy, validation rules - `_docs/02_document/tests/traceability-matrix.md` — AC coverage map (drives the SKIP / XFAIL rules in conftest) - `_docs/02_document/tests/blackbox-tests.md` + `performance-tests.md` + `resilience-tests.md` + `security-tests.md` + `resource-limit-tests.md` — list of test categories that the `e2e/tests//` folders mirror ## Excluded - The SUT (`gps-denied-onboard`) container build — owned by the BUILD-side epics (E-CC-CONF / E-BOOT and per-component epics). The test infrastructure references the SUT image but does NOT build it. - Individual test scenario implementations (FT-P-*, FT-N-*, NFT-*) — owned by the per-scenario tasks decomposed in Step 3. - The Suite Sat Service real endpoint contract — owned by the parent suite (D-PROJ-2); the mock here mirrors a sketch only. - The thermal chamber AC-NEW-5 hot-soak run — physical hardware, deferred per traceability matrix. - The AI-camera fixture (AC-7.x) — out of scope per Phase 1 gate.