mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-23 09:51:14 +00:00
ca0430a44d
Cumulative-review F1 (batches 34-36, carried into batch 37): both manifest_verifier.py (AZ-324) and provisioner.py (AZ-325) imported leading-underscore privates _aggregate_tile_hash + _compute_manifest_hash from manifest_builder.py (AZ-323). The helpers encode the trust-chain formula shared across all three components; the import shape gave readers no static signal that a refactor would silently break two modules. Move the formula into c10_provisioning/_canonical_hash.py: - TileHashRecord (moved from manifest_builder) - aggregate_tile_hash (renamed, public) - compute_manifest_hash (renamed, public) - TAKEOFF_ORIGIN_DECIMALS constant (moved) Callers updated to import directly from _canonical_hash. Bodies unchanged; manifest hashes are byte-for-byte identical. Tests: c10_provisioning suite 86/86 pass; full project 1370/1370 pass. Co-authored-by: Cursor <cursoragent@cursor.com>
597 lines
21 KiB
Python
597 lines
21 KiB
Python
"""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._canonical_hash import (
|
|
TileHashRecord,
|
|
aggregate_tile_hash,
|
|
compute_manifest_hash,
|
|
)
|
|
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",
|
|
"TilesByBboxQuery",
|
|
]
|
|
|
|
_BUILD_LOG_KIND_PREFIX = "c10.manifest"
|
|
_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"}
|
|
)
|
|
|
|
|
|
@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 _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 _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))
|