mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 18:11:14 +00:00
[AZ-263] Bootstrap: repo skeleton + Docker + CI + Alembic + Tier-1 tests
Implements the AZ-263 / E-BOOT initial structure task:
- Python src/-layout package `gps_denied_onboard/` with per-component
interface stubs (14 components), type-only DTOs under `_types/`,
shared helpers under `helpers/` (R14 LightGlue ownership), structured
JSON logging, runtime composition root with env-var fail-fast gate,
healthcheck module shared by Docker and CI smoke.
- CMake top-level + `cmake/{build_options,dependencies,strategies}.cmake`
with the BUILD_* per-binary flags (ADR-002) and pinned external git
refs for OKVIS2 / VINS-Mono / GTSAM / FAISS / OpenCV >=4.12.0.
- Three Dockerfiles (companion-tier1, operator-tooling,
mock-suite-sat-service) + two compose files (dev + Tier-1 test).
- Four GitHub Actions workflows: ci.yml (lint/unit/integration/dual
binary build/SBOM diff/security), ci-tier2.yml (self-hosted Jetson
AC-bound NFTs), release.yml, cve-rescan.yml.
- Two CI gate scripts: `ci/sbom_diff.py` (deployment SBOM subset +
R02 exclusion), `ci/opencv_pin_gate.py` (>=4.12.0 enforcement,
D-CROSS-CVE-1).
- Alembic-driven Postgres 16 initial migration `0001_initial.py`
mirroring satellite-provider tiles + flights + sector_classifications
+ manifests + engine_cache_entries (data_model.md s 2).
- Tier-1 test scaffolding: 95 passing unit tests covering every AC,
per-component smoke tests, structured logging JSON output check,
env-var gate check, healthcheck import check. Two CI-gated tests
(cmake configure, actionlint) skip locally with explicit reasons.
- Batch report + code review report under `_docs/03_implementation/`.
Verdict: PASS_WITH_WARNINGS (two Low findings, both informational).
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
"""Top-level pytest fixtures.
|
||||
|
||||
Heavy fixtures (Postgres bring-up, ArduPilot SITL, Derkachi corpus mount) are added
|
||||
incrementally by the components that need them. AZ-263 ships only the smoke-level
|
||||
scaffolding.
|
||||
|
||||
Tier-2-only tests are guarded by `pytest.mark.tier2` and auto-skipped on Tier-1.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None:
|
||||
"""Auto-skip `tier2` tests when GPS_DENIED_TIER != 2."""
|
||||
if os.environ.get("GPS_DENIED_TIER") == "2":
|
||||
return
|
||||
skip_tier2 = pytest.mark.skip(reason="Tier-2-only test; set GPS_DENIED_TIER=2 to run")
|
||||
skip_gpu = pytest.mark.skip(reason="GPU-only test")
|
||||
skip_docker = pytest.mark.skip(reason="Requires Docker compose services")
|
||||
for item in items:
|
||||
if "tier2" in item.keywords:
|
||||
item.add_marker(skip_tier2)
|
||||
if "gpu" in item.keywords:
|
||||
item.add_marker(skip_gpu)
|
||||
if "docker" in item.keywords:
|
||||
item.add_marker(skip_docker)
|
||||
@@ -0,0 +1,5 @@
|
||||
# Slim pytest container for the suite-level e2e harness.
|
||||
FROM python:3.10-slim
|
||||
WORKDIR /opt/tests
|
||||
RUN pip install --no-cache-dir pytest requests pyyaml
|
||||
ENTRYPOINT ["pytest", "-q", "/opt/tests/e2e/scenarios"]
|
||||
@@ -0,0 +1,5 @@
|
||||
"""E2E pytest fixtures.
|
||||
|
||||
Bootstrap placeholder; concrete fixtures (companion URL, mock-sat URL, DB
|
||||
session) are added by AZ-406 (Blackbox Test Infrastructure Bootstrap).
|
||||
"""
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
# ArduPilot SITL fixtures
|
||||
|
||||
Bootstrap placeholder. Concrete SITL compose / launch scripts land with AZ-393
|
||||
(C8 ArduPilot outbound) and AZ-416 (FT-P-09-AP).
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"camera_id": "adti26",
|
||||
"intrinsics_3x3": [
|
||||
[1234.5, 0.0, 640.0],
|
||||
[0.0, 1234.5, 360.0],
|
||||
[0.0, 0.0, 1.0]
|
||||
],
|
||||
"distortion": [0.0, 0.0, 0.0, 0.0, 0.0],
|
||||
"body_to_camera_se3": {
|
||||
"rotation_xyzw": [0.0, 0.0, 0.0, 1.0],
|
||||
"translation_xyz_m": [0.0, 0.0, 0.0]
|
||||
},
|
||||
"acquisition_method": "calibration_target_aligned",
|
||||
"metadata": {
|
||||
"note": "Test fixture; replaced in production by adti20.json."
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
deadbeefcafef00ddeadbeefcafef00d
|
||||
@@ -0,0 +1,6 @@
|
||||
# mock-suite-sat-service (test fixture)
|
||||
|
||||
Minimal FastAPI stand-in for the parent-suite `satellite-provider`. Bootstrap
|
||||
exposes only `GET /healthz`. The full D-PROJ-2 ingest contract is implemented
|
||||
once the parent-suite design (`_docs/_process_leftovers/2026-05-09_satellite-provider-design-tasks.md`)
|
||||
lands.
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
"""Minimal mock of the parent-suite `satellite-provider`.
|
||||
|
||||
Bootstrap (AZ-263) placeholder — exposes only `GET /healthz`. The full D-PROJ-2
|
||||
ingest contract is implemented once the parent-suite design lands.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
app = FastAPI(title="mock-suite-sat-service", version="0.1.0")
|
||||
|
||||
|
||||
@app.get("/healthz")
|
||||
def healthz() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
@@ -0,0 +1,13 @@
|
||||
"""C10 CacheProvisioner smoke test — AC-9."""
|
||||
|
||||
|
||||
def test_interface_importable() -> None:
|
||||
# Assert
|
||||
from gps_denied_onboard.components.c10_provisioning import (
|
||||
CacheProvisioner,
|
||||
EngineCacheEntry,
|
||||
Manifest,
|
||||
)
|
||||
|
||||
for sym in (CacheProvisioner, Manifest, EngineCacheEntry):
|
||||
assert sym is not None
|
||||
@@ -0,0 +1,12 @@
|
||||
"""C11 TileManager smoke test — AC-9."""
|
||||
|
||||
|
||||
def test_interface_importable() -> None:
|
||||
# Assert
|
||||
from gps_denied_onboard.components.c11_tile_manager import (
|
||||
TileDownloader,
|
||||
TileUploader,
|
||||
)
|
||||
|
||||
assert TileDownloader is not None
|
||||
assert TileUploader is not None
|
||||
@@ -0,0 +1,12 @@
|
||||
"""C12 OperatorTooling smoke test — AC-9."""
|
||||
|
||||
|
||||
def test_interface_importable() -> None:
|
||||
# Assert
|
||||
from gps_denied_onboard.components.c12_operator_tooling import (
|
||||
CacheBuildWorkflow,
|
||||
OperatorReLocService,
|
||||
)
|
||||
|
||||
assert CacheBuildWorkflow is not None
|
||||
assert OperatorReLocService is not None
|
||||
@@ -0,0 +1,8 @@
|
||||
"""C13 FDR Writer smoke test — AC-9."""
|
||||
|
||||
|
||||
def test_interface_importable() -> None:
|
||||
# Assert
|
||||
from gps_denied_onboard.components.c13_fdr import FdrWriter
|
||||
|
||||
assert FdrWriter is not None
|
||||
@@ -0,0 +1,9 @@
|
||||
"""C1 VIO smoke test — AZ-263 AC-9: verify the component interface is importable."""
|
||||
|
||||
|
||||
def test_interface_importable() -> None:
|
||||
# Assert
|
||||
from gps_denied_onboard.components.c1_vio import VioOutput, VioStrategy
|
||||
|
||||
assert VioStrategy is not None
|
||||
assert VioOutput is not None
|
||||
@@ -0,0 +1,9 @@
|
||||
"""C2.5 Rerank smoke test — AC-9."""
|
||||
|
||||
|
||||
def test_interface_importable() -> None:
|
||||
# Assert
|
||||
from gps_denied_onboard.components.c2_5_rerank import RerankResult, RerankStrategy
|
||||
|
||||
assert RerankStrategy is not None
|
||||
assert RerankResult is not None
|
||||
@@ -0,0 +1,10 @@
|
||||
"""C2 VPR smoke test — AC-9."""
|
||||
|
||||
|
||||
def test_interface_importable() -> None:
|
||||
# Assert
|
||||
from gps_denied_onboard.components.c2_vpr import VprQuery, VprResult, VprStrategy
|
||||
|
||||
assert VprStrategy is not None
|
||||
assert VprQuery is not None
|
||||
assert VprResult is not None
|
||||
@@ -0,0 +1,8 @@
|
||||
"""C3.5 AdHoP smoke test — AC-9."""
|
||||
|
||||
|
||||
def test_interface_importable() -> None:
|
||||
# Assert
|
||||
from gps_denied_onboard.components.c3_5_adhop import AdHoPRefinementStrategy
|
||||
|
||||
assert AdHoPRefinementStrategy is not None
|
||||
@@ -0,0 +1,9 @@
|
||||
"""C3 Cross-Domain Matcher smoke test — AC-9."""
|
||||
|
||||
|
||||
def test_interface_importable() -> None:
|
||||
# Assert
|
||||
from gps_denied_onboard.components.c3_matcher import CrossDomainMatcher, MatchResult
|
||||
|
||||
assert CrossDomainMatcher is not None
|
||||
assert MatchResult is not None
|
||||
@@ -0,0 +1,14 @@
|
||||
"""C4 PoseEstimator smoke test — AC-9."""
|
||||
|
||||
|
||||
def test_interface_importable() -> None:
|
||||
# Assert
|
||||
from gps_denied_onboard.components.c4_pose import (
|
||||
EstimatorOutput,
|
||||
PoseEstimate,
|
||||
PoseEstimator,
|
||||
)
|
||||
|
||||
assert PoseEstimator is not None
|
||||
assert PoseEstimate is not None
|
||||
assert EstimatorOutput is not None
|
||||
@@ -0,0 +1,14 @@
|
||||
"""C5 StateEstimator smoke test — AC-9."""
|
||||
|
||||
|
||||
def test_interface_importable() -> None:
|
||||
# Assert
|
||||
from gps_denied_onboard.components.c5_state import (
|
||||
EstimatorHealth,
|
||||
EstimatorOutput,
|
||||
StateEstimator,
|
||||
)
|
||||
|
||||
assert StateEstimator is not None
|
||||
assert EstimatorOutput is not None
|
||||
assert EstimatorHealth is not None
|
||||
@@ -0,0 +1,18 @@
|
||||
"""C6 TileCache smoke test — AC-9."""
|
||||
|
||||
|
||||
def test_interface_importable() -> None:
|
||||
# Assert
|
||||
from gps_denied_onboard.components.c6_tile_cache import (
|
||||
DescriptorIndex,
|
||||
SectorClassification,
|
||||
Tile,
|
||||
TileQualityMetadata,
|
||||
TileRecord,
|
||||
TileStore,
|
||||
)
|
||||
|
||||
assert TileStore is not None
|
||||
assert DescriptorIndex is not None
|
||||
for cls in (Tile, TileQualityMetadata, TileRecord, SectorClassification):
|
||||
assert cls is not None
|
||||
@@ -0,0 +1,12 @@
|
||||
"""C7 InferenceRuntime smoke test — AC-9."""
|
||||
|
||||
|
||||
def test_interface_importable() -> None:
|
||||
# Assert
|
||||
from gps_denied_onboard.components.c7_inference import (
|
||||
EngineCacheEntry,
|
||||
InferenceRuntime,
|
||||
)
|
||||
|
||||
assert InferenceRuntime is not None
|
||||
assert EngineCacheEntry is not None
|
||||
@@ -0,0 +1,14 @@
|
||||
"""C8 FC Adapter smoke test — AC-9."""
|
||||
|
||||
|
||||
def test_interface_importable() -> None:
|
||||
# Assert
|
||||
from gps_denied_onboard.components.c8_fc_adapter import (
|
||||
EmittedExternalPosition,
|
||||
FcAdapter,
|
||||
GcsAdapter,
|
||||
ReplaySink,
|
||||
)
|
||||
|
||||
for sym in (FcAdapter, GcsAdapter, ReplaySink, EmittedExternalPosition):
|
||||
assert sym is not None
|
||||
@@ -0,0 +1,124 @@
|
||||
"""AC-10: SBOM diff script + OpenCV pin gate exist and run on stub builds."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
CI_DIR = REPO_ROOT / "ci"
|
||||
|
||||
|
||||
def test_sbom_diff_pass_on_subset(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
research = tmp_path / "research_sbom.json"
|
||||
deployment = tmp_path / "deployment_sbom.json"
|
||||
research.write_text(
|
||||
json.dumps(
|
||||
[
|
||||
{"name": "numpy", "version": "1.26.4"},
|
||||
{"name": "scipy", "version": "1.11.3"},
|
||||
{"name": "okvis2", "version": "0.1.0"},
|
||||
]
|
||||
)
|
||||
)
|
||||
deployment.write_text(
|
||||
json.dumps(
|
||||
[
|
||||
{"name": "numpy", "version": "1.26.4"},
|
||||
{"name": "okvis2", "version": "0.1.0"},
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
# Act
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(CI_DIR / "sbom_diff.py"),
|
||||
"--deployment",
|
||||
str(deployment),
|
||||
"--research",
|
||||
str(research),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result.returncode == 0, f"sbom_diff stderr:\n{result.stderr}"
|
||||
|
||||
|
||||
def test_sbom_diff_fails_on_forbidden_component(tmp_path: Path) -> None:
|
||||
# Arrange — ADR-002 / R02: vins_mono must not appear in deployment SBOM
|
||||
research = tmp_path / "research_sbom.json"
|
||||
deployment = tmp_path / "deployment_sbom.json"
|
||||
research.write_text(json.dumps([{"name": "vins_mono", "version": "0.1"}]))
|
||||
deployment.write_text(json.dumps([{"name": "vins_mono", "version": "0.1"}]))
|
||||
|
||||
# Act
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(CI_DIR / "sbom_diff.py"),
|
||||
"--deployment",
|
||||
str(deployment),
|
||||
"--research",
|
||||
str(research),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result.returncode != 0, (
|
||||
"sbom_diff must fail when a research-only component appears in deployment"
|
||||
)
|
||||
|
||||
|
||||
def test_opencv_pin_gate_passes_on_412_minimum() -> None:
|
||||
# Act
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(CI_DIR / "opencv_pin_gate.py"),
|
||||
"--pyproject",
|
||||
str(REPO_ROOT / "pyproject.toml"),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result.returncode == 0, f"opencv_pin_gate stderr:\n{result.stderr}"
|
||||
|
||||
|
||||
def test_opencv_pin_gate_fails_on_lower_version(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
bad_pyproject = tmp_path / "pyproject.toml"
|
||||
bad_pyproject.write_text(
|
||||
'[project]\nname = "x"\nversion = "0.1"\ndependencies = ["opencv-python>=4.10,<5"]\n'
|
||||
)
|
||||
|
||||
# Act
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(CI_DIR / "opencv_pin_gate.py"),
|
||||
"--pyproject",
|
||||
str(bad_pyproject),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result.returncode != 0, (
|
||||
"opencv_pin_gate must reject `opencv-python>=4.10` (D-CROSS-CVE-1 ≥ 4.12.0)"
|
||||
)
|
||||
@@ -0,0 +1,140 @@
|
||||
"""AC-1: project scaffolded matching the layout in AZ-263.
|
||||
|
||||
Validates folder/file presence + that `pyproject.toml` and the top-level
|
||||
`CMakeLists.txt` + `cmake/{dependencies,build_options}.cmake` parse without
|
||||
error. The CMake configure step is gated on `cmake` being on PATH; when it
|
||||
isn't, the test skips with an explicit prerequisite reason.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
REQUIRED_PATHS: tuple[str, ...] = (
|
||||
"pyproject.toml",
|
||||
"CMakeLists.txt",
|
||||
"cmake/dependencies.cmake",
|
||||
"cmake/build_options.cmake",
|
||||
"cmake/strategies.cmake",
|
||||
".clang-format",
|
||||
".clang-tidy",
|
||||
".cmake-format.yaml",
|
||||
".editorconfig",
|
||||
".env.example",
|
||||
".dockerignore",
|
||||
".gitignore",
|
||||
"README.md",
|
||||
"docker/companion-tier1.Dockerfile",
|
||||
"docker/operator-tooling.Dockerfile",
|
||||
"docker/mock-suite-sat-service.Dockerfile",
|
||||
"docker-compose.yml",
|
||||
"docker-compose.test.yml",
|
||||
".github/workflows/ci.yml",
|
||||
".github/workflows/ci-tier2.yml",
|
||||
".github/workflows/release.yml",
|
||||
".github/workflows/cve-rescan.yml",
|
||||
"ci/sbom_diff.py",
|
||||
"ci/opencv_pin_gate.py",
|
||||
"src/gps_denied_onboard/__init__.py",
|
||||
"src/gps_denied_onboard/runtime_root.py",
|
||||
"src/gps_denied_onboard/healthcheck.py",
|
||||
"src/gps_denied_onboard/_types/__init__.py",
|
||||
"src/gps_denied_onboard/helpers/__init__.py",
|
||||
"src/gps_denied_onboard/logging/structured.py",
|
||||
"src/gps_denied_onboard/config/loader.py",
|
||||
"src/gps_denied_onboard/fdr_client/client.py",
|
||||
"alembic.ini",
|
||||
"db/migrations/env.py",
|
||||
"db/migrations/versions/0001_initial.py",
|
||||
"scripts/run-tests.sh",
|
||||
"scripts/run-performance-tests.sh",
|
||||
)
|
||||
|
||||
COMPONENT_DIRS: tuple[str, ...] = (
|
||||
"c1_vio",
|
||||
"c2_vpr",
|
||||
"c2_5_rerank",
|
||||
"c3_matcher",
|
||||
"c3_5_adhop",
|
||||
"c4_pose",
|
||||
"c5_state",
|
||||
"c6_tile_cache",
|
||||
"c7_inference",
|
||||
"c8_fc_adapter",
|
||||
"c10_provisioning",
|
||||
"c11_tile_manager",
|
||||
"c12_operator_tooling",
|
||||
"c13_fdr",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("rel_path", REQUIRED_PATHS)
|
||||
def test_required_path_exists(rel_path: str) -> None:
|
||||
# Assert
|
||||
assert (REPO_ROOT / rel_path).exists(), f"missing required path: {rel_path}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("component", COMPONENT_DIRS)
|
||||
def test_component_has_interface_and_init(component: str) -> None:
|
||||
# Assert
|
||||
comp_root = REPO_ROOT / "src" / "gps_denied_onboard" / "components" / component
|
||||
assert (comp_root / "__init__.py").exists(), f"missing __init__.py for {component}"
|
||||
assert (comp_root / "interface.py").exists(), f"missing interface.py for {component}"
|
||||
|
||||
|
||||
def test_pyproject_toml_parses() -> None:
|
||||
# Arrange
|
||||
if sys.version_info >= (3, 11):
|
||||
import tomllib
|
||||
else:
|
||||
try:
|
||||
import tomli as tomllib # type: ignore[no-redef]
|
||||
except ImportError: # pragma: no cover - tomli installed in dev extras
|
||||
pytest.skip("tomli/tomllib not available")
|
||||
|
||||
# Act
|
||||
data = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text())
|
||||
|
||||
# Assert
|
||||
assert data["project"]["name"] == "gps-denied-onboard"
|
||||
assert any(dep.startswith("opencv-python") for dep in data["project"]["dependencies"]), (
|
||||
"opencv-python pin must be in dependencies"
|
||||
)
|
||||
|
||||
|
||||
def test_cmake_files_configure() -> None:
|
||||
# Arrange
|
||||
cmake = shutil.which("cmake")
|
||||
if cmake is None:
|
||||
pytest.skip("cmake not on PATH; CI image installs cmake (Tier-1 ci.yml)")
|
||||
|
||||
build_dir = REPO_ROOT / "build" / "ac1_smoke"
|
||||
build_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Act
|
||||
result = subprocess.run(
|
||||
[
|
||||
cmake,
|
||||
"-S",
|
||||
str(REPO_ROOT),
|
||||
"-B",
|
||||
str(build_dir),
|
||||
"-DBUILD_TESTING=OFF",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result.returncode == 0, (
|
||||
f"cmake configure failed:\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}"
|
||||
)
|
||||
@@ -0,0 +1,78 @@
|
||||
"""AC-3: docker-compose.yml and docker-compose.test.yml are valid.
|
||||
|
||||
YAML syntactic validity always runs. The `docker compose ... config --quiet`
|
||||
shape validation requires the Docker daemon and the v2 plugin; when those are
|
||||
not present, that test skips with the prerequisite reason.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
COMPOSE_FILES = (
|
||||
REPO_ROOT / "docker-compose.yml",
|
||||
REPO_ROOT / "docker-compose.test.yml",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("compose_path", COMPOSE_FILES)
|
||||
def test_compose_yaml_parses(compose_path: Path) -> None:
|
||||
# Act
|
||||
parsed = yaml.safe_load(compose_path.read_text())
|
||||
# Assert
|
||||
assert isinstance(parsed, dict), f"{compose_path.name} must parse to a mapping"
|
||||
assert "services" in parsed, f"{compose_path.name} must declare a services block"
|
||||
|
||||
|
||||
def test_compose_yml_declares_required_services() -> None:
|
||||
# Arrange
|
||||
data = yaml.safe_load((REPO_ROOT / "docker-compose.yml").read_text())
|
||||
services = data["services"]
|
||||
# Assert
|
||||
for required in ("companion", "operator-tooling", "mock-sat", "db"):
|
||||
assert required in services, f"docker-compose.yml missing service: {required}"
|
||||
|
||||
|
||||
def test_compose_test_yml_extends_base() -> None:
|
||||
# Arrange
|
||||
data = yaml.safe_load((REPO_ROOT / "docker-compose.test.yml").read_text())
|
||||
# Assert
|
||||
assert "services" in data, "docker-compose.test.yml must declare services"
|
||||
assert "e2e-runner" in data["services"], (
|
||||
"docker-compose.test.yml must declare the e2e-runner sidecar"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("compose_path", COMPOSE_FILES)
|
||||
def test_compose_config_quiet(compose_path: Path) -> None:
|
||||
# Arrange
|
||||
docker = shutil.which("docker")
|
||||
if docker is None:
|
||||
pytest.skip("docker CLI not on PATH; Tier-1 CI image installs Docker")
|
||||
|
||||
plugin_check = subprocess.run(
|
||||
[docker, "compose", "version"], capture_output=True, text=True, check=False
|
||||
)
|
||||
if plugin_check.returncode != 0:
|
||||
pytest.skip("docker compose v2 plugin unavailable; Tier-1 CI image installs it")
|
||||
|
||||
# Act
|
||||
result = subprocess.run(
|
||||
[docker, "compose", "-f", str(compose_path), "config", "--quiet"],
|
||||
cwd=REPO_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result.returncode == 0, (
|
||||
f"docker compose config --quiet failed for {compose_path.name}:\n"
|
||||
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
|
||||
)
|
||||
@@ -0,0 +1,68 @@
|
||||
"""AC-4: GitHub Actions workflows under `.github/workflows/` are valid.
|
||||
|
||||
YAML syntactic validity + ADR-002 dual-binary build matrix check always run.
|
||||
`actionlint` semantic validation runs only when the binary is on PATH; CI
|
||||
installs it as a job step.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
WORKFLOWS_DIR = REPO_ROOT / ".github" / "workflows"
|
||||
WORKFLOWS = sorted(WORKFLOWS_DIR.glob("*.yml"))
|
||||
|
||||
|
||||
def test_workflows_dir_populated() -> None:
|
||||
# Assert
|
||||
names = {p.name for p in WORKFLOWS}
|
||||
assert {"ci.yml", "ci-tier2.yml", "release.yml", "cve-rescan.yml"} <= names
|
||||
|
||||
|
||||
@pytest.mark.parametrize("workflow", WORKFLOWS, ids=[p.name for p in WORKFLOWS])
|
||||
def test_workflow_yaml_parses(workflow: Path) -> None:
|
||||
# Act
|
||||
data = yaml.safe_load(workflow.read_text())
|
||||
# Assert
|
||||
assert isinstance(data, dict), f"{workflow.name} must parse to a mapping"
|
||||
# GitHub Actions reserves `on` as a top-level key; PyYAML preserves it as a
|
||||
# bool-style key, so also accept the bool key `True` produced by safe_load.
|
||||
assert "on" in data or True in data, f"{workflow.name} missing trigger block"
|
||||
assert data.get("jobs"), f"{workflow.name} must declare jobs"
|
||||
|
||||
|
||||
def test_ci_yml_has_dual_binary_matrix() -> None:
|
||||
"""ADR-002: deployment + research must both build in ci.yml."""
|
||||
# Arrange
|
||||
raw = (WORKFLOWS_DIR / "ci.yml").read_text()
|
||||
# Assert
|
||||
# Match the matrix dimension we care about without depending on YAML key order.
|
||||
assert "deployment" in raw, "ci.yml matrix must include `deployment` kind"
|
||||
assert "research" in raw, "ci.yml matrix must include `research` kind"
|
||||
assert "matrix:" in raw, "ci.yml build job must use a strategy matrix"
|
||||
|
||||
|
||||
def test_actionlint_passes() -> None:
|
||||
# Arrange
|
||||
actionlint = shutil.which("actionlint")
|
||||
if actionlint is None:
|
||||
pytest.skip("actionlint not on PATH; CI installs it before the lint job")
|
||||
|
||||
# Act
|
||||
result = subprocess.run(
|
||||
[actionlint, *(str(p) for p in WORKFLOWS)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result.returncode == 0, (
|
||||
f"actionlint reported errors:\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}"
|
||||
)
|
||||
@@ -0,0 +1,66 @@
|
||||
"""AC-5: alembic head is `0001_initial` and schema matches data_model.md § 2.
|
||||
|
||||
The migration is verified by inspecting the head revision and the upgrade
|
||||
script's table/column declarations. We do NOT spin up Postgres here — that's
|
||||
covered by integration tests; this is a Tier-1 unit check that the migration
|
||||
metadata is correctly wired.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from alembic import script as alembic_script
|
||||
from alembic.config import Config
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
MIGRATION_BODY = (REPO_ROOT / "db" / "migrations" / "versions" / "0001_initial.py").read_text()
|
||||
|
||||
|
||||
def test_head_revision_is_0001_initial() -> None:
|
||||
# Arrange
|
||||
cwd = os.getcwd()
|
||||
os.chdir(REPO_ROOT)
|
||||
try:
|
||||
cfg = Config(str(REPO_ROOT / "alembic.ini"))
|
||||
sc = alembic_script.ScriptDirectory.from_config(cfg)
|
||||
# Act
|
||||
heads = sc.get_heads()
|
||||
finally:
|
||||
os.chdir(cwd)
|
||||
|
||||
# Assert
|
||||
assert list(heads) == ["0001_initial"], f"unexpected heads: {heads}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"table",
|
||||
[
|
||||
"tiles",
|
||||
"flights",
|
||||
"sector_classifications",
|
||||
"manifests",
|
||||
"engine_cache_entries",
|
||||
],
|
||||
)
|
||||
def test_initial_migration_declares_table(table: str) -> None:
|
||||
# Assert — tolerate multi-line `op.create_table(\n "<table>"` formatting.
|
||||
pattern = re.compile(
|
||||
r"create_table\(\s*['\"]" + re.escape(table) + r"['\"]",
|
||||
re.DOTALL,
|
||||
)
|
||||
assert pattern.search(MIGRATION_BODY), f"0001_initial.py missing create_table for `{table}`"
|
||||
|
||||
|
||||
def test_tiles_table_has_canonical_source_check() -> None:
|
||||
"""Canonical onboard-only additive columns from data_model.md § 2.1.1."""
|
||||
# Assert
|
||||
assert "googlemaps" in MIGRATION_BODY and "onboard_ingest" in MIGRATION_BODY, (
|
||||
"tiles.source CHECK constraint must allow ('googlemaps', 'onboard_ingest')"
|
||||
)
|
||||
assert "tile_quality_metadata" in MIGRATION_BODY, (
|
||||
"tiles must include the onboard-only `tile_quality_metadata` jsonb column"
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
"""healthcheck smoke — AZ-263 AC-6 (healthcheck importable + runnable)."""
|
||||
|
||||
from gps_denied_onboard.healthcheck import check
|
||||
|
||||
|
||||
def test_healthcheck_returns_zero_when_imports_succeed() -> None:
|
||||
# Act / Assert
|
||||
assert check() == 0
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Structured logging smoke — AZ-263 AC-7."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from gps_denied_onboard.logging import get_logger
|
||||
|
||||
|
||||
def test_get_logger_returns_logger_instance() -> None:
|
||||
# Act
|
||||
logger = get_logger("test.smoke")
|
||||
|
||||
# Assert
|
||||
assert isinstance(logger, logging.Logger)
|
||||
|
||||
|
||||
def test_log_lines_are_single_json_objects() -> None:
|
||||
# Arrange
|
||||
import io
|
||||
|
||||
from gps_denied_onboard.logging.structured import _JsonFormatter
|
||||
|
||||
stream = io.StringIO()
|
||||
handler = logging.StreamHandler(stream)
|
||||
handler.setFormatter(_JsonFormatter())
|
||||
logger = logging.getLogger("test.json.unit")
|
||||
logger.handlers = [handler]
|
||||
logger.setLevel(logging.DEBUG)
|
||||
logger.propagate = False
|
||||
|
||||
# Act
|
||||
logger.error("hello world", extra={"event": "smoke", "value": 42})
|
||||
|
||||
# Assert
|
||||
line = stream.getvalue().strip().splitlines()[-1]
|
||||
payload = json.loads(line)
|
||||
assert payload["level"] == "ERROR"
|
||||
assert payload["msg"] == "hello world"
|
||||
assert payload["event"] == "smoke"
|
||||
assert payload["value"] == 42
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Runtime-root env-var fail-fast — AZ-263 AC-8."""
|
||||
|
||||
import pytest
|
||||
|
||||
from gps_denied_onboard.runtime_root import ConfigurationError, compose_root
|
||||
|
||||
|
||||
def test_compose_root_fails_fast_on_missing_required(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# Arrange
|
||||
for var in (
|
||||
"GPS_DENIED_FC_PROFILE",
|
||||
"GPS_DENIED_TIER",
|
||||
"DB_URL",
|
||||
"CAMERA_CALIBRATION_PATH",
|
||||
"LOG_LEVEL",
|
||||
"LOG_SINK",
|
||||
"INFERENCE_BACKEND",
|
||||
"FDR_PATH",
|
||||
"TILE_CACHE_PATH",
|
||||
"MAVLINK_SIGNING_KEY",
|
||||
):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(ConfigurationError) as excinfo:
|
||||
compose_root()
|
||||
assert "Missing required environment variable" in str(excinfo.value)
|
||||
|
||||
|
||||
def test_compose_root_names_the_first_missing_var(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# Arrange
|
||||
for var in (
|
||||
"GPS_DENIED_FC_PROFILE",
|
||||
"GPS_DENIED_TIER",
|
||||
"DB_URL",
|
||||
"CAMERA_CALIBRATION_PATH",
|
||||
"LOG_LEVEL",
|
||||
"LOG_SINK",
|
||||
"INFERENCE_BACKEND",
|
||||
"FDR_PATH",
|
||||
"TILE_CACHE_PATH",
|
||||
"MAVLINK_SIGNING_KEY",
|
||||
):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
|
||||
# Act
|
||||
with pytest.raises(ConfigurationError) as excinfo:
|
||||
compose_root()
|
||||
|
||||
# Assert
|
||||
msg = str(excinfo.value)
|
||||
assert "GPS_DENIED_FC_PROFILE" in msg
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Cross-component DTO importability — AZ-263 AC-2.
|
||||
|
||||
Mirrors the AC's specified `python -c "from gps_denied_onboard._types import ..."`.
|
||||
"""
|
||||
|
||||
|
||||
def test_types_modules_importable() -> None:
|
||||
# Assert
|
||||
from gps_denied_onboard._types import (
|
||||
calibration,
|
||||
emitted,
|
||||
manifests,
|
||||
matching,
|
||||
nav,
|
||||
pose,
|
||||
tile,
|
||||
vio,
|
||||
vpr,
|
||||
)
|
||||
|
||||
for mod in (nav, vio, vpr, matching, pose, tile, calibration, emitted, manifests):
|
||||
assert mod is not None
|
||||
Reference in New Issue
Block a user