fix(ip-pool): reconcile stale leases against live clones (§2.8, W1)

A job killed mid-flight never released its IP lease; with only 4 pool addresses a single leak exhausted the pool ("IP pool exhausted after 60s"). IpSlotPool.reconcile() releases leases whose owner clone-dir is gone; vm cleanup runs it only against the real build_vms base. Closes TODO §2.8.
This commit is contained in:
2026-07-23 17:15:23 +02:00
parent b0415f36e4
commit f0d856b644
8 changed files with 263 additions and 3 deletions
+61
View File
@@ -445,3 +445,64 @@ def test_remove_vm_dir_removal_failure(monkeypatch: pytest.MonkeyPatch, tmp_path
monkeypatch.setattr(vms_module, "_try_remove_dir", lambda _p: False)
result = remove_vm(backend, vmx) # type: ignore[arg-type]
assert result.dir_removed is False
# ── reconcile_ip_leases (TODO §2.8) ───────────────────────────────────────────
def _make_pool(tmp_path: Path) -> Any:
from ci_orchestrator.ip_pool import IpSlotPool
return IpSlotPool(
addresses=["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",
)
def test_reconcile_ip_leases_releases_dead_clone(tmp_path: Path) -> None:
from ci_orchestrator.operations.vms import reconcile_ip_leases
base = tmp_path / "build-vms"
base.mkdir()
(base / "clone-alive").mkdir() # survives on disk
pool = _make_pool(tmp_path)
pool.acquire("clone-alive") # 10.0.0.1
pool.acquire("clone-dead") # 10.0.0.2 — no dir on disk
released = reconcile_ip_leases(base, pool)
assert released == ["10.0.0.2"]
def test_reconcile_ip_leases_dry_run_no_write(tmp_path: Path) -> None:
from ci_orchestrator.operations.vms import reconcile_ip_leases
base = tmp_path / "build-vms"
base.mkdir()
pool = _make_pool(tmp_path)
pool.acquire("clone-dead") # 10.0.0.1, dir absent
released = reconcile_ip_leases(base, pool, dry_run=True)
assert released == ["10.0.0.1"]
import json
leases = json.loads((tmp_path / "ip-pool.json").read_text())
assert leases["10.0.0.1"] == "clone-dead" # not written
def test_reconcile_ip_leases_unreadable_base_is_noop(tmp_path: Path) -> None:
from ci_orchestrator.operations.vms import reconcile_ip_leases
missing = tmp_path / "does-not-exist"
pool = _make_pool(tmp_path)
pool.acquire("clone-dead")
# clone_base cannot be enumerated → must NOT release anything.
assert reconcile_ip_leases(missing, pool) == []
import json
leases = json.loads((tmp_path / "ip-pool.json").read_text())
assert leases["10.0.0.1"] == "clone-dead"