From 927d6927fd74386f155ec245b04167d198f5a651 Mon Sep 17 00:00:00 2001 From: Simone Date: Sun, 24 May 2026 00:26:59 +0200 Subject: [PATCH] feat(ip-pool): pre-assign static IPs to build VMs via VMX guestinfo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- config.example.toml | 21 +++++ src/ci_orchestrator/commands/job.py | 68 ++++++++++++-- src/ci_orchestrator/config.py | 42 ++++++++- src/ci_orchestrator/ip_pool.py | 140 ++++++++++++++++++++++++++++ tests/python/test_commands_job.py | 107 +++++++++++++++++++++ tests/python/test_config.py | 38 ++++++++ tests/python/test_ip_pool.py | 117 +++++++++++++++++++++++ 7 files changed, 524 insertions(+), 9 deletions(-) create mode 100644 src/ci_orchestrator/ip_pool.py create mode 100644 tests/python/test_ip_pool.py diff --git a/config.example.toml b/config.example.toml index acd4f72..e9ef228 100644 --- a/config.example.toml +++ b/config.example.toml @@ -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" diff --git a/src/ci_orchestrator/commands/job.py b/src/ci_orchestrator/commands/job.py index afef004..f566f22 100644 --- a/src/ci_orchestrator/commands/job.py +++ b/src/ci_orchestrator/commands/job.py @@ -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"] diff --git a/src/ci_orchestrator/config.py b/src/ci_orchestrator/config.py index df3744b..9bff204 100644 --- a/src/ci_orchestrator/config.py +++ b/src/ci_orchestrator/config.py @@ -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"] diff --git a/src/ci_orchestrator/ip_pool.py b/src/ci_orchestrator/ip_pool.py new file mode 100644 index 0000000..58a1418 --- /dev/null +++ b/src/ci_orchestrator/ip_pool.py @@ -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"] diff --git a/tests/python/test_commands_job.py b/tests/python/test_commands_job.py index 814fcc9..4a18401 100644 --- a/tests/python/test_commands_job.py +++ b/tests/python/test_commands_job.py @@ -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 diff --git a/tests/python/test_config.py b/tests/python/test_config.py index a37bc4e..ce53d41 100644 --- a/tests/python/test_config.py +++ b/tests/python/test_config.py @@ -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 diff --git a/tests/python/test_ip_pool.py b/tests/python/test_ip_pool.py new file mode 100644 index 0000000..f060ec8 --- /dev/null +++ b/tests/python/test_ip_pool.py @@ -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"