[AZ-326] [AZ-327] C12 operator-tool CLI + companion SSH bringup

AZ-326 (3pt): operator-tool Click CLI shell at
src/gps_denied_onboard/components/c12_operator_tooling/cli.py with six
subcommands (download, build-cache, upload-pending, reloc-confirm,
verify-ready, set-sector); SectorClassificationStore (atomic-write JSON
under ~/.azaion/onboard/sector-classifications.json); freshness-table
lookup driving AC-NEW-6; EXIT_* constants; AZ-266 structured-JSON log
wiring to a rotating ~/.azaion/onboard/c12-tooling.log handler;
operator-tool console-script entry in pyproject.toml.

AZ-327 (3pt): CompanionBringup orchestrator at
src/gps_denied_onboard/components/c12_operator_tooling/companion_bringup.py
that opens an SSH session against the companion (paramiko per project
pin), checks the four pre-flight artifacts (Manifest, expected engines,
sha256 sidecars, calibration), and returns a ReadinessReport per
description.md S2; CompanionUnreachableError + ContentHashMismatchError
with operator-friendly remediation hints; ParamikoSshSessionFactory +
RemoteSidecarVerifier (sha256sum + cat over SSH, no bytes pulled to
the workstation); paramiko>=3.4,<4.0 dep added.

NFR-perf-cold-start fix: PEP 562 lazy __getattr__ in
c12_operator_tooling/__init__.py and flights_api/__init__.py defers
HttpxFlightsApiClient (httpx), ParamikoSshSession[Factory] (paramiko +
cryptography), bbox_from_waypoints / takeoff_origin_from_flight (numpy +
pyproj). cli.py imports from leaf flights_api modules. operator-tool
--help cold start: ~870ms -> <200ms typical, <500ms p99.

Includes 73 unit tests (incl. paramiko-version-drift smoke per AZ-327
Risk 1) + console-script integration test. All 1494 repo-wide unit
tests pass; 80 skips are pre-existing environment gates.

Batch report: _docs/03_implementation/batch_42_cycle1_report.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-13 09:34:14 +03:00
parent a06b107fc3
commit 91ce1c2047
29 changed files with 4001 additions and 34 deletions
@@ -1,37 +1,83 @@
"""Composition-root factory for C12 operator-tooling services (AZ-489).
"""Composition-root factories for C12 operator-tooling services.
Currently exposes :func:`build_flights_api_client` — the
:class:`FlightsApiClient` used by C12's pre-flight cache-build workflow
(see AZ-326 / AZ-328 for downstream consumers).
* :func:`build_flights_api_client` — AZ-489 ``FlightsApiClient`` (online +
offline path).
* :func:`build_sector_classification_store` — AZ-326 persistent area →
classification map.
* :func:`build_companion_bringup` — AZ-327 SSH-based pre-flight
verification of the companion's four artifacts.
* :func:`build_operator_tool` — aggregator that returns the
:class:`OperatorToolServices` dataclass the AZ-326 CLI consumes.
The factory is intentionally tiny: there is only one concrete strategy
(``HttpxFlightsApiClient``) and httpx already defaults to TLS verify ON
and the system trust store, so the factory's job is to assemble the
client without re-implementing those defaults.
The richer ``OperatorToolServices`` dataclass that aggregates this
client with the rest of C12 (``CacheBuildWorkflow``,
``OperatorReLocService``, etc.) is owned by AZ-328 and intentionally
NOT created here per scope discipline.
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
:class:`OperatorToolServices` without renaming or moving the
dataclass.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import TYPE_CHECKING
from gps_denied_onboard.components.c12_operator_tooling.companion_bringup import (
CompanionBringup,
)
from gps_denied_onboard.components.c12_operator_tooling.flights_api import (
FlightsApiClient,
HttpxFlightsApiClient,
)
from gps_denied_onboard.components.c12_operator_tooling.paramiko_ssh_session import (
ParamikoSshSessionFactory,
)
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,
)
if TYPE_CHECKING:
from gps_denied_onboard.components.c12_operator_tooling.config import (
C12Config,
)
from gps_denied_onboard.config import Config
__all__ = ["build_flights_api_client"]
__all__ = [
"OperatorToolServices",
"build_companion_bringup",
"build_flights_api_client",
"build_operator_tool",
"build_sector_classification_store",
]
_C12_LOGGER_NAME = "c12_operator_tooling"
_COMPANION_LOGGER_NAME = "c12_operator_tooling.companion_bringup"
@dataclass(frozen=True)
class OperatorToolServices:
"""Aggregated service handles the operator-tool CLI consumes (AZ-326).
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.
"""
flights_api_client: FlightsApiClient
sector_classification_store: SectorClassificationStore
companion_bringup: CompanionBringup
def build_flights_api_client(config: Config) -> FlightsApiClient:
"""Return the operator-tier :class:`FlightsApiClient`.
"""Return the operator-tier :class:`FlightsApiClient` (AZ-489).
The current implementation is the production
:class:`HttpxFlightsApiClient` with httpx defaults (TLS verify ON,
@@ -42,3 +88,68 @@ def build_flights_api_client(config: Config) -> FlightsApiClient:
"""
_ = config # reserved for future composition-time tuning
return HttpxFlightsApiClient()
def build_sector_classification_store(
config: Config, *, logger: logging.Logger | None = None
) -> SectorClassificationStore:
"""Build the AZ-326 :class:`SectorClassificationStore` from config."""
c12_config = _resolve_c12_config(config)
return SectorClassificationStore(
store_path=c12_config.sector_classification_store_path,
logger=logger or logging.getLogger(_C12_LOGGER_NAME),
)
def build_companion_bringup(
config: Config,
*,
logger: logging.Logger | None = None,
) -> CompanionBringup:
"""Build the AZ-327 :class:`CompanionBringup` from config."""
from gps_denied_onboard.config.schema import ConfigError
c12_config = _resolve_c12_config(config)
companion = c12_config.companion
if not str(companion.ssh_keyfile):
raise ConfigError(
"C12CompanionConfig.ssh_keyfile is empty; operator wiring requires "
"a real SSH private key path"
)
factory = ParamikoSshSessionFactory(
ssh_user=companion.ssh_user,
ssh_keyfile=companion.ssh_keyfile,
host_key_policy=companion.host_key_policy,
)
verifier = RemoteSidecarVerifier(timeout_s=companion.sha256sum_timeout_s)
return CompanionBringup(
ssh_factory=factory,
sidecar_verifier=verifier,
logger=logger or logging.getLogger(_COMPANION_LOGGER_NAME),
config=companion,
)
def build_operator_tool(config: Config) -> OperatorToolServices:
"""Aggregate the three AZ-326 / AZ-327 / AZ-489 service handles."""
return OperatorToolServices(
flights_api_client=build_flights_api_client(config),
sector_classification_store=build_sector_classification_store(config),
companion_bringup=build_companion_bringup(config),
)
def _resolve_c12_config(config: Config) -> C12Config:
from gps_denied_onboard.components.c12_operator_tooling.config import (
C12Config,
)
block = config.components.get("c12_operator_tooling")
if block is None:
return C12Config()
if not isinstance(block, C12Config):
raise TypeError(
"config.components['c12_operator_tooling'] must be a C12Config; got "
f"{type(block).__name__}"
)
return block