"""Configuration loading for the CI orchestrator. Resolution order (later wins): 1. OS-aware defaults (Windows: F:\\CI\\..., Linux: /var/lib/ci/...). 2. Optional ``config.toml`` file (path via ``CI_CONFIG`` env var or ``$CI_ROOT/config.toml`` if present). 3. Environment variables (``CI_ROOT``, ``CI_TEMPLATES``, ``CI_BUILD_VMS``, ``CI_ARTIFACTS``, ``CI_KEYS``). Phase C hook: a ``[backend]`` section in ``config.toml`` selects the VM backend implementation; the field is read but currently only the ``workstation`` value is supported. """ from __future__ import annotations import os import tomllib from dataclasses import dataclass, field from pathlib import Path from typing import Any _ENV_KEYS = ("CI_ROOT", "CI_TEMPLATES", "CI_BUILD_VMS", "CI_ARTIFACTS", "CI_KEYS") @dataclass(frozen=True) class Paths: """Filesystem paths used by the orchestrator.""" root: Path templates: Path build_vms: Path artifacts: Path keys: Path @dataclass(frozen=True) class BackendConfig: """Backend selector. ``type`` is currently always 'workstation'.""" type: str = "workstation" options: dict[str, Any] = field(default_factory=dict) @dataclass(frozen=True) class Config: """Top-level configuration object.""" paths: Paths backend: BackendConfig vmrun_path: str | None = None ssh_key_path: Path | None = None guest_cred_target: str = "BuildVMGuest" def _platform_defaults() -> Paths: root = Path(r"F:\CI") if os.name == "nt" else Path("/var/lib/ci") return Paths( root=root, templates=root / "Templates" if os.name == "nt" else root / "templates", build_vms=root / "BuildVMs" if os.name == "nt" else root / "build-vms", artifacts=root / "Artifacts" if os.name == "nt" else root / "artifacts", keys=root / "keys" if os.name == "nt" else Path("/etc/ci/keys"), ) def _load_toml(path: Path) -> dict[str, Any]: with path.open("rb") as fh: return tomllib.load(fh) def _merge_paths(base: Paths, overrides: dict[str, Any]) -> Paths: def pick(key: str, current: Path) -> Path: val = overrides.get(key) return Path(val) if val else current return Paths( root=pick("root", base.root), templates=pick("templates", base.templates), build_vms=pick("build_vms", base.build_vms), artifacts=pick("artifacts", base.artifacts), keys=pick("keys", base.keys), ) def _apply_env(paths: Paths, env: dict[str, str]) -> Paths: mapping = { "CI_ROOT": "root", "CI_TEMPLATES": "templates", "CI_BUILD_VMS": "build_vms", "CI_ARTIFACTS": "artifacts", "CI_KEYS": "keys", } overrides: dict[str, Any] = {} for env_key, attr in mapping.items(): val = env.get(env_key) if val: overrides[attr] = val return _merge_paths(paths, overrides) def load_config( config_path: Path | None = None, env: dict[str, str] | None = None, ) -> Config: """Build a :class:`Config` from defaults, optional TOML file, and env vars. Parameters ---------- config_path: Explicit path to a TOML file. If ``None``, looks at ``$CI_CONFIG`` then ``$CI_ROOT/config.toml``. env: Environment dict (defaults to :data:`os.environ`). Injected for tests. """ if env is None: env = dict(os.environ) paths = _platform_defaults() # Resolve config file toml_path: Path | None = None if config_path is not None: toml_path = config_path elif env.get("CI_CONFIG"): toml_path = Path(env["CI_CONFIG"]) else: candidate = paths.root / "config.toml" if candidate.is_file(): toml_path = candidate backend = BackendConfig() vmrun_path: str | None = env.get("CI_VMRUN_PATH") ssh_key_path = Path(env["CI_SSH_KEY_PATH"]) if env.get("CI_SSH_KEY_PATH") else None guest_cred_target = env.get("CI_GUEST_CRED_TARGET", "BuildVMGuest") if toml_path and toml_path.is_file(): data = _load_toml(toml_path) paths = _merge_paths(paths, data.get("paths", {})) backend_section = data.get("backend", {}) if backend_section: backend = BackendConfig( type=str(backend_section.get("type", "workstation")), options={k: v for k, v in backend_section.items() if k != "type"}, ) if vmrun_path is None: vmrun_path = data.get("vmrun_path") if ssh_key_path is None and data.get("ssh_key_path"): ssh_key_path = Path(data["ssh_key_path"]) guest_cred_target = data.get("guest_cred_target", guest_cred_target) paths = _apply_env(paths, env) return Config( paths=paths, backend=backend, vmrun_path=vmrun_path, ssh_key_path=ssh_key_path, guest_cred_target=guest_cred_target, ) __all__ = ["BackendConfig", "Config", "Paths", "load_config"]