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:
@@ -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