perf(observatory): pack camera epoch source transfer

This commit is contained in:
DCCONSTRUCTIONS
2026-08-31 17:59:18 +03:00
parent 1766c0bdb2
commit 4cd94b5805
5 changed files with 1164 additions and 7 deletions
@@ -16,11 +16,12 @@ import os
import re
import secrets
import stat
import tarfile
from collections.abc import AsyncIterable, Mapping, Sequence
from contextlib import suppress
from dataclasses import dataclass
from pathlib import Path
from typing import Final, Literal
from typing import BinaryIO, Final, Literal, cast
from k1link.observatory.portable_result_contract import (
OBSERVATION_ONLY_AUTHORITY,
@@ -59,6 +60,16 @@ from k1link.sessions.store import SessionStore
PORTABLE_SOURCE_MATERIALIZATION_SCHEMA: Final = (
"missioncore.observatory-portable-source-materialization/v1"
)
PORTABLE_CAMERA_EPOCH_ARCHIVE_SCHEMA: Final = (
"missioncore.observatory-portable-camera-epoch-archive/v1"
)
PORTABLE_CAMERA_EPOCH_ARCHIVE_BINDING_SCHEMA: Final = (
"missioncore.observatory-portable-camera-epoch-archive-binding/v1"
)
PORTABLE_CAMERA_EPOCH_ARCHIVE_MEDIA_TYPE: Final = "application/x-tar"
PORTABLE_CAMERA_EPOCH_ARCHIVE_ID_HEADER: Final = (
"X-Mission-Core-Camera-Epoch-Archive-Id"
)
PORTABLE_RESULT_UPLOAD_PLAN_SCHEMA: Final = (
"missioncore.observatory-portable-result-upload-plan/v1"
)
@@ -73,6 +84,10 @@ PORTABLE_RESULT_STAGING_DIRECTORY: Final = "observatory-worker-result-staging"
MAX_SOURCE_MEMBERS: Final = 100_000
MAX_SOURCE_BYTES: Final = 2 * 1024 * 1024 * 1024 * 1024
MAX_CAMERA_EPOCH_ARCHIVE_BYTES: Final = (
MAX_SOURCE_BYTES + MAX_SOURCE_MEMBERS * 1024 + tarfile.RECORDSIZE
)
MAX_CAMERA_EPOCH_ARCHIVE_BINDING_BYTES: Final = 8 * 1024 * 1024
MAX_RESULT_MEMBER_BYTES: Final = 64 * 1024 * 1024 * 1024
MAX_RESULT_PACKAGE_BYTES: Final = 256 * 1024 * 1024 * 1024
MAX_RESULT_MANIFEST_BYTES: Final = 1024 * 1024
@@ -228,6 +243,53 @@ class PortableSourceMaterializationManifest:
}
@dataclass(frozen=True, slots=True)
class PortableCameraEpochArchive:
archive_id: str
job_identity_sha256: str
source_bundle_sha256: str
artifact_id: str
camera_epoch: int
member_ids: tuple[str, ...]
byte_length: int
sha256: str
def __post_init__(self) -> None:
_digest(self.archive_id, "camera epoch archive id")
_digest(self.job_identity_sha256, "camera epoch archive job identity")
_digest(self.source_bundle_sha256, "camera epoch archive source bundle")
_session_id(self.artifact_id)
_positive_int(self.camera_epoch, "camera epoch archive ordinal")
if (
not 2 <= len(self.member_ids) <= MAX_SOURCE_MEMBERS
or len(set(self.member_ids)) != len(self.member_ids)
):
raise ValueError("camera epoch archive members are invalid")
for member_id in self.member_ids:
_digest(member_id, "camera epoch archive member id")
if not 1 <= self.byte_length <= MAX_CAMERA_EPOCH_ARCHIVE_BYTES:
raise ValueError("camera epoch archive byte length is invalid")
_digest(self.sha256, "camera epoch archive sha256")
if self.archive_id != portable_camera_epoch_archive_id(
job_identity_sha256=self.job_identity_sha256,
source_bundle_sha256=self.source_bundle_sha256,
artifact_id=self.artifact_id,
camera_epoch=self.camera_epoch,
member_ids=self.member_ids,
byte_length=self.byte_length,
sha256=self.sha256,
):
raise ValueError("camera epoch archive identity is invalid")
def as_dict(self) -> dict[str, object]:
return {
"archive_id": self.archive_id,
"media_type": PORTABLE_CAMERA_EPOCH_ARCHIVE_MEDIA_TYPE,
"byte_length": self.byte_length,
"sha256": self.sha256,
}
@dataclass(frozen=True, slots=True)
class PortableResultUploadMember:
member_id: str
@@ -443,6 +505,39 @@ class PortableObservatoryArtifactTransport:
)
return member, destination
def materialize_camera_epoch_archive(
self,
*,
job_id: str,
claim_token: str,
claim_generation: int,
claimant_id: str,
) -> tuple[PortableCameraEpochArchive, Path]:
"""Publish one claim-bound deterministic tar for the admitted camera epoch."""
job = self._authorize(
job_id=job_id,
claim_token=claim_token,
claim_generation=claim_generation,
claimant_id=claimant_id,
allowed_states=("claimed", "running"),
)
manifest = self._resolve_source_manifest(job)
members = _ordered_camera_epoch_members(manifest.members)
archive, path = _publish_camera_epoch_archive(
self._source_cas,
job=job,
members=members,
)
self._authorize(
job_id=job_id,
claim_token=claim_token,
claim_generation=claim_generation,
claimant_id=claimant_id,
allowed_states=("claimed", "running"),
)
return archive, path
def stage_result_manifest(
self,
*,
@@ -1033,6 +1128,418 @@ class PortableObservatoryArtifactTransport:
return receipt
def portable_camera_epoch_archive_id(
*,
job_identity_sha256: str,
source_bundle_sha256: str,
artifact_id: str,
camera_epoch: int,
member_ids: Sequence[str],
byte_length: int,
sha256: str,
) -> str:
"""Return the fixed identity of one packed, content-sealed camera epoch."""
_digest(job_identity_sha256, "camera epoch archive job identity")
_digest(source_bundle_sha256, "camera epoch archive source bundle")
_session_id(artifact_id)
_positive_int(camera_epoch, "camera epoch archive ordinal")
canonical_member_ids = tuple(member_ids)
if (
not 2 <= len(canonical_member_ids) <= MAX_SOURCE_MEMBERS
or len(set(canonical_member_ids)) != len(canonical_member_ids)
):
raise ValueError("camera epoch archive members are invalid")
for member_id in canonical_member_ids:
_digest(member_id, "camera epoch archive member id")
if not 1 <= byte_length <= MAX_CAMERA_EPOCH_ARCHIVE_BYTES:
raise ValueError("camera epoch archive byte length is invalid")
_digest(sha256, "camera epoch archive sha256")
return hashlib.sha256(
canonical_json(
{
"schema_version": PORTABLE_CAMERA_EPOCH_ARCHIVE_SCHEMA,
"job_identity_sha256": job_identity_sha256,
"source_bundle_sha256": source_bundle_sha256,
"artifact_id": artifact_id,
"camera_epoch": camera_epoch,
"member_ids": list(canonical_member_ids),
"media_type": PORTABLE_CAMERA_EPOCH_ARCHIVE_MEDIA_TYPE,
"byte_length": byte_length,
"sha256": sha256,
}
)
).hexdigest()
def _ordered_camera_epoch_members(
members: Sequence[PortableSourceMember],
) -> tuple[PortableSourceMember, ...]:
inits = tuple(member for member in members if member.kind == "camera-init")
segments = tuple(member for member in members if member.kind == "camera-segment")
if len(inits) != 1 or not segments:
raise PortableArtifactTransportIntegrityError(
"portable camera epoch member inventory is incomplete"
)
init = inits[0]
if init.artifact_id is None or init.camera_epoch is None:
raise PortableArtifactTransportIntegrityError(
"portable camera epoch identity is incomplete"
)
ordered_segments = tuple(
sorted(segments, key=lambda member: member.camera_sequence or 0)
)
if tuple(member.camera_sequence for member in ordered_segments) != tuple(
range(1, len(ordered_segments) + 1)
) or any(
member.artifact_id != init.artifact_id
or member.camera_epoch != init.camera_epoch
for member in ordered_segments
):
raise PortableArtifactTransportIntegrityError(
"portable camera epoch member order changed"
)
return (init, *ordered_segments)
def _camera_archive_relative_name(member: PortableSourceMember) -> str:
if member.kind == "camera-init":
return "init.mp4"
if member.kind != "camera-segment" or member.camera_sequence is None:
raise PortableArtifactTransportIntegrityError(
"portable camera archive member role is invalid"
)
return f"segments/{member.camera_sequence}.m4s"
def _camera_epoch_archive_byte_length(
members: Sequence[PortableSourceMember],
) -> int:
content_bytes = sum(
512 + ((member.byte_length + 511) // 512) * 512 for member in members
)
logical_bytes = content_bytes + 1024
return ((logical_bytes + tarfile.RECORDSIZE - 1) // tarfile.RECORDSIZE) * (
tarfile.RECORDSIZE
)
def _camera_epoch_archive_binding_identity(
job: ObservatoryRecordedJob,
members: Sequence[PortableSourceMember],
) -> dict[str, object]:
ordered = _ordered_camera_epoch_members(members)
init = ordered[0]
assert init.artifact_id is not None
assert init.camera_epoch is not None
return {
"schema_version": PORTABLE_CAMERA_EPOCH_ARCHIVE_BINDING_SCHEMA,
"job_identity_sha256": job.identity_sha256,
"source_bundle_sha256": job.source_bundle_sha256,
"artifact_id": init.artifact_id,
"camera_epoch": init.camera_epoch,
"member_ids": [member.member_id for member in ordered],
}
def _load_camera_epoch_archive_binding(
path: Path,
*,
binding_id: str,
identity: Mapping[str, object],
archive_root: Path,
) -> tuple[PortableCameraEpochArchive, Path]:
try:
payload = _read_exact_regular_file(
path,
maximum_bytes=MAX_CAMERA_EPOCH_ARCHIVE_BINDING_BYTES,
)
document = object_document(
json.loads(payload.decode("utf-8")),
"camera epoch archive binding",
)
if canonical_json(document) != payload:
raise PortableArtifactTransportIntegrityError(
"camera epoch archive binding is not canonical"
)
except PortableArtifactTransportError:
raise
except (
OSError,
UnicodeDecodeError,
json.JSONDecodeError,
PortableResultPackageIntegrityError,
ValueError,
) as exc:
raise PortableArtifactTransportIntegrityError(
"camera epoch archive binding is unreadable"
) from exc
if (
set(document) != {*identity, "binding_id", "archive", "authority"}
or any(document.get(key) != value for key, value in identity.items())
or document.get("binding_id") != binding_id
or document.get("authority") != OBSERVATION_ONLY_AUTHORITY
):
raise PortableArtifactTransportIntegrityError(
"camera epoch archive binding identity changed"
)
try:
archive_document = object_document(
document.get("archive"),
"camera epoch archive",
)
except PortableResultPackageIntegrityError as exc:
raise PortableArtifactTransportIntegrityError(
"camera epoch archive seal is invalid"
) from exc
if (
set(archive_document)
!= {"archive_id", "media_type", "byte_length", "sha256"}
or archive_document.get("media_type")
!= PORTABLE_CAMERA_EPOCH_ARCHIVE_MEDIA_TYPE
):
raise PortableArtifactTransportIntegrityError(
"camera epoch archive seal changed"
)
try:
member_ids_value = identity["member_ids"]
if not isinstance(member_ids_value, list) or any(
not isinstance(member_id, str) for member_id in member_ids_value
):
raise ValueError("camera epoch archive member ids are invalid")
result = PortableCameraEpochArchive(
archive_id=string(archive_document.get("archive_id"), "archive id"),
job_identity_sha256=string(
identity["job_identity_sha256"],
"archive job identity",
),
source_bundle_sha256=string(
identity["source_bundle_sha256"],
"archive source bundle",
),
artifact_id=string(identity["artifact_id"], "archive artifact id"),
camera_epoch=_positive_int(
identity["camera_epoch"],
"archive camera epoch",
),
member_ids=tuple(member_ids_value),
byte_length=_positive_int(
archive_document.get("byte_length"),
"archive byte length",
),
sha256=string(archive_document.get("sha256"), "archive sha256"),
)
except (PortableResultPackageIntegrityError, ValueError) as exc:
raise PortableArtifactTransportIntegrityError(
"camera epoch archive binding is invalid"
) from exc
archive_path = archive_root / result.sha256[:2] / result.sha256
if not _matches_exact_file(
archive_path,
expected_sha256=result.sha256,
expected_byte_length=result.byte_length,
):
raise PortableArtifactTransportIntegrityError(
"camera epoch archive differs from its immutable binding"
)
return result, archive_path
def _publish_camera_epoch_archive(
root: Path,
*,
job: ObservatoryRecordedJob,
members: Sequence[PortableSourceMember],
) -> tuple[PortableCameraEpochArchive, Path]:
ordered = _ordered_camera_epoch_members(members)
init = ordered[0]
assert init.artifact_id is not None
assert init.camera_epoch is not None
archive_root = _prepare_secure_root(root / "camera-epoch-archives")
binding_identity = _camera_epoch_archive_binding_identity(job, ordered)
binding_id = hashlib.sha256(canonical_json(binding_identity)).hexdigest()
binding_root = _prepare_secure_root(archive_root / "bindings")
binding_path = binding_root / f"{binding_id}.json"
if binding_path.exists():
return _load_camera_epoch_archive_binding(
binding_path,
binding_id=binding_id,
identity=binding_identity,
archive_root=archive_root,
)
for member in ordered:
if member._source_path is None:
raise PortableArtifactTransportIntegrityError(
"portable camera archive member has no admitted source"
)
temporary = archive_root / f".tmp-{secrets.token_hex(16)}"
descriptor = os.open(
temporary,
os.O_WRONLY
| os.O_CREAT
| os.O_EXCL
| getattr(os, "O_NOFOLLOW", 0),
0o600,
)
try:
with os.fdopen(descriptor, "wb") as stream:
descriptor = -1
with tarfile.open(
fileobj=stream,
mode="w",
format=tarfile.USTAR_FORMAT,
) as archive:
for member in ordered:
info = tarfile.TarInfo(_camera_archive_relative_name(member))
info.size = member.byte_length
info.mode = 0o600
info.mtime = 0
info.uid = 0
info.gid = 0
info.uname = ""
info.gname = ""
assert member._source_path is not None
_add_exact_camera_archive_member(
archive,
info,
member._source_path,
expected_sha256=member.sha256,
expected_byte_length=member.byte_length,
)
stream.flush()
os.fsync(stream.fileno())
sha256, byte_length = _hash_regular_file(
temporary,
maximum_bytes=MAX_CAMERA_EPOCH_ARCHIVE_BYTES,
)
if byte_length != _camera_epoch_archive_byte_length(ordered):
raise PortableArtifactTransportIntegrityError(
"portable camera epoch archive size is not deterministic"
)
result = PortableCameraEpochArchive(
archive_id=portable_camera_epoch_archive_id(
job_identity_sha256=job.identity_sha256,
source_bundle_sha256=job.source_bundle_sha256,
artifact_id=init.artifact_id,
camera_epoch=init.camera_epoch,
member_ids=tuple(member.member_id for member in ordered),
byte_length=byte_length,
sha256=sha256,
),
job_identity_sha256=job.identity_sha256,
source_bundle_sha256=job.source_bundle_sha256,
artifact_id=init.artifact_id,
camera_epoch=init.camera_epoch,
member_ids=tuple(member.member_id for member in ordered),
byte_length=byte_length,
sha256=sha256,
)
destination_root = _prepare_secure_root(
archive_root / result.sha256[:2]
)
destination = destination_root / result.sha256
_publish_uploaded_file(
temporary,
destination,
expected_sha256=result.sha256,
expected_byte_length=result.byte_length,
)
_write_immutable_file(
binding_path,
canonical_json(
{
**binding_identity,
"binding_id": binding_id,
"archive": result.as_dict(),
"authority": dict(OBSERVATION_ONLY_AUTHORITY),
}
),
)
return result, destination
except PortableArtifactTransportError:
raise
except (OSError, tarfile.TarError, ValueError) as exc:
raise PortableArtifactTransportUnavailableError(
"portable camera epoch archive could not be materialized"
) from exc
finally:
if descriptor >= 0:
os.close(descriptor)
with suppress(FileNotFoundError):
temporary.unlink()
class _ExactArchiveSource:
def __init__(self, stream: BinaryIO, *, expected_byte_length: int) -> None:
self._stream = stream
self._expected_byte_length = expected_byte_length
self._digest = hashlib.sha256()
self.byte_length = 0
def read(self, size: int = -1) -> bytes:
payload = self._stream.read(size)
self.byte_length += len(payload)
if self.byte_length > self._expected_byte_length:
raise PortableArtifactTransportIntegrityError(
"portable camera archive source grew while read"
)
if not payload and self.byte_length != self._expected_byte_length:
raise PortableArtifactTransportIntegrityError(
"portable camera archive source ended early"
)
self._digest.update(payload)
return payload
@property
def sha256(self) -> str:
return self._digest.hexdigest()
def _add_exact_camera_archive_member(
archive: tarfile.TarFile,
info: tarfile.TarInfo,
source: Path,
*,
expected_sha256: str,
expected_byte_length: int,
) -> None:
descriptor = os.open(
source,
os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0),
)
with os.fdopen(descriptor, "rb") as source_stream:
before = os.fstat(source_stream.fileno())
if not stat.S_ISREG(before.st_mode) or before.st_size != expected_byte_length:
raise PortableArtifactTransportIntegrityError(
"portable camera archive source is not the admitted regular file"
)
verified = _ExactArchiveSource(
source_stream,
expected_byte_length=expected_byte_length,
)
archive.addfile(info, cast(BinaryIO, verified))
after = os.fstat(source_stream.fileno())
stable = (
before.st_dev,
before.st_ino,
before.st_size,
before.st_mtime_ns,
) == (
after.st_dev,
after.st_ino,
after.st_size,
after.st_mtime_ns,
)
if (
verified.byte_length != expected_byte_length
or verified.sha256 != expected_sha256
or not stable
):
raise PortableArtifactTransportIntegrityError(
"portable camera archive source changed while read"
)
def _source_member(
job: ObservatoryRecordedJob,
*,
+328 -1
View File
@@ -14,21 +14,28 @@ import json
import os
import re
import secrets
import shutil
import stat
import tarfile
import tempfile
import threading
from collections.abc import Mapping
from contextlib import suppress
from dataclasses import dataclass
from pathlib import Path
from typing import Final, Literal, cast
from typing import BinaryIO, Final, Literal, cast
from urllib.parse import urlsplit
import httpx
from k1link.observatory.portable_artifact_transport import (
MAX_CAMERA_EPOCH_ARCHIVE_BYTES,
PORTABLE_CAMERA_EPOCH_ARCHIVE_ID_HEADER,
PORTABLE_CAMERA_EPOCH_ARCHIVE_MEDIA_TYPE,
PORTABLE_RESULT_UPLOAD_PLAN_SCHEMA,
PORTABLE_RESULT_UPLOAD_RECEIPT_SCHEMA,
PORTABLE_SOURCE_MATERIALIZATION_SCHEMA,
portable_camera_epoch_archive_id,
)
from k1link.observatory.portable_result_contract import (
OBSERVATION_ONLY_AUTHORITY,
@@ -54,6 +61,7 @@ from k1link.observatory.worker_agent import (
WORKER_HTTP_MAX_JSON_BYTES: Final = 16 * 1024 * 1024
WORKER_HTTP_COPY_CHUNK_BYTES: Final = 1024 * 1024
WORKER_HTTP_DEFAULT_TIMEOUT_SECONDS: Final = 30.0
WORKER_HTTP_CAMERA_ARCHIVE_READ_TIMEOUT_SECONDS: Final = 30 * 60.0
_SHA256 = re.compile(r"^[a-f0-9]{64}$")
_JOB_ID = re.compile(r"^observatory-run-[a-f0-9]{32}$")
@@ -136,6 +144,7 @@ class ObservatoryWorkerHttpGateway(ObservatoryWorkerTransport):
_CONTOUR_HEADER: contour_id,
"Accept": "application/json",
}
self._timeout_seconds = timeout_seconds
self._work_root = _secure_directory(work_root)
self._client = httpx.Client(
base_url=self._base_url,
@@ -304,6 +313,21 @@ class ObservatoryWorkerHttpGateway(ObservatoryWorkerTransport):
"source materialization members select the same local role"
)
destinations[destination] = member
camera_members = _ordered_camera_epoch_members(members)
if not all(
_matches_file(
_source_destination(root, member),
member.sha256,
member.byte_length,
)
for member in camera_members
):
self._download_camera_epoch_archive(
job=job,
context=context,
members=camera_members,
root=root,
)
for destination, member in destinations.items():
if _matches_file(destination, member.sha256, member.byte_length):
continue
@@ -463,6 +487,123 @@ class ObservatoryWorkerHttpGateway(ObservatoryWorkerTransport):
result_sha256=draft.result_sha256,
)
def _download_camera_epoch_archive(
self,
*,
job: SealedObservatoryRecordedJob,
context: _ClaimContext,
members: tuple[_SourceMember, ...],
root: Path,
) -> None:
transfer_root = _secure_directory(self._work_root / ".source-transfers")
temporary = transfer_root / f".camera-epoch-{secrets.token_hex(16)}.tar"
descriptor = -1
try:
with self._client.stream(
"GET",
self._job_path(job.job_id, "source-camera-epoch-archive"),
headers=self._claim_headers(context),
timeout=httpx.Timeout(
connect=self._timeout_seconds,
read=WORKER_HTTP_CAMERA_ARCHIVE_READ_TIMEOUT_SECONDS,
write=self._timeout_seconds,
pool=self._timeout_seconds,
),
) as response:
if response.status_code == 404:
return
self._raise_for_status(response)
if (
response.headers.get("content-type")
!= PORTABLE_CAMERA_EPOCH_ARCHIVE_MEDIA_TYPE
):
raise ObservatoryWorkerHttpError(
"camera epoch archive media type changed"
)
declared = response.headers.get("content-length")
if (
declared is None
or not declared.isascii()
or not declared.isdigit()
or len(declared) > len(str(MAX_CAMERA_EPOCH_ARCHIVE_BYTES))
or str(int(declared)) != declared
):
raise ObservatoryWorkerHttpError(
"camera epoch archive length is invalid"
)
expected_bytes = int(declared)
expected_sha256 = response.headers.get(_CONTENT_SHA_HEADER, "")
archive_id = response.headers.get(
PORTABLE_CAMERA_EPOCH_ARCHIVE_ID_HEADER,
"",
)
if (
not 1 <= expected_bytes <= MAX_CAMERA_EPOCH_ARCHIVE_BYTES
or _SHA256.fullmatch(expected_sha256) is None
or _SHA256.fullmatch(archive_id) is None
):
raise ObservatoryWorkerHttpError(
"camera epoch archive seal is invalid"
)
descriptor = os.open(
temporary,
os.O_WRONLY
| os.O_CREAT
| os.O_EXCL
| getattr(os, "O_NOFOLLOW", 0),
0o600,
)
digest = hashlib.sha256()
byte_length = 0
with os.fdopen(descriptor, "wb") as stream:
descriptor = -1
for chunk in response.iter_bytes(WORKER_HTTP_COPY_CHUNK_BYTES):
byte_length += len(chunk)
if byte_length > expected_bytes:
raise ObservatoryWorkerHttpError(
"camera epoch archive exceeds its declared length"
)
digest.update(chunk)
stream.write(chunk)
stream.flush()
os.fsync(stream.fileno())
if byte_length != expected_bytes or digest.hexdigest() != expected_sha256:
raise ObservatoryWorkerHttpError(
"camera epoch archive content differs from its seal"
)
init = members[0]
assert init.artifact_id is not None
assert init.camera_epoch is not None
expected_archive_id = portable_camera_epoch_archive_id(
job_identity_sha256=job.identity_sha256,
source_bundle_sha256=job.source_bundle_sha256,
artifact_id=init.artifact_id,
camera_epoch=init.camera_epoch,
member_ids=tuple(member.member_id for member in members),
byte_length=byte_length,
sha256=expected_sha256,
)
if archive_id != expected_archive_id:
raise ObservatoryWorkerHttpError(
"camera epoch archive identity changed"
)
_extract_camera_epoch_archive(
temporary,
root=root,
members=members,
)
except ObservatoryWorkerHttpError:
raise
except httpx.HTTPError as exc:
raise ObservatoryWorkerHttpError(
"camera epoch archive transport is unavailable"
) from exc
finally:
if descriptor >= 0:
os.close(descriptor)
with suppress(FileNotFoundError):
temporary.unlink()
def _download_member(
self,
*,
@@ -868,6 +1009,192 @@ def _source_member(value: object) -> _SourceMember:
)
def _ordered_camera_epoch_members(
members: tuple[_SourceMember, ...],
) -> tuple[_SourceMember, ...]:
inits = tuple(member for member in members if member.kind == "camera-init")
segments = tuple(member for member in members if member.kind == "camera-segment")
if len(inits) != 1 or not segments:
raise ObservatoryWorkerHttpError(
"source materialization camera epoch is incomplete"
)
init = inits[0]
if init.artifact_id is None or init.camera_epoch is None:
raise ObservatoryWorkerHttpError(
"source materialization camera identity is incomplete"
)
ordered_segments = tuple(
sorted(segments, key=lambda member: member.camera_sequence or 0)
)
if tuple(member.camera_sequence for member in ordered_segments) != tuple(
range(1, len(ordered_segments) + 1)
) or any(
member.artifact_id != init.artifact_id
or member.camera_epoch != init.camera_epoch
for member in ordered_segments
):
raise ObservatoryWorkerHttpError(
"source materialization camera member order changed"
)
return (init, *ordered_segments)
def _camera_archive_relative_name(member: _SourceMember) -> str:
if member.kind == "camera-init":
return "init.mp4"
if member.kind != "camera-segment" or member.camera_sequence is None:
raise ObservatoryWorkerHttpError("camera archive member role is invalid")
return f"segments/{member.camera_sequence}.m4s"
def _camera_epoch_archive_byte_length(members: tuple[_SourceMember, ...]) -> int:
content_bytes = sum(
512 + ((member.byte_length + 511) // 512) * 512 for member in members
)
logical_bytes = content_bytes + 1024
return ((logical_bytes + tarfile.RECORDSIZE - 1) // tarfile.RECORDSIZE) * (
tarfile.RECORDSIZE
)
def _extract_camera_epoch_archive(
archive_path: Path,
*,
root: Path,
members: tuple[_SourceMember, ...],
) -> None:
expected_bytes = _camera_epoch_archive_byte_length(members)
try:
archive_metadata = archive_path.lstat()
except OSError as exc:
raise ObservatoryWorkerHttpError(
"camera epoch archive is unavailable"
) from exc
if (
stat.S_ISLNK(archive_metadata.st_mode)
or not stat.S_ISREG(archive_metadata.st_mode)
or archive_metadata.st_size != expected_bytes
):
raise ObservatoryWorkerHttpError(
"camera epoch archive size differs from its member inventory"
)
transfer_root = _secure_directory(archive_path.parent)
staging = Path(
tempfile.mkdtemp(prefix=".camera-epoch-extract-", dir=transfer_root)
)
staged: list[Path] = []
archive_descriptor = -1
try:
archive_descriptor = os.open(
archive_path,
os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0),
)
with os.fdopen(archive_descriptor, "rb") as archive_stream:
archive_descriptor = -1
with tarfile.open(fileobj=archive_stream, mode="r:") as archive:
for member in members:
info = archive.next()
if info is None:
raise ObservatoryWorkerHttpError(
"camera epoch archive member inventory changed"
)
relative_name = _camera_archive_relative_name(member)
if (
info.name != relative_name
or info.type != tarfile.REGTYPE
or info.size != member.byte_length
or info.mode != 0o600
or info.mtime != 0
or info.uid != 0
or info.gid != 0
or info.uname != ""
or info.gname != ""
or info.linkname != ""
or info.pax_headers
):
raise ObservatoryWorkerHttpError(
"camera epoch archive contains an unsafe member"
)
source = archive.extractfile(info)
if source is None:
raise ObservatoryWorkerHttpError(
"camera epoch archive member is unreadable"
)
target = staging.joinpath(*relative_name.split("/"))
_secure_directory(target.parent)
with source:
_copy_camera_archive_member(
cast(BinaryIO, source),
target,
expected_sha256=member.sha256,
expected_byte_length=member.byte_length,
)
staged.append(target)
if archive.next() is not None:
raise ObservatoryWorkerHttpError(
"camera epoch archive member inventory changed"
)
for temporary, member in zip(staged, members, strict=True):
_publish_local_file(
temporary,
_source_destination(root, member),
member.sha256,
member.byte_length,
)
except ObservatoryWorkerHttpError:
raise
except (OSError, tarfile.TarError) as exc:
raise ObservatoryWorkerHttpError(
"camera epoch archive could not be extracted"
) from exc
finally:
if archive_descriptor >= 0:
os.close(archive_descriptor)
shutil.rmtree(staging, ignore_errors=True)
def _copy_camera_archive_member(
source: BinaryIO,
destination: Path,
*,
expected_sha256: str,
expected_byte_length: int,
) -> None:
descriptor = os.open(
destination,
os.O_WRONLY
| os.O_CREAT
| os.O_EXCL
| getattr(os, "O_NOFOLLOW", 0),
0o600,
)
digest = hashlib.sha256()
byte_length = 0
try:
with os.fdopen(descriptor, "wb") as stream:
descriptor = -1
while chunk := source.read(WORKER_HTTP_COPY_CHUNK_BYTES):
byte_length += len(chunk)
if byte_length > expected_byte_length:
raise ObservatoryWorkerHttpError(
"camera epoch archive member exceeds its declared length"
)
digest.update(chunk)
stream.write(chunk)
stream.flush()
os.fsync(stream.fileno())
if byte_length != expected_byte_length or digest.hexdigest() != expected_sha256:
raise ObservatoryWorkerHttpError(
"camera epoch archive member content changed"
)
finally:
if descriptor >= 0:
os.close(descriptor)
if not _matches_file(destination, expected_sha256, expected_byte_length):
with suppress(OSError):
destination.unlink()
def _source_destination(root: Path, member: _SourceMember) -> Path:
if member.kind == "source-bundle":
return root / "source-bundle.json"
+37
View File
@@ -26,6 +26,8 @@ from pydantic import BaseModel, ConfigDict, Field
from k1link.observatory.portable_artifact_transport import (
MAX_RESULT_MANIFEST_BYTES,
PORTABLE_CAMERA_EPOCH_ARCHIVE_ID_HEADER,
PORTABLE_CAMERA_EPOCH_ARCHIVE_MEDIA_TYPE,
PortableArtifactTransportError,
PortableArtifactTransportIntegrityError,
PortableArtifactTransportUnavailableError,
@@ -381,6 +383,41 @@ def build_observatory_worker_router(
.as_dict()
)
@router.get("/recorded-jobs/{job_id}/source-camera-epoch-archive")
def source_camera_epoch_archive(
job_id: Annotated[str, ApiPath(pattern=_JOB_ID_PATTERN)],
claim_token: Annotated[
str,
Header(
alias=OBSERVATORY_WORKER_CLAIM_TOKEN_HEADER,
pattern=_CLAIM_TOKEN_PATTERN,
),
],
claim_generation: Annotated[
int,
Header(alias=OBSERVATORY_WORKER_CLAIM_GENERATION_HEADER, ge=1),
],
) -> FileResponse:
archive, path = _artifact_call(
lambda: artifact_transport.materialize_camera_epoch_archive(
job_id=job_id,
claim_token=claim_token,
claim_generation=claim_generation,
claimant_id=authentication.contour_id,
)
)
return FileResponse(
path,
media_type=PORTABLE_CAMERA_EPOCH_ARCHIVE_MEDIA_TYPE,
headers={
"ETag": f'"{archive.sha256}"',
"Cache-Control": "private, no-store",
"X-Content-Type-Options": "nosniff",
"X-Mission-Core-Content-Sha256": archive.sha256,
PORTABLE_CAMERA_EPOCH_ARCHIVE_ID_HEADER: archive.archive_id,
},
)
@router.get("/recorded-jobs/{job_id}/source-members/{member_id}")
def source_member(
job_id: Annotated[str, ApiPath(pattern=_JOB_ID_PATTERN)],
@@ -4,6 +4,7 @@ import asyncio
import copy
import hashlib
import json
import tarfile
from dataclasses import dataclass
from pathlib import Path
from typing import cast
@@ -484,6 +485,51 @@ def test_source_member_rejects_tampering_and_stale_generation(tmp_path: Path) ->
)
def test_camera_epoch_archive_is_sealed_deterministic_and_cached(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
fixture = _fixture(tmp_path)
archive, archive_path = fixture.service.materialize_camera_epoch_archive(
job_id=fixture.job.job_id,
claim_token=fixture.claim_token,
claim_generation=fixture.job.claim_generation,
claimant_id="worker-006",
)
assert archive_path.name == archive.sha256
assert archive_path.stat().st_size == archive.byte_length
assert hashlib.sha256(archive_path.read_bytes()).hexdigest() == archive.sha256
with tarfile.open(archive_path, mode="r:") as packed:
members = packed.getmembers()
assert [member.name for member in members] == ["init.mp4", "segments/1.m4s"]
assert all(
member.type == tarfile.REGTYPE
and member.mode == 0o600
and member.mtime == 0
and member.uid == 0
and member.gid == 0
and member.uname == ""
and member.gname == ""
for member in members
)
def reject_rebuild(*_args: object, **_kwargs: object) -> object:
pytest.fail("immutable camera epoch archive must be reused")
monkeypatch.setattr(tarfile, "open", reject_rebuild)
repeated, repeated_path = fixture.service.materialize_camera_epoch_archive(
job_id=fixture.job.job_id,
claim_token=fixture.claim_token,
claim_generation=fixture.job.claim_generation,
claimant_id="worker-006",
)
assert repeated == archive
assert repeated_path == archive_path
def test_result_upload_is_atomic_resumable_and_required_before_success(
tmp_path: Path,
) -> None:
+245 -5
View File
@@ -1,7 +1,9 @@
from __future__ import annotations
import hashlib
import io
import json
import tarfile
from collections.abc import Callable
from pathlib import Path
@@ -9,9 +11,12 @@ import httpx
import pytest
from k1link.observatory.portable_artifact_transport import (
PORTABLE_CAMERA_EPOCH_ARCHIVE_ID_HEADER,
PORTABLE_CAMERA_EPOCH_ARCHIVE_MEDIA_TYPE,
PORTABLE_RESULT_UPLOAD_PLAN_SCHEMA,
PORTABLE_RESULT_UPLOAD_RECEIPT_SCHEMA,
PORTABLE_SOURCE_MATERIALIZATION_SCHEMA,
portable_camera_epoch_archive_id,
)
from k1link.observatory.portable_result_contract import (
OBSERVATION_ONLY_AUTHORITY,
@@ -29,6 +34,8 @@ from k1link.observatory.worker_agent import (
SealedObservatoryRecordedJob,
)
from k1link.observatory.worker_http_transport import (
WORKER_HTTP_CAMERA_ARCHIVE_READ_TIMEOUT_SECONDS,
WORKER_HTTP_DEFAULT_TIMEOUT_SECONDS,
ObservatoryWorkerHttpError,
ObservatoryWorkerHttpGateway,
)
@@ -138,7 +145,10 @@ def _source_contract(
job: SealedObservatoryRecordedJob,
*,
inject_path: bool = False,
camera_segment_count: int = 1,
) -> tuple[dict[str, object], dict[str, bytes]]:
if camera_segment_count < 1:
raise ValueError("camera segment count must be positive")
rows = [
_source_member(
job,
@@ -175,16 +185,23 @@ def _source_contract(
camera_epoch=1,
media_type='video/mp4; codecs="avc1.641028"',
),
]
rows.extend(
_source_member(
job,
kind="camera-segment",
payload=b"sealed-camera-segment",
payload=(
b"sealed-camera-segment"
if sequence == 1
else f"sealed-camera-segment-{sequence}".encode("ascii")
),
artifact_id="recorded-camera-right",
camera_epoch=1,
camera_sequence=1,
camera_sequence=sequence,
media_type="video/iso.segment",
),
]
)
for sequence in range(1, camera_segment_count + 1)
)
members = [row for row, _payload in rows]
members.sort(key=lambda row: str(row["member_id"]))
if inject_path:
@@ -210,6 +227,86 @@ def _source_contract(
)
def _camera_archive_response(
job: SealedObservatoryRecordedJob,
manifest: dict[str, object],
payloads: dict[str, bytes],
*,
mutation: str | None = None,
) -> tuple[bytes, dict[str, str]]:
rows = manifest["members"]
assert isinstance(rows, list)
camera_rows = [
row
for row in rows
if isinstance(row, dict)
and row.get("kind") in {"camera-init", "camera-segment"}
]
camera_rows.sort(
key=lambda row: (
0 if row["kind"] == "camera-init" else 1,
int(row["camera_sequence"] or 0),
)
)
packed = io.BytesIO()
with tarfile.open(
fileobj=packed,
mode="w",
format=tarfile.USTAR_FORMAT,
) as archive:
for index, row in enumerate(camera_rows):
sequence = row["camera_sequence"]
name = (
"init.mp4"
if row["kind"] == "camera-init"
else f"segments/{sequence}.m4s"
)
if mutation == "traversal" and index == len(camera_rows) - 1:
name = "../../operator-secret"
member_id = str(row["member_id"])
payload = payloads[member_id]
info = tarfile.TarInfo(name)
info.mode = 0o600
info.mtime = 0
info.uid = 0
info.gid = 0
info.uname = ""
info.gname = ""
if mutation == "symlink" and index == len(camera_rows) - 1:
info.type = tarfile.SYMTYPE
info.linkname = "../../operator-secret"
info.size = 0
archive.addfile(info)
else:
info.size = len(payload)
archive.addfile(info, io.BytesIO(payload))
archive_payload = packed.getvalue()
sha256 = hashlib.sha256(archive_payload).hexdigest()
init = camera_rows[0]
archive_id = portable_camera_epoch_archive_id(
job_identity_sha256=job.identity_sha256,
source_bundle_sha256=job.source_bundle_sha256,
artifact_id=str(init["artifact_id"]),
camera_epoch=int(init["camera_epoch"]),
member_ids=tuple(str(row["member_id"]) for row in camera_rows),
byte_length=len(archive_payload),
sha256=sha256,
)
headers = {
"Content-Type": PORTABLE_CAMERA_EPOCH_ARCHIVE_MEDIA_TYPE,
"Content-Length": str(len(archive_payload)),
"X-Mission-Core-Content-Sha256": sha256,
PORTABLE_CAMERA_EPOCH_ARCHIVE_ID_HEADER: archive_id,
}
if mutation == "corruption":
changed = bytearray(archive_payload)
offset = archive_payload.find(b"sealed-camera-init")
assert offset >= 0
changed[offset] ^= 1
archive_payload = bytes(changed)
return archive_payload, headers
def test_http_gateway_materializes_only_exact_claim_bound_members(
tmp_path: Path,
) -> None:
@@ -232,6 +329,8 @@ def test_http_gateway_materializes_only_exact_claim_bound_members(
artifact_requests.append(request)
if request.url.path.endswith("/source-materialization"):
return httpx.Response(200, json=manifest)
if request.url.path.endswith("/source-camera-epoch-archive"):
return httpx.Response(404)
member_id = request.url.path.rsplit("/", 1)[-1]
payload = payloads[member_id]
return httpx.Response(
@@ -266,7 +365,148 @@ def test_http_gateway_materializes_only_exact_claim_bound_members(
)
assert persisted == manifest
assert "path" not in json.dumps(persisted, sort_keys=True)
assert len(artifact_requests) == 1 + len(payloads)
assert len(artifact_requests) == 2 + len(payloads)
def test_http_gateway_packed_camera_epoch_bounds_requests_and_header_timeout(
tmp_path: Path,
) -> None:
job = _job(
bundle_sha256=hashlib.sha256(b"source-bundle").hexdigest(),
capability_sha256=hashlib.sha256(b"source-capability").hexdigest(),
)
manifest, payloads = _source_contract(job, camera_segment_count=101)
archive_payload, archive_headers = _camera_archive_response(
job,
manifest,
payloads,
)
artifact_requests: list[httpx.Request] = []
camera_member_requests: list[str] = []
archive_read_timeouts: list[float] = []
rows = manifest["members"]
assert isinstance(rows, list)
camera_member_ids = {
str(row["member_id"])
for row in rows
if isinstance(row, dict)
and row.get("kind") in {"camera-init", "camera-segment"}
}
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path.endswith("/claims"):
return _claim_response()
artifact_requests.append(request)
if request.url.path.endswith("/source-materialization"):
return httpx.Response(200, json=manifest)
if request.url.path.endswith("/source-camera-epoch-archive"):
timeout = request.extensions.get("timeout")
assert isinstance(timeout, dict)
read_timeout = timeout.get("read")
assert isinstance(read_timeout, float)
assert read_timeout > WORKER_HTTP_DEFAULT_TIMEOUT_SECONDS
archive_read_timeouts.append(read_timeout)
return httpx.Response(
200,
content=archive_payload,
headers=archive_headers,
)
member_id = request.url.path.rsplit("/", 1)[-1]
if member_id in camera_member_ids:
camera_member_requests.append(member_id)
payload = payloads[member_id]
return httpx.Response(
200,
content=payload,
headers={
"X-Mission-Core-Content-Sha256": hashlib.sha256(
payload
).hexdigest()
},
)
with ObservatoryWorkerHttpGateway(
base_url="http://127.0.0.1:18080",
bearer_token=BEARER_TOKEN,
work_root=tmp_path / "worker",
transport=httpx.MockTransport(handler),
) as gateway:
_cache_claim(gateway)
stage = gateway.materialize(job)
assert archive_read_timeouts == [
WORKER_HTTP_CAMERA_ARCHIVE_READ_TIMEOUT_SECONDS
]
assert camera_member_requests == []
assert len(artifact_requests) == 6
assert (
stage.root / "camera/epoch-1/segments/101.m4s"
).read_bytes() == b"sealed-camera-segment-101"
expected_files = {
"source-bundle.json",
"source-capability.json",
"mqtt.raw.k1mqtt",
"mqtt.metadata.jsonl",
"materialization-manifest.json",
"camera/epoch-1/init.mp4",
*(f"camera/epoch-1/segments/{sequence}.m4s" for sequence in range(1, 102)),
}
assert {
path.relative_to(stage.root).as_posix()
for path in stage.root.rglob("*")
if path.is_file()
} == expected_files
@pytest.mark.parametrize(
("mutation", "message"),
[
("corruption", "content differs"),
("traversal", "unsafe member"),
("symlink", "unsafe member"),
],
)
def test_http_gateway_rejects_corrupt_or_unsafe_camera_archive(
tmp_path: Path,
mutation: str,
message: str,
) -> None:
job = _job(
bundle_sha256=hashlib.sha256(b"source-bundle").hexdigest(),
capability_sha256=hashlib.sha256(b"source-capability").hexdigest(),
)
manifest, payloads = _source_contract(job)
archive_payload, archive_headers = _camera_archive_response(
job,
manifest,
payloads,
mutation=mutation,
)
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path.endswith("/claims"):
return _claim_response()
if request.url.path.endswith("/source-materialization"):
return httpx.Response(200, json=manifest)
if request.url.path.endswith("/source-camera-epoch-archive"):
return httpx.Response(
200,
content=archive_payload,
headers=archive_headers,
)
pytest.fail("unsafe camera archive must fail before member fallback")
with ObservatoryWorkerHttpGateway(
base_url="http://127.0.0.1:18080",
bearer_token=BEARER_TOKEN,
work_root=tmp_path / "worker",
transport=httpx.MockTransport(handler),
) as gateway:
_cache_claim(gateway)
with pytest.raises(ObservatoryWorkerHttpError, match=message):
gateway.materialize(job)
assert not list(tmp_path.rglob("operator-secret"))
def test_http_gateway_rejects_server_selected_source_path(tmp_path: Path) -> None: