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"]
|
||||
|
||||
Reference in New Issue
Block a user