Phase A1: bootstrap Python ci_orchestrator package
- pyproject.toml (hatchling) with ruff/mypy/pytest dev deps - src/ci_orchestrator/: config, credentials, backends (Protocol + WorkstationVmrunBackend), transport (WinRM via pypsrp, SSH via paramiko) - CLI entry point with PoC 'wait-ready' subcommand (Windows + Linux guests) - tests/python/: 35 unit tests, 83% coverage, all backends/transport mocked - gitea/workflows/lint.yml: new 'python' job (ruff + mypy --strict + pytest --cov-fail-under=70) - config.example.toml + README setup section + .gitignore Python entries Neutral VmBackend Protocol prepared for Phase C ESXi extension. See plans/implementation-plan-A-B.md step A1.
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
"""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"]
|
||||
Reference in New Issue
Block a user