mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-23 13:31:13 +00:00
[AZ-507] [AZ-323] [AZ-324] C10 Manifest build + verify + AZ-270 hygiene
AZ-507: codify cross-component import rule. Added _types/inference_errors.py shim re-exporting EngineBuildError + CalibrationCacheError from c7_inference; narrowed C10 EngineCompiler's except Exception to the two typed errors so unknown exceptions propagate (AC-3). Rewrote module-layout.md "Imports from" sections for 9 components + added Rule 9; appended an architecture.md ADR-009 note explaining why components must go through _types/*. AZ-323: ManifestBuilder + Ed25519ManifestSigner. Canonical JSON via orjson OPT_SORT_KEYS+OPT_INDENT_2, atomic-write Manifest.json + sha sidecar + .sig via AZ-280, operator-key fingerprint allowlist gate (C10-ST-01), ADR-010 takeoff_origin + flight_id baked into Manifest AND manifest_hash so re-planned routes change the cache identity (AC-15/AC-16). 20 unit tests cover all 16 ACs. AZ-324: ManifestVerifierImpl. Fail-closed Steps A-D: Manifest.json sidecar self-hash, Ed25519 trust-key set, schema parse with absolute/.. path rejection + takeoff_origin in-bbox check, stream SHA-256 per artifact with multi-failure accumulation. Operator mode re-derives tiles_coverage_sha256 from C6; airborne mode trusts the signed aggregate. 19 unit tests cover all 17 ACs. Composition root: c10_factory.build_manifest_builder + build_manifest_verifier + c6_tile_metadata_store_to_tiles_query adapter (the one place that legitimately imports both C6 and C10 without violating the AZ-270 lint). Dependency: pinned cryptography>=43.0,<46.0 in pyproject.toml. Tests: 1300 passed, 80 skipped (env-only), ruff clean for all AZ-323/324 files. AZ-306 (FAISS) intentionally deferred to batch 35 — needs C++ pybind11 toolchain not present in this environment. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,675 @@
|
||||
"""C10 ManifestBuilder + Ed25519ManifestSigner (AZ-323).
|
||||
|
||||
Produces the signed cache Manifest covering every shipped artifact
|
||||
plus the build-identity tuple whose canonical hash (``manifest_hash``)
|
||||
is the D-C10-1 idempotence key. Implements the AC-NEW-1 trust chain
|
||||
(takeoff arming refuses to deserialize engines before a Manifest
|
||||
verify succeeds — AZ-324 owns the verify; this task owns the build).
|
||||
|
||||
Cross-component DTOs (``LatLonAlt``, ``BoundingBox``) come from
|
||||
``_types/geo.py``; engine entries from ``_types/inference.py``;
|
||||
the ``Manifest`` placeholder DTO from ``_types/manifests.py``. No
|
||||
direct ``components.X`` imports — the AZ-507 lint forbids it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Protocol, runtime_checkable
|
||||
from uuid import UUID
|
||||
|
||||
import orjson
|
||||
from cryptography.exceptions import UnsupportedAlgorithm
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
||||
Ed25519PrivateKey,
|
||||
Ed25519PublicKey,
|
||||
)
|
||||
from cryptography.hazmat.primitives.serialization import load_pem_private_key
|
||||
|
||||
from gps_denied_onboard._types.geo import BoundingBox, LatLonAlt
|
||||
from gps_denied_onboard._types.inference import EngineCacheEntry
|
||||
from gps_denied_onboard.clock import Clock
|
||||
from gps_denied_onboard.components.c10_provisioning.config import (
|
||||
C10ManifestConfig,
|
||||
SigningMode,
|
||||
)
|
||||
from gps_denied_onboard.components.c10_provisioning.errors import (
|
||||
ManifestWriteError,
|
||||
)
|
||||
from gps_denied_onboard.components.c10_provisioning.interface import (
|
||||
ManifestSigner,
|
||||
SigningKeyHandle,
|
||||
)
|
||||
from gps_denied_onboard.helpers.sha256_sidecar import (
|
||||
Sha256Sidecar,
|
||||
Sha256SidecarError,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"VALID_SECTOR_CLASSES",
|
||||
"Ed25519ManifestSigner",
|
||||
"ManifestArtifact",
|
||||
"ManifestBuildInput",
|
||||
"ManifestBuilder",
|
||||
"TileHashRecord",
|
||||
"TilesByBboxQuery",
|
||||
]
|
||||
|
||||
_BUILD_LOG_KIND_PREFIX = "c10.manifest"
|
||||
_TAKEOFF_ORIGIN_DECIMALS = 9
|
||||
_MANIFEST_FILENAME = "Manifest.json"
|
||||
_SIGNATURE_FILENAME = "Manifest.json.sig"
|
||||
_ED25519_PUBKEY_BYTES = 32
|
||||
_ED25519_SIG_BYTES = 64
|
||||
|
||||
VALID_SECTOR_CLASSES: frozenset[str] = frozenset(
|
||||
{"active_conflict", "stable_rear"}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TileHashRecord:
|
||||
"""Consumer-side DTO carrying the four sort keys + the per-tile digest.
|
||||
|
||||
AZ-323 only needs ``(zoom, lat, lon, source)`` for canonical
|
||||
ordering and ``sha256_hex`` for the aggregate hash. The
|
||||
composition-root adapter wraps C6's ``TileMetadata`` rows into
|
||||
this shape so the AZ-270 lint stays green (no
|
||||
``components.c6_tile_cache`` import from C10).
|
||||
"""
|
||||
|
||||
zoom: int
|
||||
lat: float
|
||||
lon: float
|
||||
source: str
|
||||
sha256_hex: str
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class TilesByBboxQuery(Protocol):
|
||||
"""Consumer-side structural cut over C6's ``TileMetadataStore``.
|
||||
|
||||
The composition root adapts
|
||||
:class:`gps_denied_onboard.components.c6_tile_cache.TileMetadataStore`
|
||||
by translating its ``query_by_bbox`` return value into a tuple of
|
||||
:class:`TileHashRecord`. C10 depends on THIS Protocol so
|
||||
``components/c10_provisioning/*`` never imports ``components.c6_*``
|
||||
(AZ-270 + AZ-507 boundary).
|
||||
"""
|
||||
|
||||
def query_by_bbox(
|
||||
self,
|
||||
*,
|
||||
bbox: BoundingBox,
|
||||
zoom_levels: tuple[int, ...],
|
||||
sector_class: str,
|
||||
) -> Iterable[TileHashRecord]: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ManifestBuildInput:
|
||||
"""Frozen call argument for :meth:`ManifestBuilder.build_manifest`.
|
||||
|
||||
Per the AZ-323 spec ``sector_class`` is the c6 enum's ``.value``
|
||||
string ('active_conflict' / 'stable_rear'); the composition root
|
||||
translates the C6 enum to its string form before injecting so
|
||||
C10 stays free of the C6 import.
|
||||
|
||||
``takeoff_origin`` + ``flight_id`` are the ADR-010 / AZ-489
|
||||
pass-through fields — when supplied they are both baked into the
|
||||
Manifest body AND fed into the ``manifest_hash`` so a re-planned
|
||||
flight produces a fresh cache identity (AC-15 / AC-16).
|
||||
"""
|
||||
|
||||
cache_root: Path
|
||||
bbox: BoundingBox
|
||||
zoom_levels: tuple[int, ...]
|
||||
sector_class: str
|
||||
engine_entries: tuple[EngineCacheEntry, ...]
|
||||
descriptor_index_path: Path
|
||||
calibration_path: Path
|
||||
key_path: Path
|
||||
takeoff_origin: LatLonAlt | None = None
|
||||
flight_id: UUID | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ManifestArtifact:
|
||||
"""Return value of :meth:`ManifestBuilder.build_manifest`.
|
||||
|
||||
``manifest_hash`` is the D-C10-1 idempotence key — derived from
|
||||
the build identity tuple, NOT from the Manifest file bytes (which
|
||||
include ``built_at`` and so differ across runs). The Manifest
|
||||
file's own SHA-256 lives on disk as ``Manifest.json.sha256`` per
|
||||
AC-11.
|
||||
"""
|
||||
|
||||
manifest_path: Path
|
||||
signature_path: Path
|
||||
manifest_hash: str
|
||||
signing_public_key_fingerprint: str
|
||||
total_artifacts_listed: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Ed25519SigningKey(SigningKeyHandle):
|
||||
"""Opaque handle wrapping a ``cryptography`` Ed25519 private key.
|
||||
|
||||
Frozen so callers cannot mutate the key in flight; the underlying
|
||||
``Ed25519PrivateKey`` object stays in the dataclass field but is
|
||||
not exposed by name on :class:`SigningKeyHandle`.
|
||||
"""
|
||||
|
||||
private_key: Ed25519PrivateKey = field(repr=False)
|
||||
public_key_raw: bytes
|
||||
fingerprint_hex: str
|
||||
|
||||
|
||||
class Ed25519ManifestSigner:
|
||||
"""Default :class:`ManifestSigner` impl backed by ``cryptography``.
|
||||
|
||||
Loads PEM-encoded PKCS8 Ed25519 private keys per AZ-323 Risk 4 —
|
||||
other formats (OpenSSH, raw 32-byte) raise
|
||||
:class:`ManifestWriteError` with the underlying ``cryptography``
|
||||
exception chained via ``__cause__`` (AC-9).
|
||||
"""
|
||||
|
||||
def load_signing_key(self, key_path: Path) -> SigningKeyHandle:
|
||||
try:
|
||||
pem_bytes = key_path.read_bytes()
|
||||
except (OSError, FileNotFoundError) as exc:
|
||||
raise ManifestWriteError(
|
||||
f"operator signing key load failed: cannot read {key_path}: {exc}"
|
||||
) from exc
|
||||
|
||||
try:
|
||||
private_key = load_pem_private_key(pem_bytes, password=None)
|
||||
except (ValueError, TypeError, UnsupportedAlgorithm) as exc:
|
||||
raise ManifestWriteError(
|
||||
f"operator signing key load failed: malformed PEM at {key_path}: {exc}"
|
||||
) from exc
|
||||
|
||||
if not isinstance(private_key, Ed25519PrivateKey):
|
||||
raise ManifestWriteError(
|
||||
"operator signing key load failed: not an Ed25519 private key "
|
||||
f"(got {type(private_key).__name__}); AZ-323 supports Ed25519 only"
|
||||
)
|
||||
|
||||
public_key = private_key.public_key()
|
||||
public_raw = _ed25519_public_raw(public_key)
|
||||
return _Ed25519SigningKey(
|
||||
private_key=private_key,
|
||||
public_key_raw=public_raw,
|
||||
fingerprint_hex=hashlib.sha256(public_raw).hexdigest(),
|
||||
)
|
||||
|
||||
def sign(self, key: SigningKeyHandle, payload_bytes: bytes) -> bytes:
|
||||
handle = _require_ed25519_handle(key)
|
||||
signature = handle.private_key.sign(payload_bytes)
|
||||
# Defensive: Ed25519 signatures are always 64 bytes — fail fast on
|
||||
# a library upgrade that changes the contract.
|
||||
if len(signature) != _ED25519_SIG_BYTES:
|
||||
raise ManifestWriteError(
|
||||
f"Ed25519 signer produced {len(signature)} bytes; expected "
|
||||
f"{_ED25519_SIG_BYTES}"
|
||||
)
|
||||
return signature
|
||||
|
||||
def public_key_fingerprint(self, key: SigningKeyHandle) -> str:
|
||||
return _require_ed25519_handle(key).fingerprint_hex
|
||||
|
||||
|
||||
def _ed25519_public_raw(public_key: Ed25519PublicKey) -> bytes:
|
||||
from cryptography.hazmat.primitives.serialization import (
|
||||
Encoding,
|
||||
PublicFormat,
|
||||
)
|
||||
|
||||
raw = public_key.public_bytes(
|
||||
encoding=Encoding.Raw,
|
||||
format=PublicFormat.Raw,
|
||||
)
|
||||
if len(raw) != _ED25519_PUBKEY_BYTES:
|
||||
raise ManifestWriteError(
|
||||
f"Ed25519 public key has unexpected length: {len(raw)} != "
|
||||
f"{_ED25519_PUBKEY_BYTES}"
|
||||
)
|
||||
return raw
|
||||
|
||||
|
||||
def _require_ed25519_handle(key: SigningKeyHandle) -> _Ed25519SigningKey:
|
||||
if not isinstance(key, _Ed25519SigningKey):
|
||||
raise ManifestWriteError(
|
||||
"Ed25519ManifestSigner received a foreign SigningKeyHandle "
|
||||
f"({type(key).__name__}); only handles produced by load_signing_key "
|
||||
"are accepted"
|
||||
)
|
||||
return key
|
||||
|
||||
|
||||
class ManifestBuilder:
|
||||
"""Build a signed cache Manifest at ``cache_root/Manifest.json``.
|
||||
|
||||
Atomic-write contract: Manifest body + ``.sha256`` sidecar +
|
||||
``.sig`` are all written via :class:`Sha256Sidecar.write_atomic*`,
|
||||
so a kill mid-build leaves either the previous-good triple or
|
||||
the new triple — never a partial Manifest (AC-10).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
sidecar: Sha256Sidecar,
|
||||
signer: ManifestSigner,
|
||||
tile_metadata_store: TilesByBboxQuery,
|
||||
logger: logging.Logger,
|
||||
clock: Clock,
|
||||
config: C10ManifestConfig,
|
||||
) -> None:
|
||||
self._sidecar = sidecar
|
||||
self._signer = signer
|
||||
self._tiles = tile_metadata_store
|
||||
self._log = logger
|
||||
self._clock = clock
|
||||
self._config = config
|
||||
|
||||
def build_manifest(self, request: ManifestBuildInput) -> ManifestArtifact:
|
||||
self._validate_request(request)
|
||||
key = self._load_and_gate_key(request.key_path)
|
||||
fingerprint = self._signer.public_key_fingerprint(key)
|
||||
self._gate_operator_mode(fingerprint)
|
||||
|
||||
calibration_sha256 = self._sha256_file(request.calibration_path)
|
||||
descriptor_index_sha256 = self._read_descriptor_index_sidecar(
|
||||
request.descriptor_index_path
|
||||
)
|
||||
|
||||
sorted_tiles = self._fetch_sorted_tiles(
|
||||
bbox=request.bbox,
|
||||
zoom_levels=request.zoom_levels,
|
||||
sector_class=request.sector_class,
|
||||
)
|
||||
tiles_coverage_sha256 = _aggregate_tile_hash(sorted_tiles)
|
||||
|
||||
engine_artifacts = tuple(
|
||||
{
|
||||
"path": str(entry.engine_path),
|
||||
"sha256": entry.sha256_hex,
|
||||
}
|
||||
for entry in request.engine_entries
|
||||
)
|
||||
|
||||
manifest_hash = _compute_manifest_hash(
|
||||
engine_entries=request.engine_entries,
|
||||
calibration_sha256=calibration_sha256,
|
||||
descriptor_index_sha256=descriptor_index_sha256,
|
||||
tiles_coverage_sha256=tiles_coverage_sha256,
|
||||
sector_class=request.sector_class,
|
||||
bbox=request.bbox,
|
||||
zoom_levels=request.zoom_levels,
|
||||
takeoff_origin=request.takeoff_origin,
|
||||
flight_id=request.flight_id,
|
||||
)
|
||||
|
||||
built_at_iso = _ns_to_iso_utc(self._clock.time_ns())
|
||||
|
||||
manifest_body = self._assemble_manifest_dict(
|
||||
schema_version=self._config.schema_version,
|
||||
bbox=request.bbox,
|
||||
zoom_levels=request.zoom_levels,
|
||||
sector_class=request.sector_class,
|
||||
built_at_iso=built_at_iso,
|
||||
manifest_hash=manifest_hash,
|
||||
flight_id=request.flight_id,
|
||||
takeoff_origin=request.takeoff_origin,
|
||||
engine_artifacts=engine_artifacts,
|
||||
descriptor_index_path=request.descriptor_index_path,
|
||||
descriptor_index_sha256=descriptor_index_sha256,
|
||||
calibration_path=request.calibration_path,
|
||||
calibration_sha256=calibration_sha256,
|
||||
tiles_coverage_sha256=tiles_coverage_sha256,
|
||||
tiles_count=len(sorted_tiles),
|
||||
fingerprint=fingerprint,
|
||||
)
|
||||
|
||||
canonical_bytes = _canonical_json_with_trailing_newline(manifest_body)
|
||||
manifest_path = request.cache_root / _MANIFEST_FILENAME
|
||||
signature_path = request.cache_root / _SIGNATURE_FILENAME
|
||||
|
||||
request.cache_root.mkdir(parents=True, exist_ok=True)
|
||||
self._atomic_write_manifest(manifest_path, canonical_bytes)
|
||||
signature_bytes = self._signer.sign(key, canonical_bytes)
|
||||
self._atomic_write_signature(signature_path, signature_bytes)
|
||||
|
||||
total_artifacts = len(engine_artifacts) + 3 # descriptor_index + calibration + tiles_coverage
|
||||
|
||||
self._log.info(
|
||||
f"{_BUILD_LOG_KIND_PREFIX}.build.success",
|
||||
extra={
|
||||
"kind": f"{_BUILD_LOG_KIND_PREFIX}.build.success",
|
||||
"kv": {
|
||||
"manifest_hash": manifest_hash,
|
||||
"total_artifacts_listed": total_artifacts,
|
||||
"signing_public_key_fingerprint": fingerprint,
|
||||
"tiles_count": len(sorted_tiles),
|
||||
"schema_version": self._config.schema_version,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
return ManifestArtifact(
|
||||
manifest_path=manifest_path,
|
||||
signature_path=signature_path,
|
||||
manifest_hash=manifest_hash,
|
||||
signing_public_key_fingerprint=fingerprint,
|
||||
total_artifacts_listed=total_artifacts,
|
||||
)
|
||||
|
||||
def _validate_request(self, request: ManifestBuildInput) -> None:
|
||||
if request.sector_class not in VALID_SECTOR_CLASSES:
|
||||
raise ManifestWriteError(
|
||||
f"sector_class={request.sector_class!r} not in "
|
||||
f"{sorted(VALID_SECTOR_CLASSES)}"
|
||||
)
|
||||
if not request.zoom_levels:
|
||||
raise ManifestWriteError(
|
||||
"zoom_levels must be a non-empty tuple of ints"
|
||||
)
|
||||
if request.takeoff_origin is not None:
|
||||
origin = request.takeoff_origin
|
||||
if not (-90.0 <= origin.lat_deg <= 90.0):
|
||||
raise ManifestWriteError(
|
||||
f"takeoff_origin.lat_deg={origin.lat_deg} out of [-90, 90]"
|
||||
)
|
||||
if not (-180.0 <= origin.lon_deg <= 180.0):
|
||||
raise ManifestWriteError(
|
||||
f"takeoff_origin.lon_deg={origin.lon_deg} out of [-180, 180]"
|
||||
)
|
||||
|
||||
def _load_and_gate_key(self, key_path: Path) -> SigningKeyHandle:
|
||||
try:
|
||||
return self._signer.load_signing_key(key_path)
|
||||
except ManifestWriteError:
|
||||
# Already logged at the call site below; the signer raises with
|
||||
# an actionable diagnostic. We must still emit the ERROR record
|
||||
# so operators see a single structured "build.error" entry.
|
||||
self._log.error(
|
||||
f"{_BUILD_LOG_KIND_PREFIX}.build.error",
|
||||
extra={
|
||||
"kind": f"{_BUILD_LOG_KIND_PREFIX}.build.error",
|
||||
"kv": {
|
||||
"phase": "load_signing_key",
|
||||
"key_path": str(key_path),
|
||||
},
|
||||
},
|
||||
)
|
||||
raise
|
||||
|
||||
def _gate_operator_mode(self, fingerprint: str) -> None:
|
||||
allowlist = self._config.allowed_operator_fingerprints
|
||||
if self._config.signing_mode is SigningMode.OPERATOR:
|
||||
if fingerprint not in allowlist:
|
||||
self._log.error(
|
||||
f"{_BUILD_LOG_KIND_PREFIX}.build.error",
|
||||
extra={
|
||||
"kind": f"{_BUILD_LOG_KIND_PREFIX}.build.error",
|
||||
"kv": {
|
||||
"phase": "operator_mode_gate",
|
||||
"offered_fingerprint": fingerprint,
|
||||
"allowed_fingerprints": list(allowlist),
|
||||
},
|
||||
},
|
||||
)
|
||||
raise ManifestWriteError(
|
||||
"signing key fingerprint not in allowed_operator_fingerprints: "
|
||||
f"offered={fingerprint!r}, allowed={sorted(allowlist)!r}"
|
||||
)
|
||||
elif self._config.signing_mode is SigningMode.DEV:
|
||||
if fingerprint in allowlist:
|
||||
self._log.warning(
|
||||
f"{_BUILD_LOG_KIND_PREFIX}.dev_mode_with_operator_key",
|
||||
extra={
|
||||
"kind": f"{_BUILD_LOG_KIND_PREFIX}.dev_mode_with_operator_key",
|
||||
"kv": {
|
||||
"offered_fingerprint": fingerprint,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
def _sha256_file(self, path: Path) -> str:
|
||||
try:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
except (OSError, FileNotFoundError) as exc:
|
||||
raise ManifestWriteError(
|
||||
f"manifest build: cannot hash artifact at {path}: {exc}"
|
||||
) from exc
|
||||
|
||||
def _read_descriptor_index_sidecar(self, descriptor_index_path: Path) -> str:
|
||||
sidecar_path = Path(str(descriptor_index_path) + ".sha256")
|
||||
try:
|
||||
text = sidecar_path.read_text(encoding="ascii").strip()
|
||||
except (OSError, FileNotFoundError) as exc:
|
||||
raise ManifestWriteError(
|
||||
"manifest build: descriptor_index sidecar missing at "
|
||||
f"{sidecar_path}: {exc}"
|
||||
) from exc
|
||||
if len(text) != 64:
|
||||
raise ManifestWriteError(
|
||||
"manifest build: descriptor_index sidecar at "
|
||||
f"{sidecar_path} is not 64 hex chars (got {len(text)})"
|
||||
)
|
||||
try:
|
||||
int(text, 16)
|
||||
except ValueError as exc:
|
||||
raise ManifestWriteError(
|
||||
"manifest build: descriptor_index sidecar at "
|
||||
f"{sidecar_path} is not hex: {exc}"
|
||||
) from exc
|
||||
if text.lower() != text:
|
||||
raise ManifestWriteError(
|
||||
"manifest build: descriptor_index sidecar at "
|
||||
f"{sidecar_path} must be lowercase hex"
|
||||
)
|
||||
return text
|
||||
|
||||
def _fetch_sorted_tiles(
|
||||
self,
|
||||
*,
|
||||
bbox: BoundingBox,
|
||||
zoom_levels: tuple[int, ...],
|
||||
sector_class: str,
|
||||
) -> tuple[TileHashRecord, ...]:
|
||||
raw = tuple(
|
||||
self._tiles.query_by_bbox(
|
||||
bbox=bbox,
|
||||
zoom_levels=zoom_levels,
|
||||
sector_class=sector_class,
|
||||
)
|
||||
)
|
||||
return tuple(
|
||||
sorted(raw, key=lambda r: (r.zoom, r.lat, r.lon, r.source))
|
||||
)
|
||||
|
||||
def _assemble_manifest_dict(
|
||||
self,
|
||||
*,
|
||||
schema_version: str,
|
||||
bbox: BoundingBox,
|
||||
zoom_levels: tuple[int, ...],
|
||||
sector_class: str,
|
||||
built_at_iso: str,
|
||||
manifest_hash: str,
|
||||
flight_id: UUID | None,
|
||||
takeoff_origin: LatLonAlt | None,
|
||||
engine_artifacts: tuple[dict[str, str], ...],
|
||||
descriptor_index_path: Path,
|
||||
descriptor_index_sha256: str,
|
||||
calibration_path: Path,
|
||||
calibration_sha256: str,
|
||||
tiles_coverage_sha256: str,
|
||||
tiles_count: int,
|
||||
fingerprint: str,
|
||||
) -> dict[str, object]:
|
||||
flight_block: dict[str, object] = {
|
||||
"flight_id": str(flight_id) if flight_id is not None else None,
|
||||
}
|
||||
if takeoff_origin is not None:
|
||||
flight_block["takeoff_origin"] = {
|
||||
"lat_deg": takeoff_origin.lat_deg,
|
||||
"lon_deg": takeoff_origin.lon_deg,
|
||||
"alt_m": takeoff_origin.alt_m,
|
||||
}
|
||||
|
||||
return {
|
||||
"schema_version": schema_version,
|
||||
"build": {
|
||||
"bbox": {
|
||||
"min_lat_deg": bbox.min_lat_deg,
|
||||
"min_lon_deg": bbox.min_lon_deg,
|
||||
"max_lat_deg": bbox.max_lat_deg,
|
||||
"max_lon_deg": bbox.max_lon_deg,
|
||||
},
|
||||
"zoom_levels": list(zoom_levels),
|
||||
"sector_class": sector_class,
|
||||
"built_at": built_at_iso,
|
||||
"manifest_hash": manifest_hash,
|
||||
},
|
||||
"flight": flight_block,
|
||||
"artifacts": {
|
||||
"engines": [dict(e) for e in engine_artifacts],
|
||||
"descriptor_index": {
|
||||
"path": str(descriptor_index_path),
|
||||
"sha256": descriptor_index_sha256,
|
||||
},
|
||||
"calibration": {
|
||||
"path": str(calibration_path),
|
||||
"sha256": calibration_sha256,
|
||||
},
|
||||
"tiles_coverage": {
|
||||
"sha256": tiles_coverage_sha256,
|
||||
"tile_count": tiles_count,
|
||||
},
|
||||
},
|
||||
"signing_public_key_fingerprint": fingerprint,
|
||||
}
|
||||
|
||||
def _atomic_write_manifest(self, path: Path, payload: bytes) -> None:
|
||||
try:
|
||||
self._sidecar.write_atomic_and_sidecar(path, payload)
|
||||
except Sha256SidecarError as exc:
|
||||
self._log.error(
|
||||
f"{_BUILD_LOG_KIND_PREFIX}.build.error",
|
||||
extra={
|
||||
"kind": f"{_BUILD_LOG_KIND_PREFIX}.build.error",
|
||||
"kv": {"phase": "write_manifest", "path": str(path)},
|
||||
},
|
||||
)
|
||||
raise ManifestWriteError(
|
||||
f"manifest build: atomic write failed at {path}: {exc}"
|
||||
) from exc
|
||||
|
||||
def _atomic_write_signature(self, path: Path, payload: bytes) -> None:
|
||||
try:
|
||||
self._sidecar.write_atomic(path, payload)
|
||||
except Sha256SidecarError as exc:
|
||||
self._log.error(
|
||||
f"{_BUILD_LOG_KIND_PREFIX}.build.error",
|
||||
extra={
|
||||
"kind": f"{_BUILD_LOG_KIND_PREFIX}.build.error",
|
||||
"kv": {"phase": "write_signature", "path": str(path)},
|
||||
},
|
||||
)
|
||||
raise ManifestWriteError(
|
||||
f"manifest build: atomic write failed at {path}: {exc}"
|
||||
) from exc
|
||||
|
||||
|
||||
def _aggregate_tile_hash(records: tuple[TileHashRecord, ...]) -> str:
|
||||
hasher = hashlib.sha256()
|
||||
for r in records:
|
||||
hasher.update(
|
||||
(
|
||||
f"z{r.zoom}|lat{r.lat:.9f}|lon{r.lon:.9f}|src{r.source}"
|
||||
f":{r.sha256_hex}\n"
|
||||
).encode("ascii")
|
||||
)
|
||||
return hasher.hexdigest()
|
||||
|
||||
|
||||
def _canonical_json_with_trailing_newline(payload: dict[str, object]) -> bytes:
|
||||
body = orjson.dumps(
|
||||
payload,
|
||||
option=orjson.OPT_SORT_KEYS | orjson.OPT_INDENT_2,
|
||||
)
|
||||
if not body.endswith(b"\n"):
|
||||
body += b"\n"
|
||||
return body
|
||||
|
||||
|
||||
def _compute_manifest_hash(
|
||||
*,
|
||||
engine_entries: tuple[EngineCacheEntry, ...],
|
||||
calibration_sha256: str,
|
||||
descriptor_index_sha256: str,
|
||||
tiles_coverage_sha256: str,
|
||||
sector_class: str,
|
||||
bbox: BoundingBox,
|
||||
zoom_levels: tuple[int, ...],
|
||||
takeoff_origin: LatLonAlt | None,
|
||||
flight_id: UUID | None,
|
||||
) -> str:
|
||||
# Engine identity is `(model_name, precision, sm, jetpack, trt, sha256)`
|
||||
# so a stale-host fp16 build never collides with a fresh int8 build —
|
||||
# this matches the AZ-281 filename schema fields modulo the precision
|
||||
# axis (which fp16 vs int8 makes load-bearing).
|
||||
model_ids = sorted(
|
||||
(
|
||||
str(entry.engine_path),
|
||||
entry.sha256_hex,
|
||||
)
|
||||
for entry in engine_entries
|
||||
)
|
||||
origin_tuple: tuple[float, float, float] | None
|
||||
if takeoff_origin is not None:
|
||||
origin_tuple = (
|
||||
round(takeoff_origin.lat_deg, _TAKEOFF_ORIGIN_DECIMALS),
|
||||
round(takeoff_origin.lon_deg, _TAKEOFF_ORIGIN_DECIMALS),
|
||||
round(takeoff_origin.alt_m, _TAKEOFF_ORIGIN_DECIMALS),
|
||||
)
|
||||
else:
|
||||
origin_tuple = None
|
||||
build_identity = {
|
||||
"model_ids": [list(entry) for entry in model_ids],
|
||||
"calibration_sha256": calibration_sha256,
|
||||
"descriptor_index_sha256": descriptor_index_sha256,
|
||||
"tiles_coverage_sha256": tiles_coverage_sha256,
|
||||
"sector_class": sector_class,
|
||||
"bbox": [
|
||||
bbox.min_lat_deg,
|
||||
bbox.min_lon_deg,
|
||||
bbox.max_lat_deg,
|
||||
bbox.max_lon_deg,
|
||||
],
|
||||
"zoom_levels": sorted(zoom_levels),
|
||||
"takeoff_origin": list(origin_tuple) if origin_tuple is not None else None,
|
||||
"flight_id": str(flight_id) if flight_id is not None else None,
|
||||
}
|
||||
canonical = orjson.dumps(build_identity, option=orjson.OPT_SORT_KEYS)
|
||||
return hashlib.sha256(canonical).hexdigest()
|
||||
|
||||
|
||||
def _ns_to_iso_utc(time_ns: int) -> str:
|
||||
"""Format ns-since-epoch as RFC 3339 UTC with second precision.
|
||||
|
||||
Second precision suffices for ``built_at`` — operators inspect the
|
||||
Manifest at hour / minute granularity, and the build-identity
|
||||
hash deliberately excludes ``built_at`` so the AC-2 byte-for-byte
|
||||
determinism check works only by redacting this exact field.
|
||||
"""
|
||||
|
||||
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(time_ns / 1_000_000_000))
|
||||
Reference in New Issue
Block a user