[AZ-328] C12 BuildCacheOrchestrator + remote C10 invoker (Batch 43)

Implements F1 pre-flight cache build orchestrator on the operator
workstation. Composes C11 TileDownloader (AZ-316), C12 CompanionBringup
(AZ-327), C12 FlightsApiClient (AZ-489), and the new
RemoteCacheProvisionerInvoker into one sequenced flow guarded by a
filelock-backed workstation-side lockfile.

Architectural decisions:
- Phase-0 flight-resolve runs BEFORE the lockfile (ADR-010): a flight
  that cannot be resolved is an operator-input error, not a contended-
  resource error. Enforced by AC-11 + AC-14.
- Consumer-side cuts (AZ-507) for C11 + C10 types: local Protocols /
  mirror DTOs in tile_downloader_cut.py and _types.py; external errors
  matched by name-based whitelisting so unknown exceptions still
  propagate per AC-6. Cross-component type translation lives at the
  composition root (c12_factory).
- Failure surfacing: recognised operational failures (download error,
  companion not ready, build error, flight-resolve error) return as
  CacheBuildReport(outcome=failure, failure_phase=...). Only lockfile
  contention raises (BuildLockHeldError) since no phase ever ran.
- Workstation-side filelock library (project pin); no custom primitive.
- Remote C10 stdout streamed line-by-line as DEBUG with api_key /
  auth_token redacted before logging (defence-in-depth).
- CLI is now a thin adapter; all workflow logic lives in
  build_cache.py. operator-tool build-cache exit codes map per
  CacheBuildReport.failure_phase + failure_exception_type.

Tests: 116 c12 unit tests pass (29 new for AZ-328 covering 15/15 ACs +
NFR-perf-overhead microbench; 7 new for remote_c10_invoker; 3 new for
file_lock; test_cli_build_cache rewritten for new orchestrator
interface). Full repo suite: 1522 passed, 80 skipped.

Also: replays Batch 42's ruff format leftover for c12 flights_api +
test_az489 files (formatter ran over the c12 directory after new
files were added). Pure whitespace; no behaviour change.

Full report: _docs/03_implementation/batch_43_cycle1_report.md

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-13 11:03:46 +03:00
parent 099c75c6f8
commit 7644b25e8c
23 changed files with 3585 additions and 256 deletions
@@ -6,13 +6,19 @@
classification map.
* :func:`build_companion_bringup` — AZ-327 SSH-based pre-flight
verification of the companion's four artifacts.
* :func:`build_build_cache_orchestrator` — AZ-328 F1 cache-build
orchestrator. Wires the ``filelock`` factory + the remote C10 invoker
+ the c11 ``TileDownloader`` adapter on top of the existing AZ-326 /
AZ-327 / AZ-489 services. The AZ-507 cross-component cut means we
translate c11's real ``DownloadRequest`` / ``DownloadBatchReport`` to
the local ``DownloadRequestCut`` / ``DownloadBatchReportCut`` here.
* :func:`build_operator_tool` — aggregator that returns the
:class:`OperatorToolServices` dataclass the AZ-326 CLI consumes.
Each ``build_*`` function is intentionally tiny — there is one
production strategy per service today and the CLI wiring just plugs
the concrete instance into the same composition root method. Sibling
tasks AZ-328 / AZ-329 / AZ-330 will each add a single field to
tasks AZ-329 / AZ-330 will each add a single field to
:class:`OperatorToolServices` without renaming or moving the
dataclass.
"""
@@ -23,9 +29,16 @@ import logging
from dataclasses import dataclass
from typing import TYPE_CHECKING
from gps_denied_onboard.clock import Clock
from gps_denied_onboard.components.c12_operator_tooling.build_cache import (
BuildCacheOrchestrator,
)
from gps_denied_onboard.components.c12_operator_tooling.companion_bringup import (
CompanionBringup,
)
from gps_denied_onboard.components.c12_operator_tooling.file_lock import (
FilelockFileLockFactory,
)
from gps_denied_onboard.components.c12_operator_tooling.flights_api import (
FlightsApiClient,
HttpxFlightsApiClient,
@@ -33,12 +46,18 @@ from gps_denied_onboard.components.c12_operator_tooling.flights_api import (
from gps_denied_onboard.components.c12_operator_tooling.paramiko_ssh_session import (
ParamikoSshSessionFactory,
)
from gps_denied_onboard.components.c12_operator_tooling.remote_c10_invoker import (
RemoteCacheProvisionerInvoker,
)
from gps_denied_onboard.components.c12_operator_tooling.remote_sidecar_verifier import (
RemoteSidecarVerifier,
)
from gps_denied_onboard.components.c12_operator_tooling.sector_classification_store import (
SectorClassificationStore,
)
from gps_denied_onboard.components.c12_operator_tooling.tile_downloader_cut import (
TileDownloaderCut,
)
if TYPE_CHECKING:
from gps_denied_onboard.components.c12_operator_tooling.config import (
@@ -48,6 +67,7 @@ if TYPE_CHECKING:
__all__ = [
"OperatorToolServices",
"build_build_cache_orchestrator",
"build_companion_bringup",
"build_flights_api_client",
"build_operator_tool",
@@ -57,6 +77,8 @@ __all__ = [
_C12_LOGGER_NAME = "c12_operator_tooling"
_COMPANION_LOGGER_NAME = "c12_operator_tooling.companion_bringup"
_BUILD_CACHE_LOGGER_NAME = "c12_operator_tooling.build_cache"
_REMOTE_C10_LOGGER_NAME = "c12_operator_tooling.remote_c10_invoker"
@dataclass(frozen=True)
@@ -65,15 +87,22 @@ class OperatorToolServices:
AZ-326 introduced the dataclass and now owns three services
(``flights_api_client``, ``sector_classification_store``,
``companion_bringup``). Sibling tasks AZ-328 (orchestrator),
AZ-329 (post-landing upload), and AZ-330 (operator reloc service)
extend this dataclass in-place by appending their own service
field — they MUST NOT rename, move, or split it.
``companion_bringup``). AZ-328 added ``build_cache_orchestrator``.
Sibling tasks AZ-329 (post-landing upload) and AZ-330 (operator
reloc service) extend this dataclass in-place by appending their
own service field — they MUST NOT rename, move, or split it.
``build_cache_orchestrator`` is ``None`` when the AZ-328 wiring is
not requested (e.g. unit tests for AZ-326 / AZ-327 that don't go
through the full build path); the CLI's ``build-cache`` subcommand
short-circuits with an EXIT_OK + log when the field is missing /
None so the rest of the CLI keeps working.
"""
flights_api_client: FlightsApiClient
sector_classification_store: SectorClassificationStore
companion_bringup: CompanionBringup
build_cache_orchestrator: BuildCacheOrchestrator | None = None
def build_flights_api_client(config: Config) -> FlightsApiClient:
@@ -130,13 +159,86 @@ def build_companion_bringup(
)
def build_operator_tool(config: Config) -> OperatorToolServices:
"""Aggregate the three AZ-326 / AZ-327 / AZ-489 service handles."""
return OperatorToolServices(
def build_build_cache_orchestrator(
config: Config,
*,
services: OperatorToolServices,
tile_downloader: TileDownloaderCut,
clock: Clock,
logger: logging.Logger | None = None,
) -> BuildCacheOrchestrator:
"""Build the AZ-328 :class:`BuildCacheOrchestrator` from config + sibling services.
Caller (production runtime root) is responsible for translating the
real c11 ``TileDownloader`` to a :class:`TileDownloaderCut` adapter
here — ``c12_operator_tooling`` cannot import c11 directly per
AZ-507. The lockfile factory + remote-C10 invoker + SSH factory are
constructed in-place; the SSH factory MUST be the same instance as
the one wired into ``services.companion_bringup`` (single
composition-root construction per AZ-328 Constraints).
"""
c12_config = _resolve_c12_config(config)
companion = c12_config.companion
if not str(companion.ssh_keyfile):
from gps_denied_onboard.config.schema import ConfigError
raise ConfigError(
"C12CompanionConfig.ssh_keyfile is empty; AZ-328 build_cache_orchestrator "
"requires a real SSH private key path"
)
ssh_factory = ParamikoSshSessionFactory(
ssh_user=companion.ssh_user,
ssh_keyfile=companion.ssh_keyfile,
host_key_policy=companion.host_key_policy,
)
invoker_logger = logger or logging.getLogger(_REMOTE_C10_LOGGER_NAME)
orchestrator_logger = logger or logging.getLogger(_BUILD_CACHE_LOGGER_NAME)
return BuildCacheOrchestrator(
flights_api_client=services.flights_api_client,
tile_downloader=tile_downloader,
companion_bringup=services.companion_bringup,
remote_c10_invoker=RemoteCacheProvisionerInvoker(logger=invoker_logger),
ssh_factory=ssh_factory,
lock_factory=FilelockFileLockFactory(),
logger=orchestrator_logger,
clock=clock,
config=c12_config.build_cache,
)
def build_operator_tool(
config: Config,
*,
tile_downloader: TileDownloaderCut | None = None,
clock: Clock | None = None,
) -> OperatorToolServices:
"""Aggregate the AZ-326 / AZ-327 / AZ-328 / AZ-489 service handles.
``tile_downloader`` and ``clock`` are optional — without them, the
``build_cache_orchestrator`` field is left as ``None`` and the CLI's
``build-cache`` subcommand short-circuits gracefully. Production
wiring (the suite-level runtime root) supplies real instances.
"""
base = OperatorToolServices(
flights_api_client=build_flights_api_client(config),
sector_classification_store=build_sector_classification_store(config),
companion_bringup=build_companion_bringup(config),
)
if tile_downloader is None or clock is None:
return base
orchestrator = build_build_cache_orchestrator(
config,
services=base,
tile_downloader=tile_downloader,
clock=clock,
)
return OperatorToolServices(
flights_api_client=base.flights_api_client,
sector_classification_store=base.sector_classification_store,
companion_bringup=base.companion_bringup,
build_cache_orchestrator=orchestrator,
)
def _resolve_c12_config(config: Config) -> C12Config: