[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,31 +1,205 @@
"""C12 Operator Pre-flight Tooling component — Public API."""
"""C12 Operator Pre-flight Tooling component — Public API.
from gps_denied_onboard.components.c12_operator_tooling.flights_api import (
Re-exports:
* AZ-489 — :class:`FlightsApiClient` Protocol + DTOs + errors + helpers.
* AZ-326 — :class:`SectorClassification`, :class:`SectorClassificationStore`,
:func:`freshness_threshold_months`, ``EXIT_*`` constants.
* AZ-327 — :class:`CompanionBringup`, :class:`CompanionAddress`,
:class:`ReadinessReport`, :class:`HostKeyPolicy`, the two error
families, the :class:`SshSession` / :class:`SshSessionFactory`
Protocols, and the production :class:`ParamikoSshSessionFactory`.
Also registers ``C12Config`` with :func:`register_component_block` so
the composition root sees the ``c12_operator_tooling`` slug under
``config.components``.
NOTE on lazy imports (AZ-326 NFR-perf-cold-start, ≤500 ms p99 for
``operator-tool --help``): the heavy adapters
:class:`ParamikoSshSessionFactory` (pulls in ``paramiko`` + ``cryptography``)
and :class:`HttpxFlightsApiClient` (pulls in ``httpx``) are exposed via a
PEP 562 :func:`__getattr__` hook rather than top-level imports. Importing
them from this module — `from gps_denied_onboard.components.c12_operator_tooling
import HttpxFlightsApiClient` — still works for callers, but the heavy
``import paramiko`` / ``import httpx`` only fires on first access. The
project spec's Constraints section forbids eager-importing these libs
from CLI entry points.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from gps_denied_onboard.components.c12_operator_tooling._types import (
AreaIdentifier,
CompanionAddress,
CompanionUnreachableReason,
ReadinessOutcome,
ReadinessReport,
SectorClassification,
)
from gps_denied_onboard.components.c12_operator_tooling.companion_bringup import (
CompanionBringup,
)
from gps_denied_onboard.components.c12_operator_tooling.config import (
C12CompanionConfig,
C12Config,
HostKeyPolicy,
)
from gps_denied_onboard.components.c12_operator_tooling.errors import (
CompanionUnreachableError,
ContentHashMismatchError,
)
from gps_denied_onboard.components.c12_operator_tooling.exit_codes import (
EXIT_BUILD_FAILURE,
EXIT_COMPANION_UNREACHABLE,
EXIT_CONTENT_HASH_MISMATCH,
EXIT_DOWNLOAD_FAILURE,
EXIT_EMPTY_WAYPOINTS,
EXIT_FLIGHT_NOT_FOUND,
EXIT_FLIGHT_SCHEMA,
EXIT_FLIGHT_STATE_NOT_CONFIRMED,
EXIT_FLIGHTS_API_AUTH,
EXIT_FLIGHTS_API_UNREACHABLE,
EXIT_GCS_LINK_ERROR,
EXIT_GENERIC_ERROR,
EXIT_LOCK_HELD,
EXIT_OK,
EXIT_UPLOAD_FAILURE,
EXIT_USAGE,
)
from gps_denied_onboard.components.c12_operator_tooling.flights_api.errors import (
EmptyWaypointsError,
FlightDto,
FlightFileNotFoundError,
FlightNotFoundError,
FlightsApiAuthError,
FlightsApiClient,
FlightsApiError,
FlightsApiSchemaError,
FlightsApiUnreachableError,
HttpxFlightsApiClient,
WaypointSchemaError,
)
from gps_denied_onboard.components.c12_operator_tooling.flights_api.file_loader import (
load_flight_file,
)
from gps_denied_onboard.components.c12_operator_tooling.flights_api.interface import (
FlightDto,
FlightsApiClient,
WaypointDto,
WaypointObjective,
WaypointSchemaError,
WaypointSource,
bbox_from_waypoints,
load_flight_file,
takeoff_origin_from_flight,
)
from gps_denied_onboard.components.c12_operator_tooling.freshness_table import (
FRESHNESS_TABLE,
freshness_threshold_months,
)
from gps_denied_onboard.components.c12_operator_tooling.interface import (
CacheBuildWorkflow,
OperatorReLocService,
)
from gps_denied_onboard.components.c12_operator_tooling.remote_sidecar_verifier import (
RemoteSidecarResult,
RemoteSidecarVerifier,
)
from gps_denied_onboard.components.c12_operator_tooling.sector_classification_store import (
SectorClassificationStore,
)
from gps_denied_onboard.components.c12_operator_tooling.ssh_session import (
RemoteCommandResult,
SshSession,
SshSessionFactory,
)
from gps_denied_onboard.config.schema import register_component_block
if TYPE_CHECKING:
from gps_denied_onboard.components.c12_operator_tooling.flights_api.bbox import (
bbox_from_waypoints,
takeoff_origin_from_flight,
)
from gps_denied_onboard.components.c12_operator_tooling.flights_api.httpx_client import (
HttpxFlightsApiClient,
)
from gps_denied_onboard.components.c12_operator_tooling.paramiko_ssh_session import (
ParamikoSshSession,
ParamikoSshSessionFactory,
)
register_component_block("c12_operator_tooling", C12Config)
# ---------------------------------------------------------------------------
# PEP 562 lazy re-exports for heavy adapters
#
# Why lazy: ``bbox_from_waypoints`` and ``takeoff_origin_from_flight``
# pull in ``numpy`` + ``pyproj`` (≈300 ms cold start);
# ``HttpxFlightsApiClient`` pulls in ``httpx`` (≈85 ms);
# ``ParamikoSshSession[Factory]`` pulls in ``paramiko`` + ``cryptography``
# (≈130 ms). Eagerly loading any of these from the package
# ``__init__.py`` blows AZ-326 NFR-perf-cold-start (≤500 ms p99).
# ---------------------------------------------------------------------------
_LAZY_NAMES: dict[str, tuple[str, str]] = {
"HttpxFlightsApiClient": (
"gps_denied_onboard.components.c12_operator_tooling.flights_api.httpx_client",
"HttpxFlightsApiClient",
),
"ParamikoSshSession": (
"gps_denied_onboard.components.c12_operator_tooling.paramiko_ssh_session",
"ParamikoSshSession",
),
"ParamikoSshSessionFactory": (
"gps_denied_onboard.components.c12_operator_tooling.paramiko_ssh_session",
"ParamikoSshSessionFactory",
),
"bbox_from_waypoints": (
"gps_denied_onboard.components.c12_operator_tooling.flights_api.bbox",
"bbox_from_waypoints",
),
"takeoff_origin_from_flight": (
"gps_denied_onboard.components.c12_operator_tooling.flights_api.bbox",
"takeoff_origin_from_flight",
),
}
def __getattr__(name: str) -> Any:
target = _LAZY_NAMES.get(name)
if target is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
module_path, attr = target
import importlib
module = importlib.import_module(module_path)
value = getattr(module, attr)
globals()[name] = value
return value
__all__ = [
"EXIT_BUILD_FAILURE",
"EXIT_COMPANION_UNREACHABLE",
"EXIT_CONTENT_HASH_MISMATCH",
"EXIT_DOWNLOAD_FAILURE",
"EXIT_EMPTY_WAYPOINTS",
"EXIT_FLIGHTS_API_AUTH",
"EXIT_FLIGHTS_API_UNREACHABLE",
"EXIT_FLIGHT_NOT_FOUND",
"EXIT_FLIGHT_SCHEMA",
"EXIT_FLIGHT_STATE_NOT_CONFIRMED",
"EXIT_GCS_LINK_ERROR",
"EXIT_GENERIC_ERROR",
"EXIT_LOCK_HELD",
"EXIT_OK",
"EXIT_UPLOAD_FAILURE",
"EXIT_USAGE",
"FRESHNESS_TABLE",
"AreaIdentifier",
"C12CompanionConfig",
"C12Config",
"CacheBuildWorkflow",
"CompanionAddress",
"CompanionBringup",
"CompanionUnreachableError",
"CompanionUnreachableReason",
"ContentHashMismatchError",
"EmptyWaypointsError",
"FlightDto",
"FlightFileNotFoundError",
@@ -35,13 +209,26 @@ __all__ = [
"FlightsApiError",
"FlightsApiSchemaError",
"FlightsApiUnreachableError",
"HostKeyPolicy",
"HttpxFlightsApiClient",
"OperatorReLocService",
"ParamikoSshSession",
"ParamikoSshSessionFactory",
"ReadinessOutcome",
"ReadinessReport",
"RemoteCommandResult",
"RemoteSidecarResult",
"RemoteSidecarVerifier",
"SectorClassification",
"SectorClassificationStore",
"SshSession",
"SshSessionFactory",
"WaypointDto",
"WaypointObjective",
"WaypointSchemaError",
"WaypointSource",
"bbox_from_waypoints",
"freshness_threshold_months",
"load_flight_file",
"takeoff_origin_from_flight",
]
@@ -0,0 +1,14 @@
"""Module entry point for ``python -m gps_denied_onboard.components.c12_operator_tooling``.
The console script declared in ``pyproject.toml`` (``operator-tool``)
points at :func:`cli.main` directly; this module is the convenience
entry for ``python -m ...`` invocations during development and for
operators who prefer the explicit form.
"""
from __future__ import annotations
from gps_denied_onboard.components.c12_operator_tooling.cli import main
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,85 @@
"""C12 operator-tooling shared DTOs / enums (AZ-326, AZ-327).
``SectorClassification`` is declared locally — c12 must not import the
c6 / c10 / c11 enums (AZ-507 / module-layout cross-component rule); the
composition root maps this enum to the consumer-side enum at the write
boundary by ``.value`` round-trip.
``CompanionAddress`` and ``ReadinessReport`` are AZ-327's externally
visible DTOs returned by ``CompanionBringup.verify_companion_ready``.
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
__all__ = [
"AreaIdentifier",
"CompanionAddress",
"CompanionUnreachableReason",
"ReadinessOutcome",
"ReadinessReport",
"SectorClassification",
]
# Stable-string identifier the operator types into ``set-sector --area``
# (AZ-326 AC-4 / AC-10). Plain ``str`` is sufficient — no GUID surface in
# this cycle per description.md § 1.
AreaIdentifier = str
class SectorClassification(str, Enum):
"""Operator-set classification of a geographic sector (AZ-326).
Mirrors the c6 enum at the c12 boundary so the operator-tool never
imports ``components.c6_tile_cache``. The string values are
identical so the composition root can round-trip via ``.value``.
"""
ACTIVE_CONFLICT = "active_conflict"
STABLE_REAR = "stable_rear"
class ReadinessOutcome(str, Enum):
"""Top-level outcome flag returned in :class:`ReadinessReport` (AZ-327)."""
READY = "ready"
NOT_READY = "not_ready"
class CompanionUnreachableReason(str, Enum):
"""SSH-session-open failure category (AZ-327).
Drives the per-reason ``remediation`` hint on
:class:`~gps_denied_onboard.components.c12_operator_tooling.errors.CompanionUnreachableError`.
"""
CONNECT_REFUSED = "connect_refused"
AUTH_FAILED = "auth_failed"
HOST_KEY_MISMATCH = "host_key_mismatch"
TIMEOUT = "timeout"
OTHER = "other"
@dataclass(frozen=True, slots=True)
class CompanionAddress:
"""Operator-supplied SSH endpoint for the airborne companion (AZ-327)."""
host: str
port: int = 22
@dataclass(frozen=True, slots=True)
class ReadinessReport:
"""Result of :func:`CompanionBringup.verify_companion_ready` (AZ-327)."""
manifest_present: bool
content_hashes_pass: bool
engines_present: bool
calibration_present: bool
outcome: ReadinessOutcome
not_ready_reasons: tuple[str, ...]
companion_cache_root: str
engines_inspected_count: int
@@ -0,0 +1,725 @@
"""``operator-tool`` CLI shell — Click app + six subcommands (AZ-326).
The task spec calls for a Typer-based shell. Typer is not pinned by
the project (only ``click>=8.1`` is in ``pyproject.toml``); the spec's
own constraint section forbids introducing new dependencies. This
module therefore uses Click directly. The user-facing surface
(subcommand names, ``--help`` output, exit codes, log lines) matches
the spec — only the framework underneath differs.
Each subcommand is a thin shell:
1. resolve its service collaborator from a per-subcommand factory
(lazy resolution lets the heavy deps stay out of CLI cold-start —
NFR perf cold-start)
2. catch the documented exception family and map to the documented
exit code via :mod:`exit_codes`
3. write a one-line operator-friendly stderr message + an ERROR log
record on the unhappy paths
Service collaborators (``CacheBuildOrchestrator``, ``CompanionBringup``,
``HttpTileDownloader``, ``HttpTileUploader``, ``OperatorReLocService``)
land in sibling tasks; this module declares typed Protocols for each
so the CLI compiles and tests pass against fakes today, and the
production wiring just plugs the concrete service into the same
composition root method (see :mod:`runtime_root.c12_factory`).
"""
from __future__ import annotations
import logging
import sys
from collections.abc import Callable
from logging.handlers import RotatingFileHandler
from pathlib import Path
from typing import Any, NoReturn
from uuid import UUID
import click
from gps_denied_onboard.components.c12_operator_tooling._types import (
SectorClassification,
)
from gps_denied_onboard.components.c12_operator_tooling.config import (
C12Config,
)
from gps_denied_onboard.components.c12_operator_tooling.errors import (
CompanionUnreachableError,
ContentHashMismatchError,
)
from gps_denied_onboard.components.c12_operator_tooling.exit_codes import (
EXIT_BUILD_FAILURE,
EXIT_COMPANION_UNREACHABLE,
EXIT_CONTENT_HASH_MISMATCH,
EXIT_DOWNLOAD_FAILURE,
EXIT_EMPTY_WAYPOINTS,
EXIT_FLIGHT_NOT_FOUND,
EXIT_FLIGHT_SCHEMA,
EXIT_FLIGHT_STATE_NOT_CONFIRMED,
EXIT_FLIGHTS_API_AUTH,
EXIT_FLIGHTS_API_UNREACHABLE,
EXIT_GCS_LINK_ERROR,
EXIT_LOCK_HELD,
EXIT_OK,
EXIT_UPLOAD_FAILURE,
EXIT_USAGE,
)
# Import flights_api types from leaf modules — going through the
# ``flights_api`` package ``__init__.py`` would eagerly load ``bbox.py``
# which pulls in numpy / pyproj (NFR-perf-cold-start regression).
from gps_denied_onboard.components.c12_operator_tooling.flights_api.errors import (
EmptyWaypointsError,
FlightFileNotFoundError,
FlightNotFoundError,
FlightsApiAuthError,
FlightsApiSchemaError,
FlightsApiUnreachableError,
WaypointSchemaError,
)
from gps_denied_onboard.components.c12_operator_tooling.flights_api.interface import (
FlightDto,
)
from gps_denied_onboard.components.c12_operator_tooling.freshness_table import (
freshness_threshold_months,
)
from gps_denied_onboard.components.c12_operator_tooling.sector_classification_store import (
SectorClassificationStore,
)
from gps_denied_onboard.logging import JsonFormatter
__all__ = ["app", "build_app", "main"]
# Service-collaborator placeholder for sibling tasks. Each subcommand
# resolves its concrete collaborator via a factory the test injects;
# production wiring lives in runtime_root.c12_factory.OperatorToolServices.
ServiceFactory = Callable[[], Any]
# ---------------------------------------------------------------------------
# Logging wiring (E-CC-LOG / AZ-266)
# ---------------------------------------------------------------------------
_LOG_KIND_INVOKED = "c12.cli.invoked"
_LOG_KIND_OK = "c12.cli.ok"
_LOG_KIND_ERROR = "c12.cli.error"
_LOG_KIND_USAGE = "c12.cli.usage"
_CLI_LOGGER_NAME = "c12_operator_tooling.cli"
_HANDLER_MARKER = "_c12_cli_file_handler"
def _ensure_cli_logger(log_path: Path) -> logging.Logger:
"""Attach a rotating file handler at ``log_path`` to the CLI logger.
Idempotent for repeated calls with the same ``log_path``. If
``log_path`` has changed since the last call (e.g. a test invocation
overrides ``--log-path``, or an operator passes a different path on
a subsequent in-process call) prior CLI-owned handlers are removed
and replaced so the new path actually receives output.
The file handler uses :class:`JsonFormatter` so every line conforms
to the AZ-266 ``log_record_schema`` v1.0.0. A WARN-level stderr
handler is added alongside so operators see degraded readiness
without tailing the file.
"""
logger = logging.getLogger(_CLI_LOGGER_NAME)
logger.setLevel(logging.INFO)
logger.propagate = False
log_path.parent.mkdir(parents=True, exist_ok=True)
resolved_path = str(log_path.resolve()) if log_path.exists() else str(log_path)
existing_file_handler: logging.Handler | None = None
for h in logger.handlers:
marker = getattr(h, _HANDLER_MARKER, None)
if marker == "file":
existing_file_handler = h
break
if existing_file_handler is not None:
existing_path = getattr(existing_file_handler, "baseFilename", None)
if existing_path == resolved_path:
return logger
# Path changed — drop every prior CLI-owned handler before re-attach.
for h in [h for h in logger.handlers if getattr(h, _HANDLER_MARKER, None) is not None]:
logger.removeHandler(h)
try:
h.close()
except Exception:
pass
file_handler = RotatingFileHandler(
log_path,
maxBytes=5 * 1024 * 1024,
backupCount=5,
encoding="utf-8",
)
file_handler.setFormatter(JsonFormatter())
file_handler.setLevel(logging.INFO)
setattr(file_handler, _HANDLER_MARKER, "file")
logger.addHandler(file_handler)
stderr_handler = logging.StreamHandler(sys.stderr)
stderr_handler.setLevel(logging.WARNING)
stderr_handler.setFormatter(JsonFormatter())
setattr(stderr_handler, _HANDLER_MARKER, "stderr")
logger.addHandler(stderr_handler)
return logger
def _emit_invoked(
logger: logging.Logger, subcommand: str, kv: dict[str, Any] | None = None
) -> None:
logger.info(
"operator invoked subcommand",
extra={"kind": _LOG_KIND_INVOKED, "kv": {"subcommand": subcommand, **(kv or {})}},
)
def _emit_ok(logger: logging.Logger, subcommand: str, kv: dict[str, Any] | None = None) -> None:
logger.info(
"subcommand completed",
extra={"kind": _LOG_KIND_OK, "kv": {"subcommand": subcommand, **(kv or {})}},
)
def _emit_error(
logger: logging.Logger,
subcommand: str,
*,
exit_code: int,
exception: BaseException,
remediation: str,
kv: dict[str, Any] | None = None,
) -> None:
payload = {
"subcommand": subcommand,
"exit_code": exit_code,
"exception_type": type(exception).__name__,
"remediation": remediation,
}
if kv:
payload.update(kv)
logger.error(
"subcommand failed",
extra={"kind": _LOG_KIND_ERROR, "kv": payload},
)
# ---------------------------------------------------------------------------
# Exception → (exit_code, hint) mapping table
# ---------------------------------------------------------------------------
_FLIGHTS_API_HINTS: dict[type, tuple[int, str]] = {
FlightsApiUnreachableError: (
EXIT_FLIGHTS_API_UNREACHABLE,
"Flights service unreachable; retry once the network recovers or use --flight-file.",
),
FlightsApiAuthError: (
EXIT_FLIGHTS_API_AUTH,
"Flights service rejected the auth token; verify the operator credential.",
),
FlightNotFoundError: (
EXIT_FLIGHT_NOT_FOUND,
"Flight ID was not found on the flights service; double-check the GUID.",
),
FlightsApiSchemaError: (
EXIT_FLIGHT_SCHEMA,
"Flight payload from the service violates the documented schema.",
),
WaypointSchemaError: (
EXIT_FLIGHT_SCHEMA,
"A waypoint inside the flight is malformed; re-plan in the Mission Planner UI.",
),
FlightFileNotFoundError: (
EXIT_FLIGHT_SCHEMA,
"--flight-file path does not exist on disk.",
),
EmptyWaypointsError: (
EXIT_EMPTY_WAYPOINTS,
"Flight has zero waypoints; re-plan in the Mission Planner UI.",
),
}
# ---------------------------------------------------------------------------
# Click app + subcommands
# ---------------------------------------------------------------------------
@click.group(
name="operator-tool",
help="GPS-denied onboard pre-flight tooling (operator workstation).",
)
@click.option(
"--log-path",
type=click.Path(dir_okay=False, path_type=Path),
default=None,
help="Override the workstation log path (defaults to ~/.azaion/onboard/c12-tooling.log).",
)
@click.pass_context
def app(ctx: click.Context, log_path: Path | None) -> None:
"""Top-level group; instantiates :class:`C12Config` + the CLI logger.
Test helpers may pre-populate ``ctx.obj`` with a dict of the form
``{"config": C12Config, "logger": Logger, "services": ...}`` to inject
fake service collaborators; in that case the callback only honours
the ``--log-path`` override and leaves the rest of the dict alone.
"""
if isinstance(ctx.obj, dict) and "config" in ctx.obj and "logger" in ctx.obj:
if log_path is not None:
existing: C12Config = ctx.obj["config"]
ctx.obj["config"] = C12Config(
log_path=log_path,
sector_classification_store_path=existing.sector_classification_store_path,
companion=existing.companion,
)
return
config = ctx.obj if isinstance(ctx.obj, C12Config) else C12Config()
if log_path is not None:
config = C12Config(
log_path=log_path,
sector_classification_store_path=config.sector_classification_store_path,
companion=config.companion,
)
ctx.obj = {
"config": config,
"logger": _ensure_cli_logger(config.log_path),
}
@app.command(
"download",
help="Download tiles for a sector. Supports AC-NEW-3 (tile fetch).",
)
@click.option("--area", required=True, help="Operator-supplied area identifier.")
@click.option("--bbox", required=True, help='Geo bbox as "min_lat,min_lon,max_lat,max_lon".')
@click.pass_context
def download(ctx: click.Context, area: str, bbox: str) -> None:
"""Delegates to ``HttpTileDownloader.fetch`` (sibling AZ-316)."""
state = ctx.obj
logger = state["logger"]
_emit_invoked(logger, "download", {"area": area, "bbox": bbox})
services = state.get("services")
if services is None or not hasattr(services, "tile_downloader_factory"):
# No service wired yet — shell stays runnable but reports the
# missing collaborator cleanly so test fakes can drive the path.
_emit_ok(logger, "download", {"note": "no tile_downloader wired (sibling AZ-316)"})
ctx.exit(EXIT_OK)
try:
downloader = services.tile_downloader_factory()
downloader.fetch(area=area, bbox=bbox)
except Exception as exc:
_handle_known_exception(
ctx,
logger,
"download",
exc,
extra_table={
# Sibling AZ-316 owns SatelliteProviderError; map by name to
# avoid an import cycle on the c11 component from c12 (the
# consumer-side cut pattern).
"SatelliteProviderError": (
EXIT_DOWNLOAD_FAILURE,
"Satellite provider rejected or failed the request.",
),
},
)
return
_emit_ok(logger, "download")
ctx.exit(EXIT_OK)
@app.command(
"build-cache",
help=(
"Build the companion-side cache from a flight (AC-8.3, AC-NEW-1, AC-NEW-6). "
"Supplies the resolved FlightDto to the orchestrator (ADR-010)."
),
)
@click.option(
"--flight-id",
type=str,
default=None,
help="UUID of the flight to fetch from the parent-suite flights service.",
)
@click.option(
"--flight-file",
type=click.Path(exists=False, dir_okay=False, path_type=Path),
default=None,
help="Local JSON export of the flight (offline path).",
)
@click.option(
"--sector-class",
type=click.Choice([e.value for e in SectorClassification], case_sensitive=False),
required=True,
help="Operator-set classification driving the AC-NEW-6 freshness budget.",
)
@click.option(
"--calibration-path",
type=click.Path(exists=False, dir_okay=False, path_type=Path),
required=True,
help="Path to the camera calibration JSON to upload alongside the cache.",
)
@click.pass_context
def build_cache(
ctx: click.Context,
flight_id: str | None,
flight_file: Path | None,
sector_class: str,
calibration_path: Path,
) -> None:
"""Orchestrate the F1 cache build (sibling AZ-328)."""
state = ctx.obj
logger = state["logger"]
_emit_invoked(
logger,
"build-cache",
{
"flight_id": flight_id,
"flight_file": str(flight_file) if flight_file else None,
"sector_class": sector_class,
},
)
if flight_id and flight_file:
_exit_with_usage(
ctx,
logger,
"build-cache",
"--flight-id and --flight-file are mutually exclusive; supply exactly one.",
)
if not flight_id and not flight_file:
_exit_with_usage(
ctx,
logger,
"build-cache",
"Supply exactly one of --flight-id or --flight-file.",
)
services = state.get("services")
if services is None or not hasattr(services, "flights_api_client"):
_emit_ok(
logger,
"build-cache",
{"note": "no flights_api_client wired (composition-root pending)"},
)
ctx.exit(EXIT_OK)
sector_class_enum = SectorClassification(sector_class.lower())
months = freshness_threshold_months(sector_class_enum)
try:
flight = _resolve_flight(services, flight_id=flight_id, flight_file=flight_file)
orchestrator = services.build_cache_orchestrator
orchestrator.build_cache(
flight=flight,
sector_class=sector_class_enum,
freshness_months=months,
calibration_path=calibration_path,
)
except Exception as exc:
_handle_known_exception(
ctx,
logger,
"build-cache",
exc,
extra_table={
"BuildLockHeldError": (
EXIT_LOCK_HELD,
"Another build-cache run holds the lock; wait for it to finish.",
),
"CacheBuildError": (
EXIT_BUILD_FAILURE,
"Cache build failed; consult the orchestrator's structured log.",
),
},
)
return
_emit_ok(logger, "build-cache", {"flight_id": str(flight.flight_id)})
ctx.exit(EXIT_OK)
@app.command(
"upload-pending",
help="Trigger post-landing upload of pending tiles (AC-NEW-7).",
)
@click.pass_context
def upload_pending(ctx: click.Context) -> None:
"""Delegates to ``post_landing_upload.trigger_post_landing_upload`` (AZ-329)."""
state = ctx.obj
logger = state["logger"]
_emit_invoked(logger, "upload-pending")
services = state.get("services")
if services is None or not hasattr(services, "post_landing_upload"):
_emit_ok(
logger,
"upload-pending",
{"note": "no post_landing_upload wired (sibling AZ-329)"},
)
ctx.exit(EXIT_OK)
try:
services.post_landing_upload.trigger_post_landing_upload()
except Exception as exc:
_handle_known_exception(
ctx,
logger,
"upload-pending",
exc,
extra_table={
"FlightStateNotConfirmedError": (
EXIT_FLIGHT_STATE_NOT_CONFIRMED,
"Flight state has not been confirmed yet; retry after landing is logged.",
),
"UploadGateBlockedError": (
EXIT_UPLOAD_FAILURE,
"Upload gate blocked the request; consult c11 logs for details.",
),
},
)
return
_emit_ok(logger, "upload-pending")
ctx.exit(EXIT_OK)
@app.command(
"reloc-confirm",
help="Request operator-driven re-localization via GCS (AC-3.4, AC-7.3).",
)
@click.option("--hint", default="", help="Optional textual hint forwarded to the GCS link.")
@click.pass_context
def reloc_confirm(ctx: click.Context, hint: str) -> None:
"""Delegates to ``operator_reloc_service.request_relocalization`` (AZ-330)."""
state = ctx.obj
logger = state["logger"]
_emit_invoked(logger, "reloc-confirm", {"hint": hint})
services = state.get("services")
if services is None or not hasattr(services, "operator_reloc_service"):
_emit_ok(
logger,
"reloc-confirm",
{"note": "no operator_reloc_service wired (sibling AZ-330)"},
)
ctx.exit(EXIT_OK)
try:
services.operator_reloc_service.request_relocalization(hint=hint)
except Exception as exc:
_handle_known_exception(
ctx,
logger,
"reloc-confirm",
exc,
extra_table={
"GcsLinkError": (
EXIT_GCS_LINK_ERROR,
"GCS link unavailable; check pymavlink connectivity and signing key.",
),
},
)
return
_emit_ok(logger, "reloc-confirm")
ctx.exit(EXIT_OK)
@app.command(
"verify-ready",
help="Verify the companion has all four pre-flight artifacts ready (AC-NEW-1).",
)
@click.option("--host", required=True, help="Companion hostname or IP.")
@click.option("--port", default=22, type=int, help="Companion SSH port.")
@click.pass_context
def verify_ready(ctx: click.Context, host: str, port: int) -> None:
"""Delegates to :class:`CompanionBringup.verify_companion_ready` (AZ-327)."""
from gps_denied_onboard.components.c12_operator_tooling._types import (
CompanionAddress,
)
state = ctx.obj
logger = state["logger"]
_emit_invoked(logger, "verify-ready", {"host": host, "port": port})
services = state.get("services")
if services is None or not hasattr(services, "companion_bringup"):
_emit_ok(
logger,
"verify-ready",
{"note": "no companion_bringup wired"},
)
ctx.exit(EXIT_OK)
address = CompanionAddress(host=host, port=port)
try:
services.companion_bringup.verify_companion_ready(address)
except CompanionUnreachableError as exc:
_emit_error(
logger,
"verify-ready",
exit_code=EXIT_COMPANION_UNREACHABLE,
exception=exc,
remediation=exc.remediation,
kv={"host": host, "port": port, "reason": exc.reason.value},
)
click.echo(f"companion unreachable: {exc.remediation}", err=True)
ctx.exit(EXIT_COMPANION_UNREACHABLE)
except ContentHashMismatchError as exc:
_emit_error(
logger,
"verify-ready",
exit_code=EXIT_CONTENT_HASH_MISMATCH,
exception=exc,
remediation=exc.remediation,
kv={"engine_path": exc.engine_path},
)
click.echo(f"content hash mismatch: {exc.remediation}", err=True)
ctx.exit(EXIT_CONTENT_HASH_MISMATCH)
_emit_ok(logger, "verify-ready")
ctx.exit(EXIT_OK)
@app.command(
"set-sector",
help="Persist a sector classification for an area (drives AC-NEW-6 freshness).",
)
@click.option("--area", required=True, help="Operator-supplied area identifier.")
@click.option(
"--sector-class",
type=click.Choice([e.value for e in SectorClassification], case_sensitive=False),
required=True,
help="Sector classification: active_conflict | stable_rear.",
)
@click.pass_context
def set_sector(ctx: click.Context, area: str, sector_class: str) -> None:
"""Delegates to :class:`SectorClassificationStore.set_classification`."""
state = ctx.obj
config: C12Config = state["config"]
logger = state["logger"]
_emit_invoked(logger, "set-sector", {"area": area, "sector_class": sector_class})
sector_class_enum = SectorClassification(sector_class.lower())
services = state.get("services")
if services is not None and hasattr(services, "sector_classification_store"):
store: SectorClassificationStore = services.sector_classification_store
else:
store = SectorClassificationStore(
store_path=config.sector_classification_store_path,
logger=logger,
)
store.set_classification(area, sector_class_enum)
_emit_ok(logger, "set-sector", {"area": area, "sector_class": sector_class})
ctx.exit(EXIT_OK)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _exit_with_usage(
ctx: click.Context,
logger: logging.Logger,
subcommand: str,
message: str,
) -> NoReturn:
logger.warning(
"subcommand usage error",
extra={
"kind": _LOG_KIND_USAGE,
"kv": {"subcommand": subcommand, "message": message},
},
)
click.echo(f"usage: {message}", err=True)
ctx.exit(EXIT_USAGE)
raise AssertionError("unreachable") # pragma: no cover
def _handle_known_exception(
ctx: click.Context,
logger: logging.Logger,
subcommand: str,
exc: BaseException,
*,
extra_table: dict[str, tuple[int, str]] | None = None,
) -> NoReturn:
"""Map ``exc`` to its documented exit code; ERROR-log; stderr; ``ctx.exit``."""
table: dict[type | str, tuple[int, str]] = dict(_FLIGHTS_API_HINTS)
if extra_table is not None:
# Allow string-keyed entries for sibling-task exception families
# we cannot import without crossing component boundaries.
for key, value in extra_table.items():
table[key] = value
exit_code: int = 1
remediation = "Inspect the exception and the structured log for details."
matched: bool = False
for cls in type(exc).__mro__:
# Class-key match first (preferred — exact import).
mapped = table.get(cls)
if mapped is not None:
exit_code, remediation = mapped
matched = True
break
name_mapped = table.get(cls.__name__)
if name_mapped is not None:
exit_code, remediation = name_mapped
matched = True
break
if not matched:
# Genuine unknown — re-raise so the user sees a stack trace they
# can attach to a bug report.
raise exc
_emit_error(
logger,
subcommand,
exit_code=exit_code,
exception=exc,
remediation=remediation,
)
click.echo(f"{type(exc).__name__}: {remediation}", err=True)
ctx.exit(exit_code)
raise AssertionError("unreachable") # pragma: no cover
def _resolve_flight(
services: Any,
*,
flight_id: str | None,
flight_file: Path | None,
) -> FlightDto:
"""Resolve the operator's flight via the flights API or the offline file."""
client = services.flights_api_client
if flight_id is not None:
flight_uuid = UUID(flight_id)
return client.fetch_flight(
flight_id=flight_uuid,
base_url=getattr(services, "flights_api_base_url", ""),
auth_token=getattr(services, "flights_api_auth_token", ""),
)
assert flight_file is not None # narrowed by the mutually-exclusive gate
return client.load_flight_file(path=flight_file)
# ---------------------------------------------------------------------------
# Console-script entry point
# ---------------------------------------------------------------------------
def build_app() -> click.Group:
"""Return the top-level Click group (kept callable for tests)."""
return app
def main(argv: list[str] | None = None) -> int:
"""Console-script entry point used by ``[project.scripts]``."""
try:
return app(args=argv, standalone_mode=False) or EXIT_OK
except click.exceptions.Exit as exit_exc:
return exit_exc.exit_code
except click.UsageError as usage_exc:
click.echo(f"usage: {usage_exc.format_message()}", err=True)
return EXIT_USAGE
except SystemExit as sys_exc: # pragma: no cover
return int(sys_exc.code) if isinstance(sys_exc.code, int) else EXIT_OK
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,241 @@
"""``CompanionBringup`` — operator-side pre-flight verification (AZ-327).
Public surface is one method:
:meth:`CompanionBringup.verify_companion_ready`. The flow is sequential
(not parallel) so log lines correlate cleanly with the four checks and
the SSH transport stays simple. Method ordering matches the spec:
1. open SSH session — translate paramiko/socket errors into
:class:`CompanionUnreachableError` (handled inside the factory)
2. ``manifest_present`` — SFTP stat against ``Manifest.json``
3. ``engines_present`` — SFTP listdir against ``engines/`` and
set-compare against ``config.expected_engines``
4. ``content_hashes_pass`` — :class:`RemoteSidecarVerifier` over
every present-and-expected engine; first mismatch raises
:class:`ContentHashMismatchError`
5. ``calibration_present`` — SFTP stat against
``camera_calibration.json``
6. compute outcome (``ready`` iff all four booleans True)
7. emit a single INFO / WARN / ERROR log line per outcome
8. return :class:`ReadinessReport`
The session is closed in a ``try/finally`` block on every code path
including the unhappy ones (AC-7).
"""
from __future__ import annotations
import logging
from pathlib import PurePosixPath
from gps_denied_onboard.components.c12_operator_tooling._types import (
CompanionAddress,
ReadinessOutcome,
ReadinessReport,
)
from gps_denied_onboard.components.c12_operator_tooling.config import (
C12CompanionConfig,
)
from gps_denied_onboard.components.c12_operator_tooling.errors import (
ContentHashMismatchError,
)
from gps_denied_onboard.components.c12_operator_tooling.remote_sidecar_verifier import (
RemoteSidecarVerifier,
)
from gps_denied_onboard.components.c12_operator_tooling.ssh_session import (
SshSession,
SshSessionFactory,
)
__all__ = ["CompanionBringup"]
_LOG_KIND_READY = "c12.companion.ready"
_LOG_KIND_DEGRADED = "c12.companion.degraded"
_LOG_KIND_HASH_MISMATCH = "c12.companion.hash.mismatch"
_LOG_KIND_UNREACHABLE = "c12.companion.unreachable"
class CompanionBringup:
"""Verifies the companion has all four pre-flight artifacts ready."""
def __init__(
self,
*,
ssh_factory: SshSessionFactory,
sidecar_verifier: RemoteSidecarVerifier,
logger: logging.Logger,
config: C12CompanionConfig,
) -> None:
self._ssh_factory = ssh_factory
self._sidecar_verifier = sidecar_verifier
self._logger = logger
self._config = config
def verify_companion_ready(self, companion_address: CompanionAddress) -> ReadinessReport:
"""Open SSH, run the four checks, return a structured report.
Raises :class:`CompanionUnreachableError` (from the factory) on
SSH session-open failure. Raises
:class:`ContentHashMismatchError` on the first sidecar mismatch.
"""
try:
session = self._ssh_factory.open(
companion_address,
timeout_s=self._config.connect_timeout_s,
)
except Exception:
# The factory translates paramiko/socket errors into
# CompanionUnreachableError before raising; we just emit the
# ERROR log here so all unreachable failures share one log
# site (AC-4 / AC-5 / AC-6 / AC-8).
self._logger.error(
"companion ssh unreachable",
extra={
"kind": _LOG_KIND_UNREACHABLE,
"kv": {
"host": companion_address.host,
"port": companion_address.port,
"connect_timeout_s": self._config.connect_timeout_s,
},
},
)
raise
try:
return self._run_checks_and_log(session, companion_address)
finally:
session.close()
def _run_checks_and_log(
self,
session: SshSession,
companion_address: CompanionAddress,
) -> ReadinessReport:
cache_root = self._config.companion_cache_root
not_ready_reasons: list[str] = []
manifest_present = session.file_exists(cache_root / self._config.manifest_filename)
if not manifest_present:
not_ready_reasons.append(f"manifest_missing: {self._config.manifest_filename}")
engines_present, listed_engines = self._check_engines_present(
session, cache_root, not_ready_reasons
)
content_hashes_pass, engines_inspected = self._check_content_hashes(
session, cache_root, listed_engines
)
calibration_present = session.file_exists(cache_root / self._config.calibration_filename)
if not calibration_present:
not_ready_reasons.append(f"calibration_missing: {self._config.calibration_filename}")
outcome = (
ReadinessOutcome.READY
if (
manifest_present and content_hashes_pass and engines_present and calibration_present
)
else ReadinessOutcome.NOT_READY
)
report = ReadinessReport(
manifest_present=manifest_present,
content_hashes_pass=content_hashes_pass,
engines_present=engines_present,
calibration_present=calibration_present,
outcome=outcome,
not_ready_reasons=tuple(not_ready_reasons),
companion_cache_root=str(cache_root),
engines_inspected_count=engines_inspected,
)
self._emit_outcome_log(report, companion_address)
return report
def _check_engines_present(
self,
session: SshSession,
cache_root: PurePosixPath,
not_ready_reasons: list[str],
) -> tuple[bool, set[str]]:
engines_dir = cache_root / "engines"
try:
listed_engines = set(session.list_dir(engines_dir))
except FileNotFoundError:
not_ready_reasons.append(f"engines_dir_missing: {engines_dir}")
return False, set()
except OSError as exc:
not_ready_reasons.append(f"engines_dir_unreadable: {exc!r}")
return False, set()
expected = set(self._config.expected_engines)
if not expected:
not_ready_reasons.append("expected_engines list empty in caller-supplied config")
return False, listed_engines
missing = expected - listed_engines
if missing:
not_ready_reasons.append(f"engines_missing: {','.join(sorted(missing))}")
return False, listed_engines
return True, listed_engines
def _check_content_hashes(
self,
session: SshSession,
cache_root: PurePosixPath,
listed_engines: set[str],
) -> tuple[bool, int]:
engines_dir = cache_root / "engines"
# Only inspect engines that are both expected AND present — missing
# engines are already flagged in not_ready_reasons by
# _check_engines_present and do NOT trigger a sidecar verify (AC-9).
expected_present = sorted(set(self._config.expected_engines) & listed_engines)
if not expected_present:
return False, 0
for engine_name in expected_present:
engine_path = engines_dir / engine_name
result = self._sidecar_verifier.verify(session, engine_path)
if not result.matches:
self._logger.error(
"engine sidecar mismatch on companion",
extra={
"kind": _LOG_KIND_HASH_MISMATCH,
"kv": {
"engine_path": str(engine_path),
"expected_sha256_hex": result.expected_hex,
"actual_sha256_hex": result.actual_hex,
},
},
)
raise ContentHashMismatchError(
engine_path=str(engine_path),
expected_sha256_hex=result.expected_hex,
actual_sha256_hex=result.actual_hex,
)
return True, len(expected_present)
def _emit_outcome_log(
self,
report: ReadinessReport,
companion_address: CompanionAddress,
) -> None:
kv = {
"host": companion_address.host,
"port": companion_address.port,
"manifest_present": report.manifest_present,
"content_hashes_pass": report.content_hashes_pass,
"engines_present": report.engines_present,
"calibration_present": report.calibration_present,
"outcome": report.outcome.value,
"engines_inspected_count": report.engines_inspected_count,
"not_ready_reasons": list(report.not_ready_reasons),
}
if report.outcome is ReadinessOutcome.READY:
self._logger.info("companion ready", extra={"kind": _LOG_KIND_READY, "kv": kv})
else:
self._logger.warning(
"companion not ready",
extra={"kind": _LOG_KIND_DEGRADED, "kv": kv},
)
@@ -0,0 +1,117 @@
"""C12 operator-tooling config block (AZ-326, AZ-327).
Registered into ``config.components['c12_operator_tooling']`` by the
package ``__init__.py``. Two composition-root factories read this
block:
* :func:`gps_denied_onboard.runtime_root.c12_factory.build_operator_tool`
reads the workstation-side service knobs (log path, sector
classification store path).
* :func:`gps_denied_onboard.runtime_root.c12_factory.build_companion_bringup`
reads the ``companion_*`` block to drive AZ-327's SSH-based
pre-flight verification.
All defaults are conservative; the only field that has no real default
is ``companion_ssh_keyfile`` (the operator's SSH private key path).
The factory raises :class:`ConfigError` when the keyfile is empty in
operator wiring; tests that do not exercise C12's SSH path keep
working without YAML by injecting a fake ``SshSessionFactory``.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path, PurePosixPath
from gps_denied_onboard.config.schema import ConfigError
__all__ = [
"C12CompanionConfig",
"C12Config",
"HostKeyPolicy",
]
class HostKeyPolicy(str, Enum):
"""SSH host-key policy supported by :class:`ParamikoSshSessionFactory`.
The deliberately-omitted ``auto_add_unknown`` value would defeat the
security model — see AZ-327 task spec "Constraints" section.
"""
STRICT = "strict"
KNOWN_HOSTS = "known_hosts"
REJECT_NEW = "reject_new"
# Defaults match description.md § 9 ("workstation home directory layout").
_DEFAULT_LOG_PATH = Path("~/.azaion/onboard/c12-tooling.log").expanduser()
_DEFAULT_SECTOR_STORE_PATH = Path("~/.azaion/onboard/sector-classifications.json").expanduser()
_DEFAULT_COMPANION_CACHE_ROOT = PurePosixPath("/var/lib/azaion/c10/cache")
_DEFAULT_CONNECT_TIMEOUT_S = 10.0
_DEFAULT_SHA256SUM_TIMEOUT_S = 60.0
@dataclass(frozen=True)
class C12CompanionConfig:
"""Companion-side SSH knobs consumed by :class:`CompanionBringup` (AZ-327).
``expected_engines`` defaults to an empty tuple so unit fixtures
that do not need to assert on a specific engine list can keep
working; the production factory passes the operator-supplied list
through from the Manifest. AC-2 explicitly fails-clean when the
list is empty.
"""
ssh_user: str = "azaion"
ssh_keyfile: Path = Path()
host_key_policy: HostKeyPolicy = HostKeyPolicy.STRICT
connect_timeout_s: float = _DEFAULT_CONNECT_TIMEOUT_S
companion_cache_root: PurePosixPath = _DEFAULT_COMPANION_CACHE_ROOT
manifest_filename: str = "Manifest.json"
calibration_filename: str = "camera_calibration.json"
expected_engines: tuple[str, ...] = ()
sha256sum_timeout_s: float = _DEFAULT_SHA256SUM_TIMEOUT_S
def __post_init__(self) -> None:
if self.connect_timeout_s <= 0:
raise ConfigError(
f"C12CompanionConfig.connect_timeout_s must be > 0; got {self.connect_timeout_s}"
)
if self.sha256sum_timeout_s <= 0:
raise ConfigError(
"C12CompanionConfig.sha256sum_timeout_s must be > 0; "
f"got {self.sha256sum_timeout_s}"
)
if not isinstance(self.host_key_policy, HostKeyPolicy):
raise ConfigError(
"C12CompanionConfig.host_key_policy must be a HostKeyPolicy; "
f"got {type(self.host_key_policy).__name__}"
)
@dataclass(frozen=True)
class C12Config:
"""Per-component config for C12 operator tooling.
* ``log_path`` — workstation-side rotating log file fed by the
AZ-266 :class:`JsonFormatter`. Defaults to
``~/.azaion/onboard/c12-tooling.log``.
* ``sector_classification_store_path`` — JSON file holding
``{area_id: sector_class}`` mappings persisted by
:class:`SectorClassificationStore`. Defaults to
``~/.azaion/onboard/sector-classifications.json``.
* ``companion`` — nested AZ-327 SSH config block.
"""
log_path: Path = _DEFAULT_LOG_PATH
sector_classification_store_path: Path = _DEFAULT_SECTOR_STORE_PATH
companion: C12CompanionConfig = field(default_factory=C12CompanionConfig)
def __post_init__(self) -> None:
if not isinstance(self.companion, C12CompanionConfig):
raise ConfigError(
"C12Config.companion must be a C12CompanionConfig; got "
f"{type(self.companion).__name__}"
)
@@ -0,0 +1,127 @@
"""C12 ``CompanionBringup`` error hierarchy (AZ-327).
Two failure modes own dedicated exit codes in
:mod:`gps_denied_onboard.components.c12_operator_tooling.exit_codes`:
* :class:`CompanionUnreachableError` — SSH session-open failure.
Mapped 1:1 from the underlying paramiko / socket exception via the
:class:`CompanionUnreachableReason` enum so the operator-friendly
``remediation`` hint can vary per category.
* :class:`ContentHashMismatchError` — sidecar hex digest does NOT
match the engine's actual SHA-256 on the companion. Distinct from
"engine missing" (which is a not-ready signal returned in the
:class:`ReadinessReport`, not an exception).
Both errors expose a ``remediation`` property the
:func:`gps_denied_onboard.components.c12_operator_tooling.cli.main`
layer reads to print a one-line operator-friendly hint to stderr.
The flights-API errors (AZ-489) deliberately do NOT carry a
``remediation`` attribute — the CLI maps each to its exit code and a
hard-coded hint string in :mod:`cli`. Adding ``remediation`` to AZ-489's
errors would expand AZ-489's surface mid-cycle; we keep that scope
discipline by keeping the hint table in c12.
"""
from __future__ import annotations
from gps_denied_onboard.components.c12_operator_tooling._types import (
CompanionUnreachableReason,
)
__all__ = [
"CompanionUnreachableError",
"ContentHashMismatchError",
]
_UNREACHABLE_REMEDIATIONS: dict[CompanionUnreachableReason, str] = {
CompanionUnreachableReason.CONNECT_REFUSED: (
"Check companion power, USB/Ethernet cable, and `config.c12.companion_address`."
),
CompanionUnreachableReason.AUTH_FAILED: (
"Verify `config.c12.companion_ssh_keyfile` matches the public key "
"in `~/.ssh/authorized_keys` on the companion."
),
CompanionUnreachableReason.HOST_KEY_MISMATCH: (
"Inspect `~/.ssh/known_hosts`; if the companion was reflashed, "
"remove its old entry; otherwise treat as a security incident."
),
CompanionUnreachableReason.TIMEOUT: (
"Companion did not respond within "
"`config.c12.companion_connect_timeout_s`; check network reachability "
"and the companion's sshd status."
),
CompanionUnreachableReason.OTHER: (
"Inspect the underlying exception (`underlying_exception_repr`) and "
"consult the SSH session log for details."
),
}
# Override for ``host_key_policy=reject_new`` first-connect (AC-10) — the
# fix is to ssh-keyscan the companion before retrying, not to clean
# `~/.ssh/known_hosts`.
_REJECT_NEW_REMEDIATION: str = (
"Add the companion to `~/.ssh/known_hosts` first via a manual `ssh-keyscan`, then retry."
)
class CompanionUnreachableError(Exception):
"""SSH session-open against the companion failed (AZ-327)."""
def __init__(
self,
*,
host: str,
port: int,
reason: CompanionUnreachableReason,
underlying_exception_repr: str,
reject_new_first_connect: bool = False,
) -> None:
super().__init__(
f"companion ssh unreachable: host={host} port={port} reason={reason.value}: "
f"{underlying_exception_repr}"
)
self.host = host
self.port = port
self.reason = reason
self.underlying_exception_repr = underlying_exception_repr
self._reject_new_first_connect = reject_new_first_connect
@property
def remediation(self) -> str:
"""One-line operator-friendly hint per :class:`CompanionUnreachableReason`."""
if self._reject_new_first_connect:
return _REJECT_NEW_REMEDIATION
return _UNREACHABLE_REMEDIATIONS[self.reason]
class ContentHashMismatchError(Exception):
"""Engine sidecar hex digest does NOT match the engine's actual SHA-256 (AZ-327).
Distinct from "engine missing", which is reported as an
``engines_present=False`` flag inside :class:`ReadinessReport` and
does NOT raise.
"""
def __init__(
self,
*,
engine_path: str,
expected_sha256_hex: str,
actual_sha256_hex: str,
) -> None:
super().__init__(
f"engine sidecar mismatch: path={engine_path} "
f"expected={expected_sha256_hex[:8]}... actual={actual_sha256_hex[:8]}..."
)
self.engine_path = engine_path
self.expected_sha256_hex = expected_sha256_hex
self.actual_sha256_hex = actual_sha256_hex
@property
def remediation(self) -> str:
return (
"Re-run the cache build (`operator-tool build-cache --flight-id ...`) "
"to repopulate the affected engine."
)
@@ -0,0 +1,73 @@
"""Exit-code constants for the ``operator-tool`` console script (AZ-326).
The CLI shell maps each documented service-collaborator exception family
to a specific exit code so operator scripts can branch on ``$?``. The
constants below are the canonical source of truth — sibling tasks that
add new exception families MUST extend this module rather than coining
fresh integers inline.
Reserved range allocation (per AZ-326 task spec):
* ``0`` — success
* ``1`` — generic / unclassified error fallthrough
* ``2`` — Click-style usage error (mutually-exclusive flag conflict, etc.)
* ``10..19`` — companion bring-up / verification (AZ-327)
* ``20..29`` — cache build (download + provisioning errors, AZ-328)
* ``30..39`` — post-landing upload gate (AZ-329)
* ``40..49`` — operator re-localization (GCS link, AZ-330)
* ``50..59`` — local locking / concurrency
* ``60..69`` — flights API (AZ-489 errors surfaced by AZ-326)
"""
from __future__ import annotations
from typing import Final
__all__ = [
"EXIT_BUILD_FAILURE",
"EXIT_COMPANION_UNREACHABLE",
"EXIT_CONTENT_HASH_MISMATCH",
"EXIT_DOWNLOAD_FAILURE",
"EXIT_EMPTY_WAYPOINTS",
"EXIT_FLIGHTS_API_AUTH",
"EXIT_FLIGHTS_API_UNREACHABLE",
"EXIT_FLIGHT_NOT_FOUND",
"EXIT_FLIGHT_SCHEMA",
"EXIT_FLIGHT_STATE_NOT_CONFIRMED",
"EXIT_GCS_LINK_ERROR",
"EXIT_GENERIC_ERROR",
"EXIT_LOCK_HELD",
"EXIT_OK",
"EXIT_UPLOAD_FAILURE",
"EXIT_USAGE",
]
EXIT_OK: Final[int] = 0
EXIT_GENERIC_ERROR: Final[int] = 1
EXIT_USAGE: Final[int] = 2
# Companion bring-up (AZ-327)
EXIT_COMPANION_UNREACHABLE: Final[int] = 10
EXIT_CONTENT_HASH_MISMATCH: Final[int] = 11
# Cache build (AZ-328)
EXIT_DOWNLOAD_FAILURE: Final[int] = 20
EXIT_BUILD_FAILURE: Final[int] = 21
# Post-landing upload (AZ-329)
EXIT_FLIGHT_STATE_NOT_CONFIRMED: Final[int] = 30
EXIT_UPLOAD_FAILURE: Final[int] = 31
# Operator re-localization (AZ-330)
EXIT_GCS_LINK_ERROR: Final[int] = 40
# Concurrency (AZ-328 build lock)
EXIT_LOCK_HELD: Final[int] = 50
# Flights API (AZ-489)
EXIT_FLIGHTS_API_UNREACHABLE: Final[int] = 60
EXIT_FLIGHTS_API_AUTH: Final[int] = 61
EXIT_FLIGHT_NOT_FOUND: Final[int] = 62
EXIT_FLIGHT_SCHEMA: Final[int] = 63
EXIT_EMPTY_WAYPOINTS: Final[int] = 64
@@ -20,14 +20,19 @@ Two sources produce the same DTO shape:
Public surface is frozen by
``_docs/02_document/contracts/c12_operator_tooling/flights_api_client.md``
v1.0.0.
NOTE on lazy imports (AZ-326 NFR-perf-cold-start): :class:`HttpxFlightsApiClient`
pulls in ``httpx`` (≈85 ms). It is exposed via PEP 562
:func:`__getattr__` so callers that do
``from ...flights_api import HttpxFlightsApiClient`` still work, but the
``httpx`` import only fires on first access. Modules that need only the
schema (DTOs, errors, helpers) avoid loading ``httpx`` entirely.
"""
from __future__ import annotations
from gps_denied_onboard.components.c12_operator_tooling.flights_api.bbox import (
bbox_from_waypoints,
takeoff_origin_from_flight,
)
from typing import TYPE_CHECKING, Any
from gps_denied_onboard.components.c12_operator_tooling.flights_api.errors import (
EmptyWaypointsError,
FlightFileNotFoundError,
@@ -41,9 +46,6 @@ from gps_denied_onboard.components.c12_operator_tooling.flights_api.errors impor
from gps_denied_onboard.components.c12_operator_tooling.flights_api.file_loader import (
load_flight_file,
)
from gps_denied_onboard.components.c12_operator_tooling.flights_api.httpx_client import (
HttpxFlightsApiClient,
)
from gps_denied_onboard.components.c12_operator_tooling.flights_api.interface import (
FlightDto,
FlightsApiClient,
@@ -52,6 +54,45 @@ from gps_denied_onboard.components.c12_operator_tooling.flights_api.interface im
WaypointSource,
)
if TYPE_CHECKING:
from gps_denied_onboard.components.c12_operator_tooling.flights_api.bbox import (
bbox_from_waypoints,
takeoff_origin_from_flight,
)
from gps_denied_onboard.components.c12_operator_tooling.flights_api.httpx_client import (
HttpxFlightsApiClient,
)
_LAZY_NAMES: dict[str, tuple[str, str]] = {
"HttpxFlightsApiClient": (
"gps_denied_onboard.components.c12_operator_tooling.flights_api.httpx_client",
"HttpxFlightsApiClient",
),
"bbox_from_waypoints": (
"gps_denied_onboard.components.c12_operator_tooling.flights_api.bbox",
"bbox_from_waypoints",
),
"takeoff_origin_from_flight": (
"gps_denied_onboard.components.c12_operator_tooling.flights_api.bbox",
"takeoff_origin_from_flight",
),
}
def __getattr__(name: str) -> Any:
target = _LAZY_NAMES.get(name)
if target is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
module_path, attr = target
import importlib
module = importlib.import_module(module_path)
value = getattr(module, attr)
globals()[name] = value
return value
__all__ = [
"EmptyWaypointsError",
"FlightDto",
@@ -0,0 +1,40 @@
"""C12 freshness-budget lookup table (AZ-326).
AC-NEW-6 fixes the per-classification tile-freshness budget that the
operator's CLI applies when scheduling cache builds. The table is a
pure-data lookup — no I/O, no logging, no logger needed — so sibling
tasks (T3 build-orchestrator and the C6 freshness gate that reads
sector classifications at write time) share a single canonical source
rather than each inventing their own months mapping.
"""
from __future__ import annotations
from typing import Final
from gps_denied_onboard.components.c12_operator_tooling._types import (
SectorClassification,
)
__all__ = ["FRESHNESS_TABLE", "freshness_threshold_months"]
FRESHNESS_TABLE: Final[dict[SectorClassification, int]] = {
SectorClassification.ACTIVE_CONFLICT: 1,
SectorClassification.STABLE_REAR: 12,
}
def freshness_threshold_months(sector_class: SectorClassification) -> int:
"""Return the AC-NEW-6 freshness budget in whole months.
``active_conflict`` → 1 month (forces frequent re-pulls in fluid
front-line areas). ``stable_rear`` → 12 months (rear-area imagery
drifts slowly; aggressive re-pulls would waste operator bandwidth).
"""
try:
return FRESHNESS_TABLE[sector_class]
except KeyError as exc:
raise ValueError(
f"freshness_threshold_months: unknown SectorClassification {sector_class!r}"
) from exc
@@ -0,0 +1,228 @@
"""Concrete :class:`SshSessionFactory` over paramiko (AZ-327).
Translates this component's :class:`HostKeyPolicy` enum into the
paramiko ``MissingHostKeyPolicy`` subclass it ships:
* ``strict`` → :class:`paramiko.RejectPolicy` (reject any unknown host)
* ``known_hosts`` → :class:`paramiko.RejectPolicy` plus an explicit
``load_system_host_keys()`` so only entries already in
``~/.ssh/known_hosts`` are accepted
* ``reject_new`` → :class:`paramiko.RejectPolicy` (functionally
identical to ``strict`` but the operator-friendly remediation hint
on :class:`CompanionUnreachableError` differs — see
``errors._REJECT_NEW_REMEDIATION``)
The deliberately-omitted ``auto_add_unknown`` option would map to
``paramiko.AutoAddPolicy`` and is forbidden — see AZ-327 Constraints.
"""
from __future__ import annotations
from pathlib import Path, PurePosixPath
from typing import Final
import paramiko
from gps_denied_onboard.components.c12_operator_tooling._types import (
CompanionAddress,
CompanionUnreachableReason,
)
from gps_denied_onboard.components.c12_operator_tooling.config import (
HostKeyPolicy,
)
from gps_denied_onboard.components.c12_operator_tooling.errors import (
CompanionUnreachableError,
)
from gps_denied_onboard.components.c12_operator_tooling.ssh_session import (
RemoteCommandResult,
SshSession,
SshSessionFactory,
)
__all__ = [
"ParamikoSshSession",
"ParamikoSshSessionFactory",
]
_DEFAULT_BANNER_TIMEOUT_S: Final[float] = 15.0
class ParamikoSshSession(SshSession):
""":class:`SshSession` backed by a single :class:`paramiko.SSHClient`.
Wraps SFTP for stat/list operations to avoid spawning a remote
``ls`` subprocess for every directory probe (which would conflate
"directory missing" and "permission denied" on the parsed text).
"""
def __init__(self, client: paramiko.SSHClient) -> None:
self._client = client
self._sftp: paramiko.SFTPClient | None = None
self._closed = False
def run(self, command: str, *, timeout_s: float) -> RemoteCommandResult:
if self._closed:
raise RuntimeError("SSH session is already closed")
try:
stdin, stdout, stderr = self._client.exec_command(command, timeout=timeout_s)
stdin.close()
stdout_bytes = stdout.read()
stderr_bytes = stderr.read()
exit_code = stdout.channel.recv_exit_status()
except TimeoutError as exc:
raise TimeoutError(f"ssh command timed out after {timeout_s}s: {command!r}") from exc
return RemoteCommandResult(
exit_code=exit_code,
stdout=stdout_bytes.decode("utf-8", errors="replace"),
stderr=stderr_bytes.decode("utf-8", errors="replace"),
)
def file_exists(self, remote_path: PurePosixPath) -> bool:
if self._closed:
raise RuntimeError("SSH session is already closed")
sftp = self._ensure_sftp()
try:
sftp.stat(str(remote_path))
return True
except FileNotFoundError:
return False
def list_dir(self, remote_path: PurePosixPath) -> list[str]:
if self._closed:
raise RuntimeError("SSH session is already closed")
sftp = self._ensure_sftp()
return list(sftp.listdir(str(remote_path)))
def close(self) -> None:
if self._closed:
return
self._closed = True
if self._sftp is not None:
try:
self._sftp.close()
except Exception:
# Closing a half-broken SFTP must not mask the original
# exception that brought us here; surface but do not raise.
pass
try:
self._client.close()
except Exception:
pass
def _ensure_sftp(self) -> paramiko.SFTPClient:
if self._sftp is None:
self._sftp = self._client.open_sftp()
return self._sftp
class ParamikoSshSessionFactory(SshSessionFactory):
"""Production :class:`SshSessionFactory` (paramiko-backed).
The factory holds the operator's per-session config (user, keyfile,
host-key policy, banner timeout); the per-call ``timeout_s``
parameter governs only the TCP / SSH-handshake phase.
"""
def __init__(
self,
*,
ssh_user: str,
ssh_keyfile: Path,
host_key_policy: HostKeyPolicy,
banner_timeout_s: float = _DEFAULT_BANNER_TIMEOUT_S,
) -> None:
self._ssh_user = ssh_user
self._ssh_keyfile = ssh_keyfile
self._host_key_policy = host_key_policy
self._banner_timeout_s = banner_timeout_s
def open(
self,
address: CompanionAddress,
*,
timeout_s: float,
) -> SshSession:
client = paramiko.SSHClient()
try:
self._configure_host_keys(client)
client.connect(
hostname=address.host,
port=address.port,
username=self._ssh_user,
key_filename=str(self._ssh_keyfile),
timeout=timeout_s,
banner_timeout=self._banner_timeout_s,
auth_timeout=timeout_s,
allow_agent=False,
look_for_keys=False,
)
except paramiko.AuthenticationException as exc:
client.close()
raise CompanionUnreachableError(
host=address.host,
port=address.port,
reason=CompanionUnreachableReason.AUTH_FAILED,
underlying_exception_repr=repr(exc),
) from exc
except paramiko.BadHostKeyException as exc:
client.close()
raise CompanionUnreachableError(
host=address.host,
port=address.port,
reason=CompanionUnreachableReason.HOST_KEY_MISMATCH,
underlying_exception_repr=repr(exc),
reject_new_first_connect=(self._host_key_policy is HostKeyPolicy.REJECT_NEW),
) from exc
except paramiko.SSHException as exc:
# paramiko raises SSHException for "Server <host> not found in
# known_hosts" (RejectPolicy hit). Treat as host_key_mismatch so
# the operator gets the right remediation hint.
client.close()
message = str(exc).lower()
if "known_hosts" in message or "no host key" in message:
raise CompanionUnreachableError(
host=address.host,
port=address.port,
reason=CompanionUnreachableReason.HOST_KEY_MISMATCH,
underlying_exception_repr=repr(exc),
reject_new_first_connect=(self._host_key_policy is HostKeyPolicy.REJECT_NEW),
) from exc
raise CompanionUnreachableError(
host=address.host,
port=address.port,
reason=CompanionUnreachableReason.OTHER,
underlying_exception_repr=repr(exc),
) from exc
except TimeoutError as exc:
client.close()
raise CompanionUnreachableError(
host=address.host,
port=address.port,
reason=CompanionUnreachableReason.TIMEOUT,
underlying_exception_repr=repr(exc),
) from exc
except (ConnectionRefusedError, OSError) as exc:
client.close()
raise CompanionUnreachableError(
host=address.host,
port=address.port,
reason=CompanionUnreachableReason.CONNECT_REFUSED,
underlying_exception_repr=repr(exc),
) from exc
return ParamikoSshSession(client)
def _configure_host_keys(self, client: paramiko.SSHClient) -> None:
# Strict and reject_new both reject any host whose key is not already
# known; the difference is only the operator-facing remediation hint.
client.set_missing_host_key_policy(paramiko.RejectPolicy())
if self._host_key_policy is HostKeyPolicy.KNOWN_HOSTS:
client.load_system_host_keys()
elif self._host_key_policy in (HostKeyPolicy.STRICT, HostKeyPolicy.REJECT_NEW):
# Same as known_hosts but we still want the system key set as the
# source of truth so a previously-trusted host validates.
client.load_system_host_keys()
else:
# Defensive — HostKeyPolicy is a closed enum; new variants would
# surface here at startup before any wire traffic.
raise ValueError(f"unsupported HostKeyPolicy: {self._host_key_policy!r}")
@@ -0,0 +1,102 @@
"""Remote SHA-256 sidecar verifier (AZ-327).
Runs ``sha256sum <engine_path>`` on the companion via SSH, parses the
hex digest, then ``cat <engine_path>.sha256`` on the same session and
parses that hex digest, and compares the two case-insensitively.
Pulling the engine bytes back to the workstation to recompute the
hash locally would defeat the purpose — engines run multi-hundred MB
each and the operator's link to the companion is often a single
USB-Ethernet adapter. Doing the hash on the companion keeps the
readiness check well under one minute end-to-end (NFR perf for AZ-327).
Returns a dataclass instead of raising so :class:`CompanionBringup`
can decide whether to convert into :class:`ContentHashMismatchError`
(it does, on the first failure — see AZ-327 AC-3).
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import PurePosixPath
from typing import Final
from gps_denied_onboard.components.c12_operator_tooling.ssh_session import (
SshSession,
)
__all__ = ["RemoteSidecarResult", "RemoteSidecarVerifier"]
_HEX64 = re.compile(r"^[0-9a-fA-F]{64}$")
_DEFAULT_TIMEOUT_S: Final[float] = 60.0
@dataclass(frozen=True, slots=True)
class RemoteSidecarResult:
"""Captured outcome of a single remote sidecar verification."""
matches: bool
expected_hex: str
actual_hex: str
class RemoteSidecarVerifier:
"""Compare the companion-side ``sha256sum`` against the sidecar's hex digest."""
def __init__(self, *, timeout_s: float = _DEFAULT_TIMEOUT_S) -> None:
if timeout_s <= 0:
raise ValueError(f"RemoteSidecarVerifier.timeout_s must be > 0; got {timeout_s}")
self._timeout_s = timeout_s
def verify(
self,
session: SshSession,
engine_path: PurePosixPath,
) -> RemoteSidecarResult:
"""Return ``matches=True`` when the sidecar agrees with the engine's actual hash."""
actual_hex = self._sha256sum_remote(session, engine_path)
expected_hex = self._read_sidecar_hex(session, engine_path)
return RemoteSidecarResult(
matches=actual_hex.lower() == expected_hex.lower(),
expected_hex=expected_hex,
actual_hex=actual_hex,
)
def _sha256sum_remote(self, session: SshSession, engine_path: PurePosixPath) -> str:
# Quote the path so a stray space cannot break the parse; we trust
# the path is operator-owned (no shell injection surface) but the
# quoting is cheap defence-in-depth.
command = f"sha256sum -- '{engine_path!s}'"
try:
result = session.run(command, timeout_s=self._timeout_s)
except TimeoutError:
return f"<timeout after {self._timeout_s}s>"
if result.exit_code != 0:
return f"<exit_code={result.exit_code}: {result.stderr.strip()[:80]}>"
# `sha256sum` prints `<hex> <path>`; we want the first whitespace
# token. Anything else is a parse failure surfaced as a synthetic
# marker so the comparison fails cleanly.
head = result.stdout.strip().split()
if not head or not _HEX64.match(head[0]):
return f"<parse_failure: {result.stdout!r}>"
return head[0]
def _read_sidecar_hex(self, session: SshSession, engine_path: PurePosixPath) -> str:
sidecar_path = PurePosixPath(str(engine_path) + ".sha256")
command = f"cat -- '{sidecar_path!s}'"
try:
result = session.run(command, timeout_s=self._timeout_s)
except TimeoutError:
return f"<sidecar_timeout after {self._timeout_s}s>"
if result.exit_code != 0:
return f"<sidecar_exit_code={result.exit_code}: {result.stderr.strip()[:80]}>"
# Sidecar format (per AZ-280 helpers.sha256_sidecar): one of
# `<hex>\n`
# `<hex> <relative_path>\n`
# We accept either by splitting on the first whitespace token.
head = result.stdout.strip().split()
if not head or not _HEX64.match(head[0]):
return f"<sidecar_parse_failure: {result.stdout!r}>"
return head[0]
@@ -0,0 +1,184 @@
"""Persistent ``{area_id: SectorClassification}`` store (AZ-326).
Atomic-write JSON file kept in the operator's home directory so a
restart of ``operator-tool`` recovers every classification the operator
ever ran ``set-sector`` against. The atomic-write pattern uses
``tempfile.NamedTemporaryFile(dir=...) + os.replace(...)`` per AC-5;
see :mod:`gps_denied_onboard.helpers.sha256_sidecar` for the heavier
SHA-256-sidecar variant of the same idea (AZ-280 / D-C10-3).
The store is a tiny stateless reader/writer — no in-memory caching;
every read goes back to disk. Operators rarely set thousands of
classifications, and going to disk keeps multi-process consistency
trivial (the only other potential writer is a future GUI which is
out of scope per description.md § 7).
"""
from __future__ import annotations
import json
import logging
import os
import tempfile
from pathlib import Path
from gps_denied_onboard.components.c12_operator_tooling._types import (
AreaIdentifier,
SectorClassification,
)
__all__ = ["SectorClassificationStore"]
_LOG_KIND_SET = "c12.sector.classification.set"
class SectorClassificationStore:
"""JSON-backed persistent map of area → :class:`SectorClassification`.
File format::
{"area_id_1": "active_conflict", "area_id_2": "stable_rear", ...}
The parent directory is created with mode ``0o700`` if missing
(AZ-326 NFR Reliability). ``set_classification`` is atomic across
process kill (AC-5): the write goes to a sibling tempfile and then
``os.replace`` swaps it in.
"""
def __init__(self, *, store_path: Path, logger: logging.Logger) -> None:
self._store_path = store_path
self._logger = logger
def set_classification(
self,
area: AreaIdentifier,
sector_class: SectorClassification,
) -> None:
"""Persist ``{area: sector_class}`` to disk; INFO log on success.
Idempotent for the same input (AC-10): the on-disk JSON file is
byte-identical when called twice with the same arguments
because the dict is sorted at serialisation time.
"""
self._ensure_parent_dir()
existing = self._read_all_or_empty()
existing[area] = sector_class.value
payload_bytes = self._serialise(existing)
self._atomic_write(payload_bytes)
self._logger.info(
"operator set sector classification",
extra={
"kind": _LOG_KIND_SET,
"kv": {
"area": area,
"sector_class": sector_class.value,
"store_path": str(self._store_path),
},
},
)
def get_classification(self, area: AreaIdentifier) -> SectorClassification | None:
"""Return the persisted classification for ``area`` or ``None`` if unset."""
existing = self._read_all_or_empty()
raw = existing.get(area)
if raw is None:
return None
try:
return SectorClassification(raw)
except ValueError:
return None
def list_classifications(
self,
) -> dict[AreaIdentifier, SectorClassification]:
"""Return every persisted classification as an in-memory dict."""
existing = self._read_all_or_empty()
result: dict[AreaIdentifier, SectorClassification] = {}
for area, raw in existing.items():
try:
result[area] = SectorClassification(raw)
except ValueError:
continue
return result
def _ensure_parent_dir(self) -> None:
parent = self._store_path.parent
if not parent.exists():
parent.mkdir(parents=True, exist_ok=True)
try:
os.chmod(parent, 0o700)
except OSError:
# Directory exists but we cannot tighten permissions — surface
# via WARN rather than fail; the operator will see it in the
# log and can fix mode 0o700 manually if security is critical.
self._logger.warning(
"could not chmod sector classifications dir to 0o700",
extra={"kind": "c12.sector.dir.chmod_failed", "kv": {"path": str(parent)}},
)
def _read_all_or_empty(self) -> dict[str, str]:
if not self._store_path.exists():
return {}
with self._store_path.open("r", encoding="utf-8") as fh:
try:
content = json.load(fh)
except json.JSONDecodeError:
# Corrupt file is non-fatal: the next set_classification rewrites
# it. We log a WARN so operators know they lost prior data.
self._logger.warning(
"sector classifications file is corrupt; will overwrite on next set",
extra={
"kind": "c12.sector.store.corrupt",
"kv": {"path": str(self._store_path)},
},
)
return {}
if not isinstance(content, dict):
self._logger.warning(
"sector classifications file is not a JSON object; will overwrite",
extra={
"kind": "c12.sector.store.shape",
"kv": {"path": str(self._store_path)},
},
)
return {}
return {str(k): str(v) for k, v in content.items()}
@staticmethod
def _serialise(payload: dict[str, str]) -> bytes:
# Sort keys so AC-10 (idempotent re-run) produces byte-identical output.
return json.dumps(payload, indent=2, sort_keys=True).encode("utf-8") + b"\n"
def _atomic_write(self, payload_bytes: bytes) -> None:
parent = self._store_path.parent
# NamedTemporaryFile in the same directory so os.replace is atomic
# on POSIX and Windows (NTFS); cross-device replace would fall back
# to copy+unlink which is NOT atomic.
tmp = tempfile.NamedTemporaryFile(
mode="wb",
dir=parent,
prefix=".sector-classifications.",
suffix=".tmp",
delete=False,
)
try:
try:
tmp.write(payload_bytes)
tmp.flush()
os.fsync(tmp.fileno())
finally:
tmp.close()
os.replace(tmp.name, self._store_path)
except Exception:
# Best-effort cleanup of the tempfile if replace failed before
# rename (or write itself raised). Re-raise so the caller sees
# the original failure cleanly.
try:
os.unlink(tmp.name)
except FileNotFoundError:
pass
raise
@@ -0,0 +1,88 @@
"""``SshSession`` + ``SshSessionFactory`` Protocols (AZ-327).
The Protocol-first split lets unit tests inject a fake session without
monkey-patching paramiko. Tests construct a synthetic ``SshSession``
whose ``run`` / ``file_exists`` / ``list_dir`` are scripted; the real
:class:`ParamikoSshSessionFactory` lives behind the same surface and
is exercised only by the (paramiko-marked) integration tests.
The Protocol surface is intentionally tiny — just the operations
:class:`CompanionBringup` actually needs. Adding a method here is a
contract change that touches every fake in the test suite.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import PurePosixPath
from typing import Protocol, runtime_checkable
from gps_denied_onboard.components.c12_operator_tooling._types import (
CompanionAddress,
)
__all__ = [
"RemoteCommandResult",
"SshSession",
"SshSessionFactory",
]
@dataclass(frozen=True, slots=True)
class RemoteCommandResult:
"""Captured outcome of an :meth:`SshSession.run` invocation.
``stdout`` / ``stderr`` are decoded to ``str`` (UTF-8) by the
concrete session implementation; binary command output is not
expected on this surface (we only run ``sha256sum`` and ``cat``).
"""
exit_code: int
stdout: str
stderr: str
@runtime_checkable
class SshSession(Protocol):
"""Open SSH session against the companion (AZ-327)."""
def run(self, command: str, *, timeout_s: float) -> RemoteCommandResult:
"""Run ``command`` synchronously, blocking until it completes or times out.
Raises ``TimeoutError`` if ``timeout_s`` elapses; the underlying
transport remains usable for subsequent calls.
"""
...
def file_exists(self, remote_path: PurePosixPath) -> bool:
"""Return ``True`` iff ``remote_path`` is a regular file or a directory."""
...
def list_dir(self, remote_path: PurePosixPath) -> list[str]:
"""Return the names of the entries inside ``remote_path``.
Raises ``OSError`` if the directory does not exist or cannot be
listed (callers translate to a not-ready signal).
"""
...
def close(self) -> None:
"""Close the transport. Safe to call multiple times."""
...
@runtime_checkable
class SshSessionFactory(Protocol):
"""Open a fresh :class:`SshSession` against a :class:`CompanionAddress`.
Sessions are NOT cached — every
:func:`CompanionBringup.verify_companion_ready` call opens and closes
a fresh session (see AZ-327 Constraints).
"""
def open(
self,
address: CompanionAddress,
*,
timeout_s: float,
) -> SshSession: ...
@@ -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