tile_manifest_path arrives in the detection and segmentation request and was read straight off disk, and a manifest entry may name an absolute tile path. That makes an API field an unbounded reference to the host filesystem, and it contradicts the rule the persistence model rests on: only a governed, runtime-produced artifact may be consumed, and a file outside the storage root is not one. Both the manifest and every tile it names now resolve under STORAGE_ROOT. Resolution happens before the comparison, so ".." cannot climb out and a sibling that merely shares a name prefix does not pass. GEOINTEL_ALLOW_EXTERNAL_ARTIFACT_PATHS opts out for provisioning workflows that stage tiles before ingest. The check honours the Settings the caller is operating under rather than the process-wide ones, because every analysis path already threads its own. The affected tests write manifests into tmp_path, so they now declare tmp_path as the storage root — which is what a deployment does, and makes the fixtures more honest than they were. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
363 lines
13 KiB
Python
363 lines
13 KiB
Python
from __future__ import annotations
|
|
|
|
from hashlib import sha256
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
from app.core.config import Settings
|
|
from app.main import app
|
|
from app.services.runtime_model_provenance_service import RuntimeModelProvenanceService
|
|
from app.services.yolo_preflight_service import YoloPreflightService
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
|
|
|
|
class AvailableAdapter:
|
|
@staticmethod
|
|
def dependencies_available() -> bool:
|
|
return True
|
|
|
|
def __init__(self, settings: Settings) -> None:
|
|
self.settings = settings
|
|
|
|
def load_model(self, model_path: Path) -> object:
|
|
return {"model_path": str(model_path)}
|
|
|
|
|
|
class MissingDependencyAdapter:
|
|
@staticmethod
|
|
def dependencies_available() -> bool:
|
|
return False
|
|
|
|
|
|
class FailingLoadAdapter(AvailableAdapter):
|
|
def load_model(self, model_path: Path) -> object:
|
|
raise RuntimeError(f"cannot load {model_path}")
|
|
|
|
|
|
def _manifest(tmp_path: Path, tile_count: int = 1) -> Path:
|
|
tiles = []
|
|
for index in range(tile_count):
|
|
tile_path = tmp_path / f"tile_{index:04d}.tif"
|
|
tile_path.write_bytes(b"tile")
|
|
tiles.append(
|
|
{
|
|
"path": str(tile_path),
|
|
"pixel_window": [0, 0, 100, 100],
|
|
"bounds": [4.0, 51.0, 5.0, 52.0],
|
|
"transform": [4.0, 0.01, 0.0, 52.0, 0.0, -0.01],
|
|
"index": index,
|
|
}
|
|
)
|
|
manifest_path = tmp_path / "manifest.json"
|
|
manifest_path.write_text(json.dumps({"tiles": tiles, "count": tile_count}), encoding="utf-8")
|
|
return manifest_path
|
|
|
|
|
|
def _write_model_sidecar(model_path: Path, settings: Settings) -> None:
|
|
model_sha256 = sha256(model_path.read_bytes()).hexdigest()
|
|
payload = {
|
|
"schema_version": RuntimeModelProvenanceService.MANIFEST_SCHEMA_VERSION,
|
|
"data_contract": {"key": "geointel.model.pytorch", "version": "1.0.0"},
|
|
"model": {
|
|
"model_id": settings.yolo_model_id,
|
|
"task_type": "object_detection",
|
|
"sha256": model_sha256,
|
|
"model_format": "pytorch",
|
|
"framework": "ultralytics/pytorch",
|
|
"class_mapping": {"0": "building"},
|
|
"source_version": settings.yolo_model_version or "test-v1",
|
|
},
|
|
"source": {
|
|
"source_registry_id": "11111111-1111-4111-8111-111111111111",
|
|
"source_snapshot_id": "22222222-2222-4222-8222-222222222222",
|
|
"source_registry_key": "model",
|
|
"source_snapshot_checksum_sha256": model_sha256,
|
|
},
|
|
"lineage": {
|
|
"upstream_asset_ids": ["test-training-corpus"],
|
|
"upstream_checksums_sha256": ["a" * 64],
|
|
"transformations": [
|
|
{"name": "test-training", "version": "1.0.0", "checksum_sha256": "b" * 64}
|
|
],
|
|
},
|
|
"metadata": {"training_manifest_sha256": "c" * 64},
|
|
"imported_at": "2026-08-01T10:00:00+00:00",
|
|
}
|
|
payload["metadata"]["runtime_manifest_sha256"] = RuntimeModelProvenanceService.manifest_self_checksum(payload)
|
|
RuntimeModelProvenanceService.manifest_path_for_model(model_path).write_text(
|
|
json.dumps(payload, sort_keys=True),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
|
|
def test_yolo_preflight_reports_disabled_without_loading_model(tmp_path: Path, monkeypatch) -> None:
|
|
monkeypatch.setenv("YOLO_CONFIG_DIR", str(tmp_path / "ultralytics"))
|
|
|
|
result = YoloPreflightService.run(
|
|
settings=Settings(yolo_enabled=False, yolo_model_path=str(tmp_path / "missing.pt")),
|
|
tile_manifest_path=str(tmp_path / "missing-manifest.json"),
|
|
yolo_adapter_class=AvailableAdapter,
|
|
)
|
|
|
|
assert result["status"] == "not_configured"
|
|
assert result["checks"]["enabled"] is False
|
|
assert result["checks"]["dependencies_available"] is None
|
|
assert result["checks"]["model_file_exists"] is None
|
|
assert result["checks"]["manifest_valid"] is None
|
|
assert result["runtime"]["dependencies_assumed"] is False
|
|
assert result["runtime"]["model_directory"] == str(tmp_path)
|
|
assert result["runtime"]["yolo_config_dir"] == str(tmp_path / "ultralytics")
|
|
assert "torch_version" in result["runtime"]
|
|
assert "ultralytics_version" in result["runtime"]
|
|
assert "cuda_available" in result["runtime"]
|
|
|
|
|
|
def test_yolo_preflight_distinguishes_missing_dependencies_from_missing_model(tmp_path: Path) -> None:
|
|
model_path = tmp_path / "model.pt"
|
|
model_path.write_bytes(b"weights")
|
|
|
|
result = YoloPreflightService.run(
|
|
settings=Settings(yolo_enabled=True, yolo_model_path=str(model_path)),
|
|
tile_manifest_path=str(_manifest(tmp_path)),
|
|
yolo_adapter_class=MissingDependencyAdapter,
|
|
)
|
|
|
|
assert result["status"] == "dependency_unavailable"
|
|
assert result["checks"]["dependencies_available"] is False
|
|
assert result["checks"]["model_file_exists"] is None
|
|
assert result["checks"]["manifest_valid"] is None
|
|
|
|
|
|
def test_yolo_preflight_validates_model_and_manifest_without_importing_yolo(tmp_path: Path) -> None:
|
|
model_path = tmp_path / "model.pt"
|
|
model_path.write_bytes(b"weights")
|
|
manifest_path = _manifest(tmp_path, tile_count=2)
|
|
settings = Settings(
|
|
storage_root=str(tmp_path),
|
|
yolo_enabled=True,
|
|
yolo_model_path=str(model_path),
|
|
yolo_max_tiles=4,
|
|
)
|
|
_write_model_sidecar(model_path, settings)
|
|
|
|
result = YoloPreflightService.run(
|
|
settings=settings,
|
|
tile_manifest_path=str(manifest_path),
|
|
yolo_adapter_class=AvailableAdapter,
|
|
)
|
|
|
|
assert result["status"] == "ready"
|
|
assert result["checks"]["dependencies_available"] is True
|
|
assert result["checks"]["model_file_exists"] is True
|
|
assert result["checks"]["model_provenance_valid"] is True
|
|
assert result["checks"]["manifest_valid"] is True
|
|
assert result["tile_count"] == 2
|
|
assert result["will_download_models"] is False
|
|
assert result["will_run_inference"] is False
|
|
assert result["runtime"]["dependencies_assumed"] is False
|
|
|
|
|
|
def test_yolo_preflight_marks_assumed_dependencies_in_runtime_details(tmp_path: Path) -> None:
|
|
model_path = tmp_path / "model.pt"
|
|
model_path.write_bytes(b"weights")
|
|
manifest_path = _manifest(tmp_path, tile_count=1)
|
|
settings = Settings(
|
|
storage_root=str(tmp_path),
|
|
yolo_enabled=True,
|
|
yolo_model_path=str(model_path),
|
|
yolo_max_tiles=4,
|
|
)
|
|
_write_model_sidecar(model_path, settings)
|
|
|
|
result = YoloPreflightService.run(
|
|
settings=settings,
|
|
tile_manifest_path=str(manifest_path),
|
|
yolo_adapter_class=MissingDependencyAdapter,
|
|
assume_dependencies=True,
|
|
)
|
|
|
|
assert result["status"] == "ready"
|
|
assert result["checks"]["dependencies_available"] is True
|
|
assert result["runtime"]["dependencies_assumed"] is True
|
|
assert result["runtime"]["model_directory"] == str(tmp_path)
|
|
|
|
|
|
def test_yolo_preflight_can_explicitly_smoke_load_local_model(tmp_path: Path) -> None:
|
|
model_path = tmp_path / "model.pt"
|
|
model_path.write_bytes(b"weights")
|
|
manifest_path = _manifest(tmp_path)
|
|
settings = Settings(
|
|
storage_root=str(tmp_path),
|
|
yolo_enabled=True,
|
|
yolo_model_path=str(model_path),
|
|
yolo_max_tiles=4,
|
|
)
|
|
_write_model_sidecar(model_path, settings)
|
|
|
|
result = YoloPreflightService.run(
|
|
settings=settings,
|
|
tile_manifest_path=str(manifest_path),
|
|
yolo_adapter_class=AvailableAdapter,
|
|
check_model_load=True,
|
|
)
|
|
|
|
assert result["status"] == "ready"
|
|
assert result["checks"]["model_load_requested"] is True
|
|
assert result["checks"]["model_load_ok"] is True
|
|
assert result["will_download_models"] is False
|
|
assert result["will_run_inference"] is False
|
|
|
|
|
|
def test_yolo_preflight_reports_explicit_model_load_failure(tmp_path: Path) -> None:
|
|
model_path = tmp_path / "model.pt"
|
|
model_path.write_bytes(b"weights")
|
|
settings = Settings(
|
|
storage_root=str(tmp_path),
|
|
yolo_enabled=True,
|
|
yolo_model_path=str(model_path),
|
|
yolo_max_tiles=4,
|
|
)
|
|
_write_model_sidecar(model_path, settings)
|
|
|
|
result = YoloPreflightService.run(
|
|
settings=settings,
|
|
tile_manifest_path=str(_manifest(tmp_path)),
|
|
yolo_adapter_class=FailingLoadAdapter,
|
|
check_model_load=True,
|
|
)
|
|
|
|
assert result["status"] == "model_load_failed"
|
|
assert result["error_code"] == "DETECTION_MODEL_LOAD_FAILED"
|
|
assert result["checks"]["model_load_ok"] is False
|
|
|
|
|
|
def test_yolo_preflight_script_outputs_json(tmp_path: Path) -> None:
|
|
model_path = tmp_path / "model.pt"
|
|
model_path.write_bytes(b"weights")
|
|
manifest_path = _manifest(tmp_path)
|
|
_write_model_sidecar(model_path, Settings(yolo_enabled=True, yolo_model_path=str(model_path)))
|
|
|
|
result = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
str(ROOT / "scripts" / "yolo_preflight.py"),
|
|
"--model-path",
|
|
str(model_path),
|
|
"--tile-manifest-path",
|
|
str(manifest_path),
|
|
"--assume-dependencies",
|
|
"--json",
|
|
],
|
|
cwd=ROOT,
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
# The script reads process settings; the manifest it is asked to
|
|
# validate lives here, so this is the storage root for that run.
|
|
env={**os.environ, "STORAGE_ROOT": str(tmp_path)},
|
|
)
|
|
payload = json.loads(result.stdout)
|
|
|
|
assert payload["status"] == "ready"
|
|
assert payload["model_path"] == str(model_path)
|
|
assert payload["tile_manifest_path"] == str(manifest_path)
|
|
|
|
|
|
def test_yolo_preflight_script_uses_environment_configuration(tmp_path: Path, monkeypatch) -> None:
|
|
model_path = tmp_path / "model.pt"
|
|
model_path.write_bytes(b"weights")
|
|
manifest_path = _manifest(tmp_path)
|
|
_write_model_sidecar(model_path, Settings(
|
|
storage_root=str(tmp_path),
|
|
yolo_enabled=True,
|
|
yolo_model_path=str(model_path),
|
|
yolo_max_tiles=4,
|
|
))
|
|
monkeypatch.setenv("STORAGE_ROOT", str(tmp_path))
|
|
monkeypatch.setenv("YOLO_ENABLED", "true")
|
|
monkeypatch.setenv("YOLO_MODEL_PATH", str(model_path))
|
|
monkeypatch.setenv("YOLO_MAX_TILES", "4")
|
|
|
|
result = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
str(ROOT / "scripts" / "yolo_preflight.py"),
|
|
"--tile-manifest-path",
|
|
str(manifest_path),
|
|
"--assume-dependencies",
|
|
"--json",
|
|
],
|
|
cwd=ROOT,
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
payload = json.loads(result.stdout)
|
|
|
|
assert payload["status"] == "ready"
|
|
assert payload["checks"]["enabled"] is True
|
|
assert payload["model_path"] == str(model_path)
|
|
assert payload["max_tiles"] == 4
|
|
|
|
|
|
def test_yolo_preflight_refuses_unmanifested_local_weights(tmp_path: Path) -> None:
|
|
model_path = tmp_path / "model.pt"
|
|
model_path.write_bytes(b"unmanifested weights")
|
|
|
|
result = YoloPreflightService.run(
|
|
settings=Settings(yolo_enabled=True, yolo_model_path=str(model_path)),
|
|
tile_manifest_path=str(_manifest(tmp_path)),
|
|
yolo_adapter_class=AvailableAdapter,
|
|
)
|
|
|
|
assert result["status"] == "contract_incomplete"
|
|
assert result["checks"]["model_provenance_valid"] is False
|
|
assert result["error_code"] == "MODEL_PROVENANCE_MANIFEST_MISSING"
|
|
|
|
|
|
def test_yolo_preflight_script_rejects_assumed_dependencies_for_model_load(tmp_path: Path) -> None:
|
|
result = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
str(ROOT / "scripts" / "yolo_preflight.py"),
|
|
"--model-path",
|
|
str(tmp_path / "model.pt"),
|
|
"--assume-dependencies",
|
|
"--check-model-load",
|
|
"--json",
|
|
],
|
|
cwd=ROOT,
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
assert result.returncode != 0
|
|
assert "--check-model-load cannot be combined with --assume-dependencies" in result.stderr
|
|
|
|
|
|
def test_yolo_preflight_api_returns_canonical_envelope(monkeypatch, tmp_path: Path) -> None:
|
|
monkeypatch.setenv("STORAGE_ROOT", str(tmp_path))
|
|
monkeypatch.setenv("YOLO_ENABLED", "false")
|
|
monkeypatch.setenv("YOLO_MODEL_PATH", str(tmp_path / "missing.pt"))
|
|
monkeypatch.setenv("YOLO_CONFIG_DIR", str(tmp_path / "ultralytics"))
|
|
|
|
response = TestClient(app).get("/api/v1/detection/yolo/preflight")
|
|
|
|
assert response.status_code == 200
|
|
payload = response.json()
|
|
assert set(payload) == {"data"}
|
|
assert payload["data"]["status"] == "not_configured"
|
|
assert payload["data"]["checks"]["enabled"] is False
|
|
assert payload["data"]["runtime"]["model_directory"] == str(tmp_path)
|
|
assert payload["data"]["runtime"]["yolo_config_dir"] == str(tmp_path / "ultralytics")
|
|
assert payload["data"]["will_download_models"] is False
|
|
assert payload["data"]["will_run_inference"] is False
|