feat(perception): add autonomous vegetation shadow lab
This commit is contained in:
@@ -138,6 +138,7 @@ from k1link.web.m49_physical_safety_playback_api import (
|
||||
)
|
||||
from k1link.web.m49_tgs_fail_closed_api import build_m49_tgs_fail_closed_router
|
||||
from k1link.web.m49_tgs_full_shadow_api import build_m49_tgs_full_shadow_router
|
||||
from k1link.web.vegetation_shadow_lab_api import build_vegetation_shadow_lab_router
|
||||
from k1link.web.map_api import (
|
||||
MapGatewayConfiguration,
|
||||
MapGatewayProxy,
|
||||
@@ -1019,6 +1020,17 @@ app.include_router(
|
||||
),
|
||||
)
|
||||
)
|
||||
app.include_router(
|
||||
build_vegetation_shadow_lab_router(
|
||||
root_provider=lambda: (
|
||||
REPOSITORY_ROOT
|
||||
/ ".runtime"
|
||||
/ "compute-experiments"
|
||||
/ "lab-v1-vegetation"
|
||||
/ "results"
|
||||
),
|
||||
)
|
||||
)
|
||||
app.include_router(
|
||||
build_m49_physical_safety_playback_router(
|
||||
root_provider=lambda: (
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
"""Read-only API for the autonomous vegetation policy shadow LAB."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from functools import lru_cache
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any, Final
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from k1link.laboratory.evidence_registry import LaboratoryEvidenceDefinition
|
||||
from k1link.laboratory.evidence_report import (
|
||||
LaboratoryEvidenceReportError,
|
||||
verify_laboratory_evidence_result,
|
||||
)
|
||||
from k1link.laboratory.vegetation_shadow_lab import LAB_SCHEMA, RESULT_PREFIX
|
||||
|
||||
RootProvider = Callable[[], Path | None]
|
||||
RESULT_ID: Final = re.compile(rf"^{re.escape(RESULT_PREFIX)}[a-f0-9]{{64}}$")
|
||||
_MAX_DOCUMENT_BYTES: Final = 1024 * 1024
|
||||
_DEFINITION: Final = LaboratoryEvidenceDefinition(
|
||||
work_id="lab-v1-vegetation-shadow",
|
||||
runtime_relative_root=PurePosixPath("lab-v1-vegetation/results"),
|
||||
result_id_prefix="lab-v1-vegetation-shadow",
|
||||
document_name="result.json",
|
||||
result_schema_version=LAB_SCHEMA,
|
||||
)
|
||||
|
||||
|
||||
def build_vegetation_shadow_lab_router(
|
||||
*, root_provider: RootProvider = lambda: None,
|
||||
) -> APIRouter:
|
||||
router = APIRouter(
|
||||
prefix="/api/v1/laboratory/vegetation-shadow",
|
||||
tags=["laboratory"],
|
||||
)
|
||||
|
||||
@router.get("/{result_id}")
|
||||
def get_result(result_id: str) -> dict[str, object]:
|
||||
candidate = _resolve_candidate(root_provider, result_id)
|
||||
return {**copy.deepcopy(_read_verified(candidate)), "access": "read-only"}
|
||||
|
||||
@router.get("/{result_id}/assets/{asset_path:path}")
|
||||
def get_asset(result_id: str, asset_path: str) -> FileResponse:
|
||||
candidate = _resolve_candidate(root_provider, result_id)
|
||||
manifest = _read_verified(candidate)
|
||||
artifacts = manifest.get("artifacts")
|
||||
if not isinstance(artifacts, list):
|
||||
raise HTTPException(status_code=404, detail="Vegetation LAB asset not found")
|
||||
descriptor = next(
|
||||
(
|
||||
item
|
||||
for item in artifacts
|
||||
if isinstance(item, dict) and item.get("path") == asset_path
|
||||
),
|
||||
None,
|
||||
)
|
||||
if descriptor is None:
|
||||
raise HTTPException(status_code=404, detail="Vegetation LAB asset not found")
|
||||
relative = PurePosixPath(asset_path)
|
||||
path = candidate.joinpath(*relative.parts)
|
||||
if (
|
||||
relative.is_absolute()
|
||||
or str(relative) != asset_path
|
||||
or any(part in {"", ".", ".."} for part in relative.parts)
|
||||
or path.is_symlink()
|
||||
or not path.is_file()
|
||||
or not path.resolve().is_relative_to(candidate)
|
||||
):
|
||||
raise HTTPException(status_code=404, detail="Vegetation LAB asset not found")
|
||||
media_type = descriptor.get("media_type")
|
||||
if not isinstance(media_type, str) or not media_type.startswith("image/"):
|
||||
raise HTTPException(status_code=404, detail="Vegetation LAB asset not found")
|
||||
return FileResponse(
|
||||
path,
|
||||
media_type=media_type,
|
||||
headers={
|
||||
"Cache-Control": "private, max-age=31536000, immutable",
|
||||
"ETag": f'"{descriptor.get("sha256", "")}"',
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
)
|
||||
|
||||
return router
|
||||
|
||||
|
||||
def _configured_root(provider: RootProvider) -> Path | None:
|
||||
candidate = provider()
|
||||
if candidate is None:
|
||||
return None
|
||||
absolute = candidate.expanduser().absolute()
|
||||
if absolute.is_symlink():
|
||||
return None
|
||||
try:
|
||||
root = absolute.resolve(strict=True)
|
||||
except OSError:
|
||||
return None
|
||||
return root if root.is_dir() else None
|
||||
|
||||
|
||||
def _resolve_candidate(provider: RootProvider, result_id: str) -> Path:
|
||||
root = _configured_root(provider)
|
||||
if root is None or RESULT_ID.fullmatch(result_id) is None:
|
||||
raise HTTPException(status_code=404, detail="Vegetation LAB result not found")
|
||||
candidate = root / result_id
|
||||
if candidate.is_symlink():
|
||||
raise HTTPException(status_code=404, detail="Vegetation LAB result not found")
|
||||
try:
|
||||
resolved = candidate.resolve(strict=True)
|
||||
except OSError:
|
||||
raise HTTPException(status_code=404, detail="Vegetation LAB result not found") from None
|
||||
if not resolved.is_dir() or not resolved.is_relative_to(root):
|
||||
raise HTTPException(status_code=404, detail="Vegetation LAB result not found")
|
||||
return resolved
|
||||
|
||||
|
||||
def _read_verified(candidate: Path) -> dict[str, Any]:
|
||||
try:
|
||||
rows: list[tuple[str, int, int, int, int]] = []
|
||||
for path in sorted(candidate.rglob("*"), key=lambda item: item.as_posix()):
|
||||
stat = path.lstat()
|
||||
rows.append((
|
||||
path.relative_to(candidate).as_posix(),
|
||||
stat.st_mode,
|
||||
stat.st_size,
|
||||
stat.st_mtime_ns,
|
||||
stat.st_ctime_ns,
|
||||
))
|
||||
signature = tuple(rows)
|
||||
except OSError:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Vegetation LAB evidence failed verification",
|
||||
) from None
|
||||
return _read_verified_cached(str(candidate), signature)
|
||||
|
||||
|
||||
@lru_cache(maxsize=16)
|
||||
def _read_verified_cached(
|
||||
candidate_text: str,
|
||||
signature: tuple[tuple[str, int, int, int, int], ...],
|
||||
) -> dict[str, Any]:
|
||||
del signature
|
||||
candidate = Path(candidate_text)
|
||||
try:
|
||||
verify_laboratory_evidence_result(_DEFINITION, candidate)
|
||||
path = candidate / "result.json"
|
||||
if path.stat().st_size > _MAX_DOCUMENT_BYTES:
|
||||
raise LaboratoryEvidenceReportError("Vegetation LAB document is too large")
|
||||
payload = json.loads(path.read_text("utf-8"))
|
||||
except (json.JSONDecodeError, OSError, LaboratoryEvidenceReportError):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Vegetation LAB evidence failed verification",
|
||||
) from None
|
||||
if not isinstance(payload, dict):
|
||||
raise HTTPException(status_code=503, detail="Vegetation LAB evidence is invalid")
|
||||
return payload
|
||||
|
||||
|
||||
__all__ = ["build_vegetation_shadow_lab_router"]
|
||||
Reference in New Issue
Block a user