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.
160 lines
5.5 KiB
Python
160 lines
5.5 KiB
Python
"""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)
|
|
|
|
|
|
# ── 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"
|
|
|
|
|
|
# ── reconcile (TODO §2.8) ─────────────────────────────────────────────────────
|
|
|
|
|
|
def test_reconcile_releases_leases_with_dead_owners(tmp_path: Path) -> None:
|
|
pool = make_pool(tmp_path)
|
|
pool.acquire("clone-alive") # 10.0.0.1
|
|
pool.acquire("clone-dead") # 10.0.0.2
|
|
|
|
released = pool.reconcile({"clone-alive"})
|
|
|
|
assert released == ["10.0.0.2"]
|
|
leases = json.loads((tmp_path / "ip-pool.json").read_text())
|
|
assert leases["10.0.0.1"] == "clone-alive" # kept
|
|
assert leases["10.0.0.2"] is None # released
|
|
|
|
|
|
def test_reconcile_keeps_all_when_all_owners_live(tmp_path: Path) -> None:
|
|
pool = make_pool(tmp_path)
|
|
pool.acquire("clone-a")
|
|
pool.acquire("clone-b")
|
|
assert pool.reconcile({"clone-a", "clone-b"}) == []
|
|
leases = json.loads((tmp_path / "ip-pool.json").read_text())
|
|
assert leases["10.0.0.1"] == "clone-a"
|
|
assert leases["10.0.0.2"] == "clone-b"
|
|
|
|
|
|
def test_reconcile_dry_run_reports_without_writing(tmp_path: Path) -> None:
|
|
pool = make_pool(tmp_path)
|
|
pool.acquire("clone-dead") # 10.0.0.1
|
|
|
|
released = pool.reconcile(set(), dry_run=True)
|
|
|
|
assert released == ["10.0.0.1"]
|
|
leases = json.loads((tmp_path / "ip-pool.json").read_text())
|
|
assert leases["10.0.0.1"] == "clone-dead" # untouched
|
|
|
|
|
|
def test_reconcile_empty_pool_returns_empty(tmp_path: Path) -> None:
|
|
pool = make_pool(tmp_path) # no acquires → all leases None
|
|
assert pool.reconcile(set()) == []
|