141 lines
5.1 KiB
Python
141 lines
5.1 KiB
Python
"""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 collections.abc import Generator
|
|
from contextlib import contextmanager
|
|
from pathlib import Path
|
|
|
|
|
|
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"]
|