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:
2026-05-24 00:26:59 +02:00
parent 203515f5d7
commit 927d6927fd
7 changed files with 524 additions and 9 deletions
+60 -8
View File
@@ -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"]