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:
2026-05-24 00:26:59 +02:00
parent 203515f5d7
commit 927d6927fd
7 changed files with 524 additions and 9 deletions
+21
View File
@@ -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 (20180 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"
+58 -6
View File
@@ -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"]
+41 -1
View File
@@ -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"]
+140
View File
@@ -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 (20180 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"]
+107
View File
@@ -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
+38
View File
@@ -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
+117
View File
@@ -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"