Files
local-ci-cd-system/src/ci_orchestrator/config.py
T
Simone 32c723147f feat(ip-pool): pre-assign static IPs to build VMs via VMX guestinfo
Eliminates VMware-Tools IP-detect polling (observed: 39–177 s variance on
Linux host B7 benchmark).  When [ip_pool] is configured the orchestrator:

  1. Allocates a slot IP from a JSON-backed pool (fcntl-locked on Linux)
  2. Injects guestinfo.ip-assignment / netmask / gateway into the cloned VMX
     before vmrun start
  3. Skips _wait_for_ip entirely — uses the pre-assigned IP immediately
  4. Releases the slot in _destroy_clone (all paths: success/failure/interrupt)

New modules / changes:
  src/ci_orchestrator/ip_pool.py      IpSlotPool + _file_lock context manager
  src/ci_orchestrator/config.py       IpPoolConfig dataclass; [ip_pool] TOML
  src/ci_orchestrator/commands/job.py _inject_guestinfo_ip helper; pool wiring
  tests/python/test_ip_pool.py        12 unit tests (acquire/release/persist)
  tests/python/test_config.py         ip_pool config parsing tests
  tests/python/test_commands_job.py   pool path + _inject_guestinfo_ip tests
  config.example.toml                 [ip_pool] section (commented example)

Coverage 90.02%; mypy --strict clean.
2026-05-24 00:26:59 +02:00

204 lines
6.4 KiB
Python

"""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")
_IP_POOL_LEASE_DEFAULT_LINUX = "/var/lib/ci/ip-pool.json"
_IP_POOL_LEASE_DEFAULT_WIN = r"F:\CI\ip-pool.json"
@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 IpPoolConfig:
"""Pre-assigned IP pool for zero-polling VM ready detection.
When present, the orchestrator injects ``guestinfo.ip-assignment`` into
the cloned VMX before starting the VM so the guest can apply a static IP
at boot (see ``template/guest-setup/``).
"""
addresses: list[str]
netmask: str = "255.255.255.0"
gateway: str = ""
lease_file: Path = field(
default_factory=lambda: Path(
_IP_POOL_LEASE_DEFAULT_WIN if os.name == "nt"
else _IP_POOL_LEASE_DEFAULT_LINUX
)
)
@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"
ip_pool: IpPoolConfig | None = None
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")
ip_pool: IpPoolConfig | None = None
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)
pool_section = data.get("ip_pool", {})
if pool_section and pool_section.get("addresses"):
addrs = pool_section["addresses"]
if isinstance(addrs, list) and addrs:
_lease_default = (
_IP_POOL_LEASE_DEFAULT_WIN if os.name == "nt"
else _IP_POOL_LEASE_DEFAULT_LINUX
)
ip_pool = IpPoolConfig(
addresses=[str(a) for a in addrs],
netmask=str(pool_section.get("netmask", "255.255.255.0")),
gateway=str(pool_section.get("gateway", "")),
lease_file=Path(pool_section.get("lease_file") or _lease_default),
)
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,
ip_pool=ip_pool,
)
__all__ = ["BackendConfig", "Config", "IpPoolConfig", "Paths", "load_config"]