Files
zima-apps/Apps/docker-ip-addr-manager/tests/integration_tests.py
T

291 lines
10 KiB
Python

#!/usr/bin/env python3
from __future__ import annotations
from pathlib import Path
import sys
import tempfile
ROOT_DIR = Path(__file__).resolve().parents[1]
BACKEND_DIR = ROOT_DIR / "backend"
sys.path.insert(0, str(BACKEND_DIR))
from app.docker_api import DockerApiError, DockerUsageResolver
from app.dns_sync import DnsSyncError
from app.models import IpEntry
from app.service import ConflictError, DependencyError, EntryService
from app.storage import EntryStorage
class FakeDockerClient:
def list_containers(self, include_stopped: bool = True):
assert include_stopped is True
return [
{"Id": "a1", "Names": ["/alpha"]},
{"Id": "b2", "Names": ["/beta"]},
]
def inspect_container(self, container_id: str):
if container_id == "a1":
return {
"Name": "/alpha",
"NetworkSettings": {
"Ports": {
"8080/tcp": [{"HostIp": "10.0.4.2", "HostPort": "8080"}],
"8443/tcp": [{"HostIp": "0.0.0.0", "HostPort": "8443"}],
}
},
}
if container_id == "b2":
return {
"Name": "/beta",
"NetworkSettings": {
"Ports": {
"9000/tcp": [{"HostIp": "10.0.4.3", "HostPort": "9000"}],
}
},
}
raise AssertionError(f"unexpected container_id: {container_id}")
class FakeUsageResolver:
def __init__(self, mapping=None, should_fail=False):
self._mapping = mapping or {}
self._should_fail = should_fail
def resolve_ip_usage(self, ips: set[str]):
if self._should_fail:
raise DockerApiError("docker unreachable")
response = {ip: set() for ip in ips}
for ip in ips:
response[ip] = set(self._mapping.get(ip, set()))
return response
class FakeIpManager:
def __init__(self):
self.present = set()
def is_present(self, ip: str, cidr: int, device: str):
return (ip, cidr, device) in self.present
def ensure_present(self, ip: str, cidr: int, device: str):
self.present.add((ip, cidr, device))
def ensure_absent(self, ip: str, cidr: int, device: str):
self.present.discard((ip, cidr, device))
class FakeDnsProvider:
def __init__(self, fail_upsert=False, fail_delete=False):
self.records = {}
self.upserts = []
self.deletes = []
self.fail_upsert = fail_upsert
self.fail_delete = fail_delete
def upsert_a_record(self, fqdn: str, ip: str, ttl: int):
if self.fail_upsert:
raise DnsSyncError("upsert failed")
self.records[fqdn] = ip
self.upserts.append((fqdn, ip, ttl))
def delete_a_record(self, fqdn: str):
if self.fail_delete:
raise DnsSyncError("delete failed")
self.records.pop(fqdn, None)
self.deletes.append(fqdn)
def assert_true(condition, message):
if not condition:
raise AssertionError(message)
def build_service(
tmp_path: Path,
entries=None,
usage_resolver=None,
ip_manager=None,
dns_provider=None,
dns_base_domain="home.arpa",
):
storage = EntryStorage(str(tmp_path / "entries.json"))
if entries:
storage.save_entries(entries)
resolver = usage_resolver or FakeUsageResolver()
ipm = ip_manager or FakeIpManager()
return EntryService(
storage=storage,
usage_resolver=resolver,
ip_manager=ipm,
interface_provider=lambda: ["eth0", "eth1"],
dns_provider=dns_provider,
dns_base_domain=dns_base_domain,
dns_ttl_seconds=120,
)
def test_exact_hostip_match_only():
resolver = DockerUsageResolver(FakeDockerClient())
usage = resolver.resolve_ip_usage({"10.0.4.2", "10.0.4.3"})
assert_true(usage["10.0.4.2"] == {"alpha"}, "10.0.4.2 should match alpha")
assert_true(usage["10.0.4.3"] == {"beta"}, "10.0.4.3 should match beta")
def test_disable_blocked_when_used(tmp_path: Path):
entry = IpEntry(id="one", name="lan-a", ip="10.0.4.2", cidr=16, device="eth0", enabled=True)
resolver = FakeUsageResolver(mapping={"10.0.4.2": {"alpha"}})
ip_manager = FakeIpManager()
ip_manager.present.add(("10.0.4.2", 16, "eth0"))
service = build_service(tmp_path, entries=[entry], usage_resolver=resolver, ip_manager=ip_manager)
blocked = False
try:
service.set_enabled("one", enabled=False)
except ConflictError:
blocked = True
assert_true(blocked, "disable must be blocked when entry is used")
assert_true(("10.0.4.2", 16, "eth0") in ip_manager.present, "IP must stay present when disable is blocked")
def test_disable_blocked_when_docker_check_fails(tmp_path: Path):
entry = IpEntry(id="two", name="lan-b", ip="10.0.4.8", cidr=16, device="eth0", enabled=True)
resolver = FakeUsageResolver(should_fail=True)
ip_manager = FakeIpManager()
ip_manager.present.add(("10.0.4.8", 16, "eth0"))
service = build_service(tmp_path, entries=[entry], usage_resolver=resolver, ip_manager=ip_manager)
blocked = False
try:
service.set_enabled("two", enabled=False)
except DependencyError:
blocked = True
assert_true(blocked, "disable must fail-closed when Docker usage check fails")
def test_delete_blocked_when_enabled(tmp_path: Path):
entry = IpEntry(id="three", name="lan-c", ip="10.0.4.9", cidr=16, device="eth0", enabled=True)
service = build_service(tmp_path, entries=[entry])
blocked = False
try:
service.delete_entry("three")
except ConflictError:
blocked = True
assert_true(blocked, "delete must be blocked while entry is enabled")
def test_reconcile_reapplies_enabled(tmp_path: Path):
entry = IpEntry(id="four", name="lan-d", ip="10.0.4.10", cidr=16, device="eth0", enabled=True)
ip_manager = FakeIpManager()
service = build_service(tmp_path, entries=[entry], ip_manager=ip_manager)
errors = service.reconcile_enabled_entries()
assert_true(errors == [], "reconcile should succeed without errors")
assert_true(("10.0.4.10", 16, "eth0") in ip_manager.present, "enabled IP must be re-applied on startup reconcile")
def test_dns_upsert_on_enable_when_used(tmp_path: Path):
entry = IpEntry(id="dns1", name="Lan App", ip="10.0.4.20", cidr=16, device="eth0", enabled=False)
resolver = FakeUsageResolver(mapping={"10.0.4.20": {"nginx"}})
ip_manager = FakeIpManager()
dns = FakeDnsProvider()
service = build_service(tmp_path, entries=[entry], usage_resolver=resolver, ip_manager=ip_manager, dns_provider=dns)
service.set_enabled("dns1", enabled=True)
assert_true(dns.records.get("lan-app.home.arpa") == "10.0.4.20", "DNS record should be created on enable+used")
def test_dns_no_upsert_on_enable_when_unused(tmp_path: Path):
entry = IpEntry(id="dns2", name="Lan App 2", ip="10.0.4.21", cidr=16, device="eth0", enabled=False)
resolver = FakeUsageResolver(mapping={})
ip_manager = FakeIpManager()
dns = FakeDnsProvider()
service = build_service(tmp_path, entries=[entry], usage_resolver=resolver, ip_manager=ip_manager, dns_provider=dns)
service.set_enabled("dns2", enabled=True)
assert_true("lan-app-2.home.arpa" not in dns.records, "Unused entries must not create DNS record")
def test_dns_reconcile_deletes_when_no_longer_used(tmp_path: Path):
entry = IpEntry(id="dns3", name="Lan App 3", ip="10.0.4.22", cidr=16, device="eth0", enabled=True)
resolver = FakeUsageResolver(mapping={"10.0.4.22": {"nginx"}})
dns = FakeDnsProvider()
service = build_service(tmp_path, entries=[entry], usage_resolver=resolver, dns_provider=dns)
service.reconcile_dns_records()
assert_true(dns.records.get("lan-app-3.home.arpa") == "10.0.4.22", "record should exist after used reconcile")
resolver._mapping = {}
service.reconcile_dns_records()
assert_true("lan-app-3.home.arpa" not in dns.records, "record should be removed when usage disappears")
def test_dns_fail_closed_rolls_back_enable(tmp_path: Path):
entry = IpEntry(id="dns4", name="Lan App 4", ip="10.0.4.23", cidr=16, device="eth0", enabled=False)
resolver = FakeUsageResolver(mapping={"10.0.4.23": {"nginx"}})
ip_manager = FakeIpManager()
dns = FakeDnsProvider(fail_upsert=True)
service = build_service(tmp_path, entries=[entry], usage_resolver=resolver, ip_manager=ip_manager, dns_provider=dns)
failed = False
try:
service.set_enabled("dns4", enabled=True)
except ConflictError:
failed = True
assert_true(failed, "enable must fail-closed when DNS upsert fails")
current = service.list_entries().items[0]
assert_true(not current.enabled, "entry must roll back to disabled on DNS failure")
assert_true(not ip_manager.is_present("10.0.4.23", 16, "eth0"), "IP presence must roll back on DNS failure")
def test_dns_fail_closed_blocks_delete(tmp_path: Path):
entry = IpEntry(id="dns5", name="Lan App 5", ip="10.0.4.24", cidr=16, device="eth0", enabled=False)
resolver = FakeUsageResolver(mapping={})
dns = FakeDnsProvider(fail_delete=True)
service = build_service(tmp_path, entries=[entry], usage_resolver=resolver, dns_provider=dns)
failed = False
try:
service.delete_entry("dns5")
except ConflictError:
failed = True
assert_true(failed, "delete must fail-closed when DNS cleanup fails")
assert_true(len(service.list_entries().items) == 1, "entry must remain when delete fails")
def main():
test_exact_hostip_match_only()
with tempfile.TemporaryDirectory() as tmp:
tmp_path = Path(tmp)
test_disable_blocked_when_used(tmp_path)
test_disable_blocked_when_docker_check_fails(tmp_path)
test_delete_blocked_when_enabled(tmp_path)
test_reconcile_reapplies_enabled(tmp_path)
test_dns_upsert_on_enable_when_used(tmp_path)
test_dns_no_upsert_on_enable_when_unused(tmp_path)
test_dns_reconcile_deletes_when_no_longer_used(tmp_path)
test_dns_fail_closed_rolls_back_enable(tmp_path)
test_dns_fail_closed_blocks_delete(tmp_path)
print("Integration tests passed")
if __name__ == "__main__":
main()