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:
@@ -32,3 +32,24 @@ keys = "F:/CI/keys"
|
||||
# Phase C hook: backend selector. Only "workstation" is implemented in Phase A.
|
||||
[backend]
|
||||
type = "workstation"
|
||||
|
||||
# Optional: pre-assign static IPs to build VMs via VMX guestinfo injection.
|
||||
# Eliminates VMware-Tools IP-detect polling (20–180 s variance) — the host
|
||||
# injects guestinfo.ip-assignment into each cloned VMX before vmrun start,
|
||||
# and the guest boot script applies the static IP immediately.
|
||||
#
|
||||
# Prerequisite: install the guest-side startup script from
|
||||
# template/guest-setup/linux/ci-report-ip.sh (Linux guests)
|
||||
# template/guest-setup/windows/ci-static-ip.ps1 (Windows guests)
|
||||
# and re-take the BaseClean / BaseClean-Linux snapshot.
|
||||
#
|
||||
# Pool size should equal runner capacity (default 4).
|
||||
# Addresses must be outside the VMnet8 DHCP range (check
|
||||
# /etc/vmware/vmnet8/dhcpd/dhcpd.conf — typically .128–.254).
|
||||
#
|
||||
# [ip_pool]
|
||||
# addresses = ["192.168.79.201", "192.168.79.202",
|
||||
# "192.168.79.203", "192.168.79.204"]
|
||||
# netmask = "255.255.255.0"
|
||||
# gateway = "192.168.79.2" # VMnet8 gateway; leave empty to skip default route
|
||||
# lease_file = "/var/lib/ci/ip-pool.json"
|
||||
|
||||
@@ -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,9 +541,14 @@ 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:
|
||||
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"]
|
||||
@@ -531,3 +531,110 @@ def test_job_wait_for_ip_returns_none_on_backend_error(
|
||||
job_module._wait_for_ip(_Boom(), VmHandle("x"), deadline=0.5, poll=0.001) # type: ignore[arg-type]
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
# ── _inject_guestinfo_ip ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_inject_guestinfo_ip_appends_lines(tmp_path: Path) -> None:
|
||||
vmx = tmp_path / "test.vmx"
|
||||
vmx.write_text('guestOS = "ubuntu-64"\n', encoding="utf-8")
|
||||
job_module._inject_guestinfo_ip(vmx, "192.168.79.201", "255.255.255.0", "192.168.79.2")
|
||||
content = vmx.read_text()
|
||||
assert 'guestinfo.ip-assignment = "192.168.79.201"' in content
|
||||
assert 'guestinfo.netmask = "255.255.255.0"' in content
|
||||
assert 'guestinfo.gateway = "192.168.79.2"' in content
|
||||
assert 'guestOS = "ubuntu-64"' in content
|
||||
|
||||
|
||||
def test_inject_guestinfo_ip_replaces_existing(tmp_path: Path) -> None:
|
||||
vmx = tmp_path / "test.vmx"
|
||||
vmx.write_text(
|
||||
'guestOS = "ubuntu-64"\n'
|
||||
'guestinfo.ip-assignment = "192.168.79.100"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
job_module._inject_guestinfo_ip(vmx, "192.168.79.201", "255.255.255.0", "")
|
||||
content = vmx.read_text()
|
||||
assert "192.168.79.100" not in content
|
||||
assert 'guestinfo.ip-assignment = "192.168.79.201"' in content
|
||||
|
||||
|
||||
def test_inject_guestinfo_ip_omits_empty_gateway(tmp_path: Path) -> None:
|
||||
vmx = tmp_path / "test.vmx"
|
||||
vmx.write_text('guestOS = "ubuntu-64"\n', encoding="utf-8")
|
||||
job_module._inject_guestinfo_ip(vmx, "192.168.79.201", "255.255.255.0", "")
|
||||
assert "guestinfo.gateway" not in vmx.read_text()
|
||||
|
||||
|
||||
# ── job with ip_pool ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_job_with_ip_pool_uses_preassigned_ip(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path
|
||||
) -> None:
|
||||
"""When ip_pool is configured the job skips _wait_for_ip and uses the pool IP."""
|
||||
from ci_orchestrator.config import Config, IpPoolConfig
|
||||
from ci_orchestrator.ip_pool import IpSlotPool
|
||||
|
||||
backend = _FakeBackend(running_after=1)
|
||||
calls = _patch_common(monkeypatch, backend)
|
||||
|
||||
pool_addresses = ["192.168.79.201"]
|
||||
lease_file = tmp_path / "ip-pool.json"
|
||||
pool_cfg = IpPoolConfig(
|
||||
addresses=pool_addresses,
|
||||
netmask="255.255.255.0",
|
||||
gateway="192.168.79.2",
|
||||
lease_file=lease_file,
|
||||
)
|
||||
|
||||
# Patch load_config to return a config with ip_pool set.
|
||||
real_cfg = job_module.load_config()
|
||||
patched_cfg = Config(
|
||||
paths=real_cfg.paths,
|
||||
backend=real_cfg.backend,
|
||||
vmrun_path=real_cfg.vmrun_path,
|
||||
ssh_key_path=real_cfg.ssh_key_path,
|
||||
guest_cred_target=real_cfg.guest_cred_target,
|
||||
ip_pool=pool_cfg,
|
||||
)
|
||||
monkeypatch.setattr(job_module, "load_config", lambda: patched_cfg)
|
||||
|
||||
base_clone = tmp_path / "build-vms"
|
||||
base_artifact = tmp_path / "artifacts"
|
||||
|
||||
result = CliRunner().invoke(
|
||||
cli, _common_args(template_vmx, clone_base=base_clone, artifact_base=base_artifact)
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
# backend.get_ip was NOT called — IP came from pool.
|
||||
assert "get_ip" not in backend.calls
|
||||
|
||||
# The pre-assigned IP appears in output.
|
||||
assert "192.168.79.201" in result.output
|
||||
assert "pre-assigned" in result.output
|
||||
|
||||
# Pool slot was released after job.
|
||||
released = IpSlotPool(pool_addresses, "255.255.255.0", "", lease_file)._read_leases()
|
||||
assert released["192.168.79.201"] is None
|
||||
|
||||
# Build was invoked with the pool IP.
|
||||
assert calls["linux_build"][0]["ip_address"] == "192.168.79.201"
|
||||
|
||||
# VMX injection confirmed via log output (clone dir removed after job).
|
||||
assert "pre-assigned" in result.output
|
||||
|
||||
|
||||
def test_destroy_clone_releases_pool_ip(tmp_path: Path) -> None:
|
||||
from ci_orchestrator.ip_pool import IpSlotPool
|
||||
|
||||
lease = tmp_path / "pool.json"
|
||||
pool = IpSlotPool(["10.0.0.1"], "255.255.255.0", "", lease)
|
||||
ip = pool.acquire("owner")
|
||||
|
||||
job_module._destroy_clone(None, None, None, pool=pool, pool_ip=ip)
|
||||
|
||||
leases = pool._read_leases()
|
||||
assert leases["10.0.0.1"] is None
|
||||
|
||||
@@ -107,3 +107,41 @@ def test_toml_provides_ssh_key_path_and_guest_cred_target(tmp_path: Path) -> Non
|
||||
cfg = load_config(config_path=toml, env={})
|
||||
assert cfg.ssh_key_path == key
|
||||
assert cfg.guest_cred_target == "MyTarget"
|
||||
|
||||
|
||||
def test_toml_ip_pool_parsed(tmp_path: Path) -> None:
|
||||
lease = tmp_path / "ip-pool.json"
|
||||
toml = tmp_path / "config.toml"
|
||||
toml.write_text(
|
||||
"[ip_pool]\n"
|
||||
'addresses = ["192.168.79.201", "192.168.79.202"]\n'
|
||||
'netmask = "255.255.255.0"\n'
|
||||
'gateway = "192.168.79.2"\n'
|
||||
f'lease_file = "{lease.as_posix()}"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
cfg = load_config(config_path=toml, env={})
|
||||
assert cfg.ip_pool is not None
|
||||
assert cfg.ip_pool.addresses == ["192.168.79.201", "192.168.79.202"]
|
||||
assert cfg.ip_pool.netmask == "255.255.255.0"
|
||||
assert cfg.ip_pool.gateway == "192.168.79.2"
|
||||
assert cfg.ip_pool.lease_file == lease
|
||||
|
||||
|
||||
def test_toml_ip_pool_default_lease_file(tmp_path: Path) -> None:
|
||||
toml = tmp_path / "config.toml"
|
||||
toml.write_text(
|
||||
"[ip_pool]\n"
|
||||
'addresses = ["192.168.79.201"]\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
cfg = load_config(config_path=toml, env={})
|
||||
assert cfg.ip_pool is not None
|
||||
assert cfg.ip_pool.lease_file.name == "ip-pool.json"
|
||||
|
||||
|
||||
def test_ip_pool_absent_when_not_configured(tmp_path: Path) -> None:
|
||||
toml = tmp_path / "config.toml"
|
||||
toml.write_text("[backend]\ntype = \"workstation\"\n", encoding="utf-8")
|
||||
cfg = load_config(config_path=toml, env={})
|
||||
assert cfg.ip_pool is None
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Tests for ci_orchestrator.ip_pool — IpSlotPool acquire/release."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from ci_orchestrator.ip_pool import IpSlotPool
|
||||
|
||||
|
||||
def make_pool(tmp_path: Path, addresses: list[str] | None = None) -> IpSlotPool:
|
||||
return IpSlotPool(
|
||||
addresses=addresses or ["10.0.0.1", "10.0.0.2"],
|
||||
netmask="255.255.255.0",
|
||||
gateway="10.0.0.254",
|
||||
lease_file=tmp_path / "ip-pool.json",
|
||||
)
|
||||
|
||||
|
||||
# ── construction ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_empty_addresses_raises(tmp_path: Path) -> None:
|
||||
with pytest.raises(ValueError, match="at least one"):
|
||||
IpSlotPool([], "255.255.255.0", "", tmp_path / "pool.json")
|
||||
|
||||
|
||||
# ── acquire ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_acquire_returns_first_free_ip(tmp_path: Path) -> None:
|
||||
pool = make_pool(tmp_path)
|
||||
ip = pool.acquire("owner-1")
|
||||
assert ip == "10.0.0.1"
|
||||
|
||||
|
||||
def test_acquire_second_slot(tmp_path: Path) -> None:
|
||||
pool = make_pool(tmp_path)
|
||||
pool.acquire("owner-1")
|
||||
ip2 = pool.acquire("owner-2")
|
||||
assert ip2 == "10.0.0.2"
|
||||
|
||||
|
||||
def test_acquire_writes_lease_file(tmp_path: Path) -> None:
|
||||
pool = make_pool(tmp_path)
|
||||
pool.acquire("owner-1")
|
||||
leases = json.loads((tmp_path / "ip-pool.json").read_text())
|
||||
assert leases["10.0.0.1"] == "owner-1"
|
||||
assert leases["10.0.0.2"] is None
|
||||
|
||||
|
||||
def test_acquire_exhausted_raises(tmp_path: Path) -> None:
|
||||
pool = make_pool(tmp_path, addresses=["10.0.0.1"])
|
||||
pool.acquire("owner-1")
|
||||
with pytest.raises(RuntimeError, match="exhausted"):
|
||||
pool.acquire("owner-2", timeout=0.1)
|
||||
|
||||
|
||||
# ── release ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_release_frees_slot(tmp_path: Path) -> None:
|
||||
pool = make_pool(tmp_path)
|
||||
ip = pool.acquire("owner-1")
|
||||
pool.release(ip)
|
||||
leases = json.loads((tmp_path / "ip-pool.json").read_text())
|
||||
assert leases[ip] is None
|
||||
|
||||
|
||||
def test_release_unknown_ip_is_noop(tmp_path: Path) -> None:
|
||||
pool = make_pool(tmp_path)
|
||||
pool.release("99.99.99.99") # should not raise
|
||||
|
||||
|
||||
def test_release_and_reacquire(tmp_path: Path) -> None:
|
||||
pool = make_pool(tmp_path, addresses=["10.0.0.1"])
|
||||
ip = pool.acquire("owner-1")
|
||||
pool.release(ip)
|
||||
ip2 = pool.acquire("owner-2")
|
||||
assert ip2 == "10.0.0.1"
|
||||
|
||||
|
||||
# ── persistence ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_second_pool_instance_sees_leases(tmp_path: Path) -> None:
|
||||
lease = tmp_path / "ip-pool.json"
|
||||
p1 = IpSlotPool(["10.0.0.1", "10.0.0.2"], "255.255.255.0", "", lease)
|
||||
p1.acquire("owner-1")
|
||||
|
||||
p2 = IpSlotPool(["10.0.0.1", "10.0.0.2"], "255.255.255.0", "", lease)
|
||||
ip = p2.acquire("owner-2")
|
||||
assert ip == "10.0.0.2"
|
||||
|
||||
|
||||
def test_corrupt_lease_file_treated_as_empty(tmp_path: Path) -> None:
|
||||
lease = tmp_path / "ip-pool.json"
|
||||
lease.write_text("NOT JSON", encoding="utf-8")
|
||||
pool = make_pool(tmp_path)
|
||||
ip = pool.acquire("owner-1")
|
||||
assert ip == "10.0.0.1"
|
||||
|
||||
|
||||
def test_missing_lease_file_treated_as_empty(tmp_path: Path) -> None:
|
||||
pool = make_pool(tmp_path)
|
||||
ip = pool.acquire("owner-1")
|
||||
assert ip == "10.0.0.1"
|
||||
|
||||
|
||||
def test_non_dict_lease_json_treated_as_empty(tmp_path: Path) -> None:
|
||||
lease = tmp_path / "ip-pool.json"
|
||||
lease.write_text("[1, 2, 3]", encoding="utf-8") # valid JSON but not a dict
|
||||
pool = make_pool(tmp_path)
|
||||
ip = pool.acquire("owner-1")
|
||||
assert ip == "10.0.0.1"
|
||||
Reference in New Issue
Block a user