Files
gps-denied-onboard/src/gps_denied_onboard/config/loader.py
T
Oleksandr Bezdieniezhnykh 1e0be08e8a [AZ-393] [AZ-394] [AZ-395] C8 outbound chain + AP MAVLink2 signing
AZ-393 ArduPilot outbound: PymavlinkArdupilotAdapter encodes
EstimatorOutput to MAVLink2 GPS_INPUT via gps_input_send; emits
NAMED_VALUE_FLOAT(name="src_lbl") every frame and STATUSTEXT on
source_label transition (1 Hz per-severity cap). Smoothed-output
guard (Invariant 6), single-writer thread (Invariant 8), SPD
propagation. Shared helper _outbound_provenance.py owns the
canonical source-label-to-float table + transition rate-limiter.

AZ-394 iNav outbound: Msp2InavAdapter encodes EstimatorOutput to
hand-rolled MSP2_SENSOR_GPS (0x1F03, 52-byte LE payload via
_msp2_sensor_gps_encoder.py + YAMSPy send_RAW_msg). Secondary
unsigned MAVLink channel for STATUSTEXT transitions. open()
rejects non-None signing_key (RESTRICT-COMM-2 / Invariant 2);
request_source_set_switch raises SourceSetSwitchNotSupportedError
(Invariant 9 verified: never calls setup_signing on secondary).

AZ-395 AP MAVLink2 signing: ephemeral per-flight 32-byte key
from secrets.token_bytes; pymavlink setup_signing handshake at
open(); in-place bytearray zeroisation on close(); mid-flight
signing-failure detection (ERROR log + WARNING STATUSTEXT + no
raise; threshold configurable). Key never logged / persisted /
serialised (regex-scanned by AC-4/AC-5). BUILD_DEV_STATIC_KEY=ON
enables repeatable static-key dev path; rejected at open() when
the build flag is absent.

Shared: EstimatorOutput.smoothed (default False) added for the
Invariant 6 gate at the C8 boundary; FcConfig extended with
dev_static_signing_key + signing_failure_threshold (additive
defaults; cross-field validation in __post_init__).

Tests: 33 new AC tests (11 + 11 + 11) covering all 30 ACs; full
suite 476 passing / 2 skipped / 0 failing (was 443). Contract
surfaces unchanged at fc_adapter_protocol v1.0.0 and
composition_root v1.2.0.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 04:47:44 +03:00

210 lines
7.0 KiB
Python

"""`load_config` — the single entrypoint that materialises `Config` at startup.
Implements the `composition_root_protocol` contract v1.0.0 (E-CC-CONF /
AZ-269 / AZ-246). Precedence (highest -> lowest):
1. Environment variables (``env`` argument).
2. YAML files (``paths``), in order — later paths override earlier ones.
3. Documented defaults baked into the cross-cutting dataclasses.
The returned `Config` is frozen end-to-end. Required env vars that fail
to resolve raise `RequiredFieldMissingError` with the name of the
offending variable and a pointer at ``.env.example``.
"""
from __future__ import annotations
from collections.abc import Mapping, Sequence
from pathlib import Path
from typing import Any, Final
import yaml
from gps_denied_onboard.config.schema import (
_COMPONENT_REGISTRY,
Config,
FcConfig,
FdrConfig,
GcsConfig,
LogConfig,
RequiredFieldMissingError,
RuntimeConfig,
_replace_block,
_resolve_component_blocks,
)
__all__ = ["ENV_KEY_MAP", "load_config"]
# Env-var -> (block, field) mapping. The composition root reads env vars
# through this table so the YAML path and the env path stay in sync.
ENV_KEY_MAP: Final[dict[str, tuple[str, str]]] = {
# Cross-cutting blocks
"GPS_DENIED_FC_PROFILE": ("runtime", "fc_profile"),
"GPS_DENIED_TIER": ("runtime", "tier"),
"DB_URL": ("runtime", "db_url"),
"CAMERA_CALIBRATION_PATH": ("runtime", "camera_calibration_path"),
"INFERENCE_BACKEND": ("runtime", "inference_backend"),
"TILE_CACHE_PATH": ("runtime", "tile_cache_path"),
"LOG_LEVEL": ("log", "level"),
"LOG_TIER": ("log", "tier"),
"LOG_SINK": ("log", "sink"),
"FDR_PATH": ("fdr", "path"),
"FDR_QUEUE_SIZE": ("fdr", "queue_size"),
# C8 FC + GCS adapter blocks (AZ-390)
"FC_ADAPTER": ("fc", "adapter"),
"FC_PORT_DEVICE": ("fc", "port_device"),
"FC_PORT_BAUD": ("fc", "port_baud"),
"FC_SIGNING_KEY_SOURCE": ("fc", "signing_key_source"),
"FC_DEV_STATIC_SIGNING_KEY": ("fc", "dev_static_signing_key"),
"FC_SIGNING_FAILURE_THRESHOLD": ("fc", "signing_failure_threshold"),
"GCS_ADAPTER": ("gcs", "adapter"),
"GCS_PORT_DEVICE": ("gcs", "port_device"),
"GCS_PORT_BAUD": ("gcs", "port_baud"),
"GCS_SUMMARY_RATE_HZ": ("gcs", "summary_rate_hz"),
}
# Env vars that MUST resolve to a non-empty value before `load_config`
# can return (per AZ-263 AC-8 + AZ-269 AC-6). Missing values trigger
# `RequiredFieldMissingError` with the variable name in the message.
_REQUIRED_ENV_VARS: Final[tuple[str, ...]] = (
"GPS_DENIED_FC_PROFILE",
"GPS_DENIED_TIER",
"DB_URL",
"CAMERA_CALIBRATION_PATH",
"LOG_LEVEL",
"LOG_SINK",
"INFERENCE_BACKEND",
"FDR_PATH",
"TILE_CACHE_PATH",
)
# Field-name -> python type. We coerce string env vars + raw YAML scalars
# into the dataclass's declared types so `Config.runtime.tier` is always
# `int` regardless of source.
_FIELD_COERCIONS: Final[dict[str, type]] = {
"tier": int,
"queue_size": int,
"level": str,
"sink": str,
"path": str,
"fc_profile": str,
"db_url": str,
"camera_calibration_path": str,
"inference_backend": str,
"tile_cache_path": str,
"overrun_policy": str,
# C8 FC + GCS adapter coercions (AZ-390)
"adapter": str,
"port_device": str,
"port_baud": int,
"signing_key_source": str,
"dev_static_signing_key": str,
"signing_failure_threshold": int,
"summary_rate_hz": float,
}
def _coerce_value(field_name: str, raw: Any) -> Any:
target_type = _FIELD_COERCIONS.get(field_name)
if target_type is None or isinstance(raw, target_type):
return raw
try:
return target_type(raw)
except (TypeError, ValueError) as exc:
raise RequiredFieldMissingError(
f"config field {field_name!r}: cannot coerce {raw!r} to {target_type.__name__} ({exc})"
) from exc
def _load_yaml_files(paths: Sequence[Path]) -> dict[str, dict[str, Any]]:
"""Merge YAML files in order: later paths win for the same block + field."""
merged: dict[str, dict[str, Any]] = {}
for path in paths:
data = yaml.safe_load(path.read_text()) or {}
if not isinstance(data, dict):
raise RequiredFieldMissingError(
f"YAML at {path} must be a mapping at the top level; got {type(data).__name__}"
)
for block_name, block_value in data.items():
if not isinstance(block_value, dict):
continue
merged.setdefault(block_name, {}).update(block_value)
return merged
def _apply_env_overrides(layered: dict[str, dict[str, Any]], env: Mapping[str, str]) -> None:
"""Overlay env-var values on the per-block override dictionaries."""
for env_key, (block_name, field_name) in ENV_KEY_MAP.items():
if env_key not in env:
continue
layered.setdefault(block_name, {})[field_name] = env[env_key]
def _check_required_env(env: Mapping[str, str]) -> None:
"""AC-6 + AZ-263 AC-8: missing required vars fail fast with a pointer."""
missing = [name for name in _REQUIRED_ENV_VARS if not env.get(name)]
if missing:
raise RequiredFieldMissingError(
"Missing required environment variable(s): "
+ ", ".join(missing)
+ ". See `.env.example` for the documented set."
)
def load_config(
env: Mapping[str, str],
paths: Sequence[Path] = (),
*,
require_env: bool = True,
) -> Config:
"""Build a frozen `Config` from env + YAML files + documented defaults.
Precedence: env > YAML > defaults. `paths` may be empty; missing keys
fall to the dataclass-declared defaults.
"""
if require_env:
_check_required_env(env)
yaml_overrides = _load_yaml_files(paths) if paths else {}
_apply_env_overrides(yaml_overrides, env)
runtime_block = _replace_block(
RuntimeConfig(),
{k: _coerce_value(k, v) for k, v in yaml_overrides.get("runtime", {}).items()},
)
log_block = _replace_block(
LogConfig(),
{k: _coerce_value(k, v) for k, v in yaml_overrides.get("log", {}).items()},
)
fdr_block = _replace_block(
FdrConfig(),
{k: _coerce_value(k, v) for k, v in yaml_overrides.get("fdr", {}).items()},
)
fc_block = _replace_block(
FcConfig(),
{k: _coerce_value(k, v) for k, v in yaml_overrides.get("fc", {}).items()},
)
gcs_block = _replace_block(
GcsConfig(),
{k: _coerce_value(k, v) for k, v in yaml_overrides.get("gcs", {}).items()},
)
component_blocks = _resolve_component_blocks()
for slug, dataclass_type in _COMPONENT_REGISTRY.items():
block_overrides = yaml_overrides.get(slug, {})
if block_overrides:
component_blocks[slug] = _replace_block(
dataclass_type(),
{k: _coerce_value(k, v) for k, v in block_overrides.items()},
)
return Config(
runtime=runtime_block,
log=log_block,
fdr=fdr_block,
fc=fc_block,
gcs=gcs_block,
components=component_blocks,
)