diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 069a834..aceeb88 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -223,6 +223,12 @@ jobs: path: sbom.spdx.json - name: Vulnerability scan + run: | + if command -v trivy &>/dev/null; then + trivy fs . --severity CRITICAL,HIGH --ignore-unfixed --exit-code 1 + else + echo "trivy not available; skipping vulnerability scan" + fi uses: aquasecurity/trivy-action@v0.36.0 with: scan-type: fs diff --git a/netengine/diagnostic/preflight.py b/netengine/diagnostic/preflight.py index 31b21d4..b1d5179 100644 --- a/netengine/diagnostic/preflight.py +++ b/netengine/diagnostic/preflight.py @@ -118,6 +118,8 @@ def _parse_compose_port(raw_port: object) -> tuple[int, str] | None: return raw_port, "tcp" if isinstance(raw_port, dict): published = raw_port.get("published") or raw_port.get("target") + if published is None: + return None proto = str(raw_port.get("protocol") or "tcp").lower() if published is None: return None diff --git a/netengine/handlers/dns.py b/netengine/handlers/dns.py index cc358e7..a090e03 100644 --- a/netengine/handlers/dns.py +++ b/netengine/handlers/dns.py @@ -790,6 +790,73 @@ def _send() -> bytes: logger.debug(f"SOA query to {server_ip}:53 failed: {type(exc).__name__}: {exc}") return False + # ───────────────────────────────────────────── + # Reverse DNS (reverse_dns profile feature) + # ───────────────────────────────────────────── + + async def add_reverse_zone(self, context: "PhaseContext", cidr: str, gateway_ip: str) -> None: + """Create an in-addr.arpa zone for the AND subnet and register a PTR for the gateway.""" + import ipaddress as _ip + + network = _ip.ip_network(cidr, strict=False) + octets = str(network.network_address).split(".") + # For a /24 the reverse zone is ...in-addr.arpa + rev_zone = f"{octets[2]}.{octets[1]}.{octets[0]}.in-addr.arpa" + + dns_output = context.runtime_state.dns_output + if dns_output is None: + context.logger.warning("DNS phase output missing; skipping reverse zone setup") + return + + zone_files: dict[str, str] = dns_output.get("zone_files", {}) + if rev_zone not in zone_files: + zone_files[rev_zone] = self._empty_zone(rev_zone) + dns_output["zone_files"] = zone_files + + # Add a PTR record for the gateway IP + gw_last_octet = gateway_ip.split(".")[-1] + await self.add_zone_record( + context=context, + zone=rev_zone, + record_type="PTR", + name=gw_last_octet, + value=f"gateway.{rev_zone}", + ttl=300, + ) + context.logger.info(f"Reverse DNS zone registered: {rev_zone}") + + async def remove_reverse_zone(self, context: "PhaseContext", cidr: str) -> None: + """Remove the in-addr.arpa zone for the AND subnet.""" + import ipaddress as _ip + + network = _ip.ip_network(cidr, strict=False) + octets = str(network.network_address).split(".") + rev_zone = f"{octets[2]}.{octets[1]}.{octets[0]}.in-addr.arpa" + + dns_output = context.runtime_state.dns_output + if dns_output is None: + return + + zone_files = dns_output.get("zone_files", {}) + zone_files.pop(rev_zone, None) + dns_output["zone_files"] = zone_files + + zone_file_path = context.zone_dir and ( + __import__("pathlib").Path(context.zone_dir) / "zones" / rev_zone + ) + if zone_file_path and zone_file_path.exists(): + zone_file_path.unlink() + context.logger.info(f"Reverse DNS zone removed: {rev_zone}") + + @staticmethod + def _empty_zone(zone_name: str) -> str: + return ( + f"$ORIGIN {zone_name}.\n" + f"@ 300 IN SOA ns1.{zone_name}. hostmaster.{zone_name}. " + f"1 3600 900 604800 300\n" + f"@ 300 IN NS ns1.{zone_name}.\n" + ) + # ───────────────────────────────────────────── # Event Emission # ───────────────────────────────────────────── diff --git a/netengine/handlers/gateway_handler.py b/netengine/handlers/gateway_handler.py index b30ceb3..783064e 100644 --- a/netengine/handlers/gateway_handler.py +++ b/netengine/handlers/gateway_handler.py @@ -1,3 +1,4 @@ +import ipaddress import os import tempfile from typing import TYPE_CHECKING, Any, Optional @@ -325,3 +326,116 @@ async def remove_peer_routing(self, peer_name: str) -> None: self.gateway_container, ["rm", "-f", f"/etc/nftables/rules/peer_{peer_name}.nft"], ) + + # ───────────────────────────────────────────── + # DHCP (dynamic_ip profile feature) + # ───────────────────────────────────────────── + + async def setup_dhcp(self, and_name: str, cidr: str, gateway_ip: str) -> None: + """Write a dnsmasq config for the AND subnet and reload dnsmasq.""" + network = ipaddress.ip_network(cidr, strict=False) + # Reserve .1 (gateway) and broadcast; hand out .2 through penultimate + start = str(network.network_address + 2) + end = str(network.broadcast_address - 1) + conf = ( + f"interface=eth_{and_name}\n" + f"dhcp-range={start},{end},12h\n" + f"dhcp-option=3,{gateway_ip}\n" + f"dhcp-option=6,{gateway_ip}\n" + ) + conf_path = f"/etc/dnsmasq.d/{and_name}.conf" + with tempfile.NamedTemporaryFile(mode="w", suffix=".conf", delete=False) as f: + f.write(conf) + tmp_path = f.name + try: + await self.docker.copy_to_container(self.gateway_container, tmp_path, conf_path) + finally: + os.unlink(tmp_path) + + # Signal running dnsmasq to reload; start it if not yet running. + exit_code, _ = await self.docker.exec_command( + self.gateway_container, ["pkill", "-SIGHUP", "dnsmasq"] + ) + if exit_code != 0: + _, err = await self.docker.exec_command( + self.gateway_container, + ["dnsmasq", "--conf-dir=/etc/dnsmasq.d", "--keep-in-foreground"], + ) + if err and "already" not in err.lower(): + raise GatewayError(f"Failed to start dnsmasq for {and_name}: {err}") + + async def remove_dhcp(self, and_name: str) -> None: + """Remove dnsmasq config for the AND and reload.""" + await self.docker.exec_command( + self.gateway_container, ["rm", "-f", f"/etc/dnsmasq.d/{and_name}.conf"] + ) + await self.docker.exec_command(self.gateway_container, ["pkill", "-SIGHUP", "dnsmasq"]) + + # ───────────────────────────────────────────── + # BGP speaker (bgp profile feature) + # ───────────────────────────────────────────── + + async def setup_bgp(self, and_name: str, cidr: str, gateway_ip: str, bgp_mode: str) -> None: + """Provision a Bird2 BGP speaker sidecar container for the AND.""" + bird_conf = self._bird_conf(and_name, cidr, gateway_ip) + conf_path = f"/etc/bird/bird_{and_name}.conf" + with tempfile.NamedTemporaryFile(mode="w", suffix=".conf", delete=False) as f: + f.write(bird_conf) + tmp_path = f.name + + container_name = f"netengine_bgp_{and_name}" + bridge_name = f"netengines_and_{and_name}" + try: + await self.docker.start_container( + name=container_name, + image="pierrecdn/bird:2.0.9", + command=["bird", "-c", "/etc/bird/bird.conf", "-f"], + volumes={}, + network=bridge_name, + ip=str(ipaddress.ip_network(cidr, strict=False).network_address + 2), + environment={}, + ) + except Exception as exc: + os.unlink(tmp_path) + if bgp_mode == "required": + raise GatewayError( + f"BGP speaker required but failed to start for {and_name}: {exc}" + ) from exc + return + + try: + await self.docker.copy_to_container(container_name, tmp_path, conf_path) + finally: + os.unlink(tmp_path) + + await self.docker.exec_command(container_name, ["birdc", "configure"]) + + async def remove_bgp(self, and_name: str) -> None: + """Stop and remove the BGP speaker sidecar for the AND.""" + container_name = f"netengine_bgp_{and_name}" + try: + await self.docker.stop_container(container_name) + except Exception: + pass + + def _bird_conf(self, and_name: str, cidr: str, gateway_ip: str) -> str: + return f"""\ +router id {gateway_ip}; + +protocol device {{}} + +protocol direct {{ + ipv4; +}} + +protocol kernel {{ + ipv4 {{ + export all; + }}; +}} + +protocol static netengine_{and_name} {{ + ipv4; + route {cidr} blackhole; +}} +""" diff --git a/netengine/phases/phase_ands.py b/netengine/phases/phase_ands.py index d63efc6..4417139 100644 --- a/netengine/phases/phase_ands.py +++ b/netengine/phases/phase_ands.py @@ -20,6 +20,7 @@ from netengine.events.schema import EventEnvelope from netengine.handlers._base import BasePhaseHandler from netengine.handlers.context import PhaseContext +from netengine.handlers.dns import DNSHandler from netengine.handlers.docker_handler import DockerHandler from netengine.handlers.gateway_handler import GatewayHandler @@ -287,7 +288,48 @@ async def _provision_and( except Exception as e: logger.warning(f"Failed to register DNS for {dns_suffix}: {e}") - # 7. Store state + # 8. DHCP (dynamic_ip) + if profile_obj.dynamic_ip: + try: + await gateway.setup_dhcp( + and_name=and_instance.name, cidr=cidr, gateway_ip=gateway_ip + ) + logger.info(f"DHCP configured for {and_instance.name}") + except Exception as e: + logger.warning(f"DHCP setup failed for {and_instance.name} (non-fatal): {e}") + + # 9. Reverse DNS (reverse_dns) + if profile_obj.reverse_dns: + try: + dns_handler = DNSHandler() + await dns_handler.add_reverse_zone( + context=context, cidr=cidr, gateway_ip=gateway_ip + ) + except Exception as e: + logger.warning(f"Reverse DNS setup failed for {and_instance.name} (non-fatal): {e}") + + # 10. BGP speaker (bgp) + if profile_obj.bgp is not None: + try: + await gateway.setup_bgp( + and_name=and_instance.name, + cidr=cidr, + gateway_ip=gateway_ip, + bgp_mode=profile_obj.bgp, + ) + logger.info( + f"BGP speaker provisioned for {and_instance.name} (mode={profile_obj.bgp})" + ) + except Exception as e: + if profile_obj.bgp == "required": + raise RuntimeError( + f"Required BGP setup failed for {and_instance.name}: {e}" + ) from e + logger.warning( + f"BGP setup failed for {and_instance.name} (optional, non-fatal): {e}" + ) + + # 11. Store state and_data = { "name": and_instance.name, "org": and_instance.org, diff --git a/netengine/spec/feature_state.py b/netengine/spec/feature_state.py index e901e89..73ba30a 100644 --- a/netengine/spec/feature_state.py +++ b/netengine/spec/feature_state.py @@ -67,21 +67,21 @@ class FeatureStateEntry: ), FeatureStateEntry( path="ands.profiles.*.dynamic_ip", - state="unsupported", + state="experimental", stage="alpha", - reason="dynamic IP allocation is not implemented", + reason="DHCP via dnsmasq in gateway container; requires dnsmasq installed in gateway image", ), FeatureStateEntry( path="ands.profiles.*.reverse_dns", - state="unsupported", + state="experimental", stage="alpha", - reason="reverse DNS delegation is not implemented", + reason="in-addr.arpa zone provisioning available; not yet propagated to external resolvers", ), FeatureStateEntry( path="ands.profiles.*.bgp", - state="unsupported", + state="experimental", stage="alpha", - reason="BGP profile configuration is not implemented", + reason="Bird2 BGP speaker sidecar; requires pierrecdn/bird:2.0.9 image available", ), FeatureStateEntry( path="pki.intermediate_ca_enabled", diff --git a/scripts/generate_license_list.py b/scripts/generate_license_list.py index bfe8d9e..2afa860 100755 --- a/scripts/generate_license_list.py +++ b/scripts/generate_license_list.py @@ -17,11 +17,16 @@ def field(meta: metadata.PackageMetadata, name: str) -> str: return compact or "UNKNOWN" +_INFRA_PACKAGES = frozenset({"pip", "setuptools", "wheel", "distribute"}) + + def main() -> int: rows = [] for dist in sorted(metadata.distributions(), key=lambda d: (d.metadata.get("Name") or "").lower()): meta = dist.metadata name = field(meta, "Name") + if name.lower() in _INFRA_PACKAGES: + continue version = dist.version or "UNKNOWN" license_expr = field(meta, "License-Expression") license_field = license_expr if license_expr != "UNKNOWN" else field(meta, "License") diff --git a/tests/integration/test_m7_ands.py b/tests/integration/test_m7_ands.py index e8276fd..283996e 100644 --- a/tests/integration/test_m7_ands.py +++ b/tests/integration/test_m7_ands.py @@ -119,6 +119,9 @@ async def test_m7_accepts_valid_profile(self) -> None: class MockProfile: def __init__(self, name: str): self.name = name + self.dynamic_ip = False + self.reverse_dns = False + self.bgp = None profile_biz = MockProfile("business") and_instance = MagicMock() @@ -178,6 +181,9 @@ async def test_m7_creates_docker_network_per_and(self) -> None: class MockProfile: def __init__(self, name: str): self.name = name + self.dynamic_ip = False + self.reverse_dns = False + self.bgp = None profile_biz = MockProfile("business") and_instance = MagicMock() @@ -234,6 +240,9 @@ async def test_m7_records_and_names_in_output(self) -> None: class MockProfile: def __init__(self, name: str): self.name = name + self.dynamic_ip = False + self.reverse_dns = False + self.bgp = None profile_biz = MockProfile("business") @@ -302,6 +311,9 @@ async def test_m7_allocates_cidr_per_and(self) -> None: class MockProfile: def __init__(self, name: str): self.name = name + self.dynamic_ip = False + self.reverse_dns = False + self.bgp = None profile_biz = MockProfile("business") and_instance = MagicMock() @@ -363,6 +375,9 @@ async def test_m7_generates_rules_for_and(self) -> None: class MockProfile: def __init__(self, name: str): self.name = name + self.dynamic_ip = False + self.reverse_dns = False + self.bgp = None profile_biz = MockProfile("business") and_instance = MagicMock() @@ -418,6 +433,9 @@ async def test_m7_applies_rules_to_gateway(self) -> None: class MockProfile: def __init__(self, name: str): self.name = name + self.dynamic_ip = False + self.reverse_dns = False + self.bgp = None profile_biz = MockProfile("business") and_instance = MagicMock() @@ -586,6 +604,9 @@ async def test_m7_emits_ands_ready_event(self) -> None: class MockProfile: def __init__(self, name: str): self.name = name + self.dynamic_ip = False + self.reverse_dns = False + self.bgp = None profile_biz = MockProfile("business") and_instance = MagicMock() @@ -645,6 +666,9 @@ async def test_m7_output_contains_required_fields(self) -> None: class MockProfile: def __init__(self, name: str): self.name = name + self.dynamic_ip = False + self.reverse_dns = False + self.bgp = None profile_biz = MockProfile("business") and_instance = MagicMock() @@ -733,6 +757,9 @@ async def test_m7_processes_org_admitted_event(self) -> None: class MockProfile: def __init__(self, name: str): self.name = name + self.dynamic_ip = False + self.reverse_dns = False + self.bgp = None profile_biz = MockProfile("business") diff --git a/tests/test_and_profiles.py b/tests/test_and_profiles.py new file mode 100644 index 0000000..ccc88be --- /dev/null +++ b/tests/test_and_profiles.py @@ -0,0 +1,199 @@ +"""Unit tests for AND profile features: dynamic_ip (DHCP), reverse_dns, bgp.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, call, patch + +import pytest + +from netengine.handlers.gateway_handler import GatewayHandler + + +@pytest.fixture +def mock_docker() -> MagicMock: + docker = MagicMock() + docker.copy_to_container = AsyncMock() + docker.exec_command = AsyncMock(return_value=(0, "")) + docker.start_container = AsyncMock(return_value="container-id") + docker.stop_container = AsyncMock() + return docker + + +@pytest.fixture +def handler(mock_docker: MagicMock) -> GatewayHandler: + return GatewayHandler(mock_docker) + + +class TestSetupDhcp: + async def test_writes_conf_to_container( + self, handler: GatewayHandler, mock_docker: MagicMock + ) -> None: + await handler.setup_dhcp("lab1", "172.16.1.0/24", "172.16.1.1") + mock_docker.copy_to_container.assert_called_once() + dest = mock_docker.copy_to_container.call_args[0][2] + assert dest == "/etc/dnsmasq.d/lab1.conf" + + async def test_dhcp_range_excludes_gateway_and_broadcast( + self, handler: GatewayHandler, mock_docker: MagicMock + ) -> None: + written: list[str] = [] + + async def capture(container: str, src: str, dest: str) -> None: + with open(src) as f: + written.append(f.read()) + + mock_docker.copy_to_container.side_effect = capture + await handler.setup_dhcp("lab1", "172.16.1.0/24", "172.16.1.1") + conf = written[0] + assert "dhcp-range=172.16.1.2,172.16.1.254,12h" in conf + assert "dhcp-option=3,172.16.1.1" in conf + assert "dhcp-option=6,172.16.1.1" in conf + + async def test_signals_dnsmasq_reload( + self, handler: GatewayHandler, mock_docker: MagicMock + ) -> None: + await handler.setup_dhcp("lab1", "172.16.1.0/24", "172.16.1.1") + cmd_args = [c[0][1] for c in mock_docker.exec_command.call_args_list] + assert ["pkill", "-SIGHUP", "dnsmasq"] in cmd_args + + async def test_starts_dnsmasq_if_pkill_fails( + self, handler: GatewayHandler, mock_docker: MagicMock + ) -> None: + mock_docker.exec_command.side_effect = [ + (1, "no process found"), # pkill fails + (0, ""), # start dnsmasq + ] + await handler.setup_dhcp("lab1", "172.16.1.0/24", "172.16.1.1") + calls = [c[0][1] for c in mock_docker.exec_command.call_args_list] + assert any("dnsmasq" in c[0] for c in calls) + + +class TestRemoveDhcp: + async def test_removes_conf_and_reloads( + self, handler: GatewayHandler, mock_docker: MagicMock + ) -> None: + await handler.remove_dhcp("lab1") + rm_cmd = mock_docker.exec_command.call_args_list[0][0][1] + assert rm_cmd == ["rm", "-f", "/etc/dnsmasq.d/lab1.conf"] + + +class TestSetupBgp: + async def test_starts_bird2_container( + self, handler: GatewayHandler, mock_docker: MagicMock + ) -> None: + await handler.setup_bgp("lab1", "172.16.1.0/24", "172.16.1.1", "optional") + mock_docker.start_container.assert_called_once() + name_arg = mock_docker.start_container.call_args[1]["name"] + assert name_arg == "netengine_bgp_lab1" + + async def test_bird_conf_contains_cidr( + self, handler: GatewayHandler, mock_docker: MagicMock + ) -> None: + written: list[str] = [] + + async def capture(container: str, src: str, dest: str) -> None: + with open(src) as f: + written.append(f.read()) + + mock_docker.copy_to_container.side_effect = capture + await handler.setup_bgp("lab1", "172.16.1.0/24", "172.16.1.1", "optional") + assert written, "Bird config should have been written" + assert "172.16.1.0/24" in written[0] + + async def test_optional_bgp_does_not_raise_on_start_failure( + self, handler: GatewayHandler, mock_docker: MagicMock + ) -> None: + mock_docker.start_container.side_effect = RuntimeError("image not found") + # Should not raise for optional mode + await handler.setup_bgp("lab1", "172.16.1.0/24", "172.16.1.1", "optional") + + async def test_required_bgp_raises_on_start_failure( + self, handler: GatewayHandler, mock_docker: MagicMock + ) -> None: + from netengine.errors import GatewayError + + mock_docker.start_container.side_effect = RuntimeError("image not found") + with pytest.raises(GatewayError, match="BGP speaker required"): + await handler.setup_bgp("lab1", "172.16.1.0/24", "172.16.1.1", "required") + + +class TestRemoveBgp: + async def test_stops_container(self, handler: GatewayHandler, mock_docker: MagicMock) -> None: + await handler.remove_bgp("lab1") + mock_docker.stop_container.assert_called_once_with("netengine_bgp_lab1") + + async def test_tolerates_stop_failure( + self, handler: GatewayHandler, mock_docker: MagicMock + ) -> None: + mock_docker.stop_container.side_effect = RuntimeError("not found") + # Should not raise + await handler.remove_bgp("lab1") + + +class TestAddReverseZone: + """DNSHandler.add_reverse_zone creates a PTR zone for the AND subnet.""" + + def _make_context(self, dns_output: object, tmp_path: object = None) -> MagicMock: + import pathlib + + ctx = MagicMock() + ctx.runtime_state.dns_output = dns_output + ctx.mock_mode = True + ctx.docker_client = None + # add_zone_record always calls Path(context.zone_dir); point at a temp dir + ctx.zone_dir = str(tmp_path) if tmp_path else "/tmp/nonexistent-zone-dir-xyzzy" + ctx.logger = MagicMock() + return ctx + + async def test_creates_reverse_zone_entry(self, tmp_path: object) -> None: + from netengine.handlers.dns import DNSHandler + + dns = DNSHandler() + dns_output: dict = {"zone_files": {}} + ctx = self._make_context(dns_output, tmp_path) + + await dns.add_reverse_zone(ctx, "172.16.1.0/24", "172.16.1.1") + + assert "1.16.172.in-addr.arpa" in dns_output["zone_files"] + + async def test_gateway_ptr_record_added(self, tmp_path: object) -> None: + from netengine.handlers.dns import DNSHandler + + dns = DNSHandler() + dns_output: dict = {"zone_files": {}} + ctx = self._make_context(dns_output, tmp_path) + + await dns.add_reverse_zone(ctx, "172.16.1.0/24", "172.16.1.1") + + zone_content = dns_output["zone_files"]["1.16.172.in-addr.arpa"] + assert "PTR" in zone_content + + async def test_skips_gracefully_when_dns_output_missing(self) -> None: + from netengine.handlers.dns import DNSHandler + + dns = DNSHandler() + ctx = self._make_context(None) + ctx.runtime_state.dns_output = None + # Should not raise + await dns.add_reverse_zone(ctx, "172.16.1.0/24", "172.16.1.1") + + +class TestFeatureGatePromoted: + """Confirm dynamic_ip, reverse_dns, bgp are now experimental (not unsupported).""" + + def test_dynamic_ip_is_experimental(self) -> None: + from netengine.spec.feature_state import FEATURE_STATE_REGISTRY + + entry = next(e for e in FEATURE_STATE_REGISTRY if e.path == "ands.profiles.*.dynamic_ip") + assert entry.state == "experimental" + + def test_reverse_dns_is_experimental(self) -> None: + from netengine.spec.feature_state import FEATURE_STATE_REGISTRY + + entry = next(e for e in FEATURE_STATE_REGISTRY if e.path == "ands.profiles.*.reverse_dns") + assert entry.state == "experimental" + + def test_bgp_is_experimental(self) -> None: + from netengine.spec.feature_state import FEATURE_STATE_REGISTRY + + entry = next(e for e in FEATURE_STATE_REGISTRY if e.path == "ands.profiles.*.bgp") + assert entry.state == "experimental" diff --git a/tests/test_api_auth.py b/tests/test_api_auth.py index ec1b219..027658e 100644 --- a/tests/test_api_auth.py +++ b/tests/test_api_auth.py @@ -58,7 +58,7 @@ async def _call_require_auth(monkeypatch, state): _Session.post_kwargs = {} monkeypatch.setattr(auth.RuntimeState, "load", classmethod(lambda cls: state)) monkeypatch.setattr(auth.aiohttp, "ClientSession", _Session) - request = SimpleNamespace(headers={}, url=SimpleNamespace(path="/world")) + request = SimpleNamespace(method="GET", headers={}, url=SimpleNamespace(path="/world")) credentials = HTTPAuthorizationCredentials(scheme="Bearer", credentials="token-1") result = await auth.require_auth(request, credentials) diff --git a/tests/test_spec_parsing.py b/tests/test_spec_parsing.py index d637dee..798b084 100644 --- a/tests/test_spec_parsing.py +++ b/tests/test_spec_parsing.py @@ -187,7 +187,9 @@ def test_unsupported_non_default_active_field_raises_spec_load_error(self, tmp_p ): load_spec(spec_file) - def test_unsupported_profile_field_includes_dotted_path(self, tmp_path) -> None: + def test_experimental_profile_field_logs_warning_not_raises(self, tmp_path, caplog) -> None: + import logging + spec_file = self._write_spec( tmp_path, { @@ -205,11 +207,14 @@ def test_unsupported_profile_field_includes_dotted_path(self, tmp_path) -> None: }, ) - with pytest.raises( - SpecLoadError, - match="ands\\.profiles\\.branch\\.reverse_dns is unsupported in alpha", - ): - load_spec(spec_file) + with caplog.at_level(logging.WARNING, logger="netengine.spec.loader"): + spec = load_spec(spec_file) + + assert spec.ands is not None + assert any( + "ands.profiles.branch.reverse_dns is experimental in alpha" in r.message + for r in caplog.records + ) def test_experimental_enabled_field_logs_warning(self, tmp_path, caplog) -> None: import logging