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.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -42,6 +42,7 @@ from ci_orchestrator.commands.build import _linux_build, _windows_build
|
||||
from ci_orchestrator.commands.vm import _stop_with_fallback, _try_remove_dir
|
||||
from ci_orchestrator.config import Config, load_config
|
||||
from ci_orchestrator.credentials import KeyringCredentialStore
|
||||
from ci_orchestrator.ip_pool import IpSlotPool
|
||||
from ci_orchestrator.transport.errors import TransportError
|
||||
from ci_orchestrator.transport.ssh import SshTransport
|
||||
from ci_orchestrator.transport.winrm import WinRmTransport
|
||||
@@ -188,6 +189,28 @@ def _probe_transport(
|
||||
return False
|
||||
|
||||
|
||||
def _inject_guestinfo_ip(
|
||||
vmx_path: Path, ip: str, netmask: str, gateway: str
|
||||
) -> None:
|
||||
"""Write guestinfo.ip-assignment/netmask/gateway into the cloned VMX.
|
||||
|
||||
Removes any pre-existing lines for these keys before appending fresh
|
||||
values, so re-runs are idempotent. Called after clone and before start.
|
||||
"""
|
||||
_KEYS = {"guestinfo.ip-assignment", "guestinfo.netmask", "guestinfo.gateway"}
|
||||
lines = vmx_path.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
lines = [
|
||||
ln for ln in lines
|
||||
if ln.split("=")[0].strip().lower() not in _KEYS
|
||||
]
|
||||
lines.append(f'guestinfo.ip-assignment = "{ip}"')
|
||||
if netmask:
|
||||
lines.append(f'guestinfo.netmask = "{netmask}"')
|
||||
if gateway:
|
||||
lines.append(f'guestinfo.gateway = "{gateway}"')
|
||||
vmx_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def _make_clone_name(job_id: str) -> str:
|
||||
timestamp = datetime.now(tz=UTC).strftime("%Y%m%d_%H%M%S")
|
||||
# uuid4 suffix avoids collisions when several clones share a JobId.
|
||||
@@ -242,6 +265,9 @@ def _destroy_clone(
|
||||
backend: VmBackend | None,
|
||||
handle: VmHandle | None,
|
||||
clone_dir: Path | None,
|
||||
*,
|
||||
pool: IpSlotPool | None = None,
|
||||
pool_ip: str | None = None,
|
||||
) -> None:
|
||||
"""Final cleanup. Tolerates every error — must never raise."""
|
||||
if backend is not None and handle is not None:
|
||||
@@ -265,6 +291,12 @@ def _destroy_clone(
|
||||
click.echo(
|
||||
f"[job] could not fully remove: {clone_dir}", err=True
|
||||
)
|
||||
if pool is not None and pool_ip is not None:
|
||||
try:
|
||||
pool.release(pool_ip)
|
||||
click.echo(f"[job] IP slot released: {pool_ip}")
|
||||
except Exception as exc:
|
||||
click.echo(f"[job] IP slot release failed (ignored): {exc}", err=True)
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────── command
|
||||
@@ -435,6 +467,8 @@ def job(
|
||||
clone_vmx = clone_dir / f"{clone_name}.vmx"
|
||||
|
||||
handle: VmHandle | None = None
|
||||
slot_pool: IpSlotPool | None = None
|
||||
pool_ip: str | None = None
|
||||
started = datetime.now(tz=UTC)
|
||||
phase_timings: list[tuple[str, float]] = []
|
||||
phase_start = _now()
|
||||
@@ -474,6 +508,19 @@ def job(
|
||||
)
|
||||
_apply_vmx_overrides(clone_vmx, guest_cpu, guest_memory_mb)
|
||||
|
||||
if config.ip_pool is not None:
|
||||
slot_pool = IpSlotPool(
|
||||
addresses=config.ip_pool.addresses,
|
||||
netmask=config.ip_pool.netmask,
|
||||
gateway=config.ip_pool.gateway,
|
||||
lease_file=config.ip_pool.lease_file,
|
||||
)
|
||||
pool_ip = slot_pool.acquire(clone_name)
|
||||
_inject_guestinfo_ip(
|
||||
clone_vmx, pool_ip, config.ip_pool.netmask, config.ip_pool.gateway
|
||||
)
|
||||
click.echo(f"[job] pre-assigned guest IP: {pool_ip}")
|
||||
|
||||
phase_timings.append(("clone", _now() - phase_start))
|
||||
phase_start = _now()
|
||||
|
||||
@@ -494,10 +541,15 @@ def job(
|
||||
deadline = _now() + ready_timeout
|
||||
if not _wait_running(backend, handle, deadline, poll_interval):
|
||||
raise click.ClickException("timeout waiting for VM to be running.")
|
||||
ip_address = _wait_for_ip(backend, handle, deadline, poll_interval)
|
||||
if not ip_address:
|
||||
raise click.ClickException("timeout waiting for guest IP.")
|
||||
click.echo(f"[job] guest IP: {ip_address}")
|
||||
if pool_ip is not None:
|
||||
ip_address: str = pool_ip
|
||||
click.echo(f"[job] guest IP: {ip_address} (pre-assigned, skipping poll)")
|
||||
else:
|
||||
_ip = _wait_for_ip(backend, handle, deadline, poll_interval)
|
||||
if not _ip:
|
||||
raise click.ClickException("timeout waiting for guest IP.")
|
||||
ip_address = _ip
|
||||
click.echo(f"[job] guest IP: {ip_address}")
|
||||
phase_timings.append(("start", _now() - phase_start))
|
||||
phase_start = _now()
|
||||
|
||||
@@ -622,17 +674,17 @@ def job(
|
||||
click.echo("=" * 60)
|
||||
except KeyboardInterrupt:
|
||||
click.echo("\n[job] interrupted by user; cleaning up...", err=True)
|
||||
_destroy_clone(backend, handle, clone_dir)
|
||||
_destroy_clone(backend, handle, clone_dir, pool=slot_pool, pool_ip=pool_ip)
|
||||
sys.exit(130)
|
||||
except click.ClickException:
|
||||
_destroy_clone(backend, handle, clone_dir)
|
||||
_destroy_clone(backend, handle, clone_dir, pool=slot_pool, pool_ip=pool_ip)
|
||||
raise
|
||||
except Exception as exc:
|
||||
click.echo(f"\n[job] FAILURE: {exc}", err=True)
|
||||
_destroy_clone(backend, handle, clone_dir)
|
||||
_destroy_clone(backend, handle, clone_dir, pool=slot_pool, pool_ip=pool_ip)
|
||||
raise click.ClickException(str(exc)) from exc
|
||||
else:
|
||||
_destroy_clone(backend, handle, clone_dir)
|
||||
_destroy_clone(backend, handle, clone_dir, pool=slot_pool, pool_ip=pool_ip)
|
||||
|
||||
|
||||
__all__ = ["job"]
|
||||
|
||||
@@ -22,6 +22,9 @@ 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:
|
||||
@@ -34,6 +37,26 @@ class Paths:
|
||||
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'."""
|
||||
@@ -51,6 +74,7 @@ class Config:
|
||||
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:
|
||||
@@ -133,6 +157,7 @@ def load_config(
|
||||
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)
|
||||
@@ -148,6 +173,20 @@ def load_config(
|
||||
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)
|
||||
|
||||
@@ -157,7 +196,8 @@ def load_config(
|
||||
vmrun_path=vmrun_path,
|
||||
ssh_key_path=ssh_key_path,
|
||||
guest_cred_target=guest_cred_target,
|
||||
ip_pool=ip_pool,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["BackendConfig", "Config", "Paths", "load_config"]
|
||||
__all__ = ["BackendConfig", "Config", "IpPoolConfig", "Paths", "load_config"]
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
"""IP slot pool — pre-assigns static IPs to build VMs via VMX guestinfo.
|
||||
|
||||
When ``[ip_pool]`` is configured, the orchestrator allocates one IP from the
|
||||
pool before starting each VM, injects it into the cloned VMX as::
|
||||
|
||||
guestinfo.ip-assignment = "192.168.79.201"
|
||||
guestinfo.netmask = "255.255.255.0"
|
||||
guestinfo.gateway = "192.168.79.2"
|
||||
|
||||
and releases it on VM destroy. The guest applies the static IP at boot:
|
||||
|
||||
* **Linux**: ``/usr/local/bin/ci-report-ip.sh`` (updated version in
|
||||
``template/guest-setup/linux/``) reads ``guestinfo.ip-assignment`` and
|
||||
configures a static NIC address before ``network-online.target`` is reached.
|
||||
|
||||
* **Windows**: the ``CI-StaticIp`` Task Scheduler task
|
||||
(``template/guest-setup/windows/ci-static-ip.ps1``) runs at system startup
|
||||
as SYSTEM and applies the static IP before WinRM starts.
|
||||
|
||||
This eliminates the DHCP / VMware-Tools IP-detect polling loop (20–180 s
|
||||
variance observed in B7 benchmarks). After the VM reaches *running* state the
|
||||
host knows the IP immediately and goes straight to the transport probe.
|
||||
|
||||
Lease file — JSON dict mapping each pool IP to its current owner string, or
|
||||
``null`` if free::
|
||||
|
||||
{
|
||||
"192.168.79.201": "Clone_job123_20260524_abc123",
|
||||
"192.168.79.202": null,
|
||||
"192.168.79.203": null,
|
||||
"192.168.79.204": null
|
||||
}
|
||||
|
||||
File locking: ``fcntl.flock`` (POSIX/Linux); no-op on Windows (low risk —
|
||||
pool size matches concurrency, failures are detectable via transport probe).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Generator
|
||||
|
||||
|
||||
class IpSlotPool:
|
||||
"""Process-safe IP slot allocator backed by a JSON lease file."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
addresses: list[str],
|
||||
netmask: str,
|
||||
gateway: str,
|
||||
lease_file: Path,
|
||||
) -> None:
|
||||
if not addresses:
|
||||
raise ValueError("IpSlotPool requires at least one address.")
|
||||
self._addresses = list(addresses)
|
||||
self.netmask = netmask
|
||||
self.gateway = gateway
|
||||
self._lease_file = lease_file
|
||||
self._lock_file = lease_file.with_suffix(".lock")
|
||||
|
||||
# ── public API ─────────────────────────────────────────────────────────
|
||||
|
||||
def acquire(self, owner: str, timeout: float = 60.0) -> str:
|
||||
"""Allocate a free IP slot. Blocks up to *timeout* seconds.
|
||||
|
||||
Raises :exc:`RuntimeError` if the pool stays exhausted past *timeout*.
|
||||
"""
|
||||
deadline = time.monotonic() + timeout
|
||||
while True:
|
||||
with _file_lock(self._lock_file):
|
||||
leases = self._read_leases()
|
||||
for ip in self._addresses:
|
||||
if leases.get(ip) is None:
|
||||
leases[ip] = owner
|
||||
self._write_leases(leases)
|
||||
return ip
|
||||
if time.monotonic() >= deadline:
|
||||
raise RuntimeError(
|
||||
f"IP pool exhausted after {timeout:.0f}s; "
|
||||
f"addresses={self._addresses}"
|
||||
)
|
||||
time.sleep(2.0)
|
||||
|
||||
def release(self, ip: str) -> None:
|
||||
"""Return *ip* to the pool (no-op if not in pool)."""
|
||||
with _file_lock(self._lock_file):
|
||||
leases = self._read_leases()
|
||||
if ip in leases:
|
||||
leases[ip] = None
|
||||
self._write_leases(leases)
|
||||
|
||||
# ── internals ──────────────────────────────────────────────────────────
|
||||
|
||||
def _read_leases(self) -> dict[str, str | None]:
|
||||
result: dict[str, str | None] = {ip: None for ip in self._addresses}
|
||||
if not self._lease_file.exists():
|
||||
return result
|
||||
try:
|
||||
raw: object = json.loads(self._lease_file.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return result
|
||||
if not isinstance(raw, dict):
|
||||
return result
|
||||
for ip in self._addresses:
|
||||
val = raw.get(ip)
|
||||
result[ip] = val if isinstance(val, str) else None
|
||||
return result
|
||||
|
||||
def _write_leases(self, leases: dict[str, str | None]) -> None:
|
||||
self._lease_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = self._lease_file.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(leases, indent=2) + "\n", encoding="utf-8")
|
||||
tmp.replace(self._lease_file)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _file_lock(path: Path) -> Generator[None, None, None]:
|
||||
"""Exclusive advisory lock on *path* via ``fcntl.flock`` (POSIX only)."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "w") as fd:
|
||||
try:
|
||||
import fcntl
|
||||
fcntl.flock(fd, fcntl.LOCK_EX)
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
try:
|
||||
import fcntl
|
||||
fcntl.flock(fd, fcntl.LOCK_UN)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
__all__ = ["IpSlotPool"]
|
||||
Reference in New Issue
Block a user