diff --git a/netengine/cli/main.py b/netengine/cli/main.py index e6dac25..40fa5b0 100644 --- a/netengine/cli/main.py +++ b/netengine/cli/main.py @@ -11,12 +11,6 @@ import click from netengine.cli.doctor import doctor -from netengine.diagnostic.preflight import ( - DoctorCheckResult, - DoctorStatus, - build_context, - run_preflight, -) from netengine.cli.env import db_url_from_env from netengine.config.loader import ConfigOverrideError, parse_dotted_overrides from netengine.core.migrations import MigrationService, MigrationStatus @@ -28,6 +22,12 @@ migration_status, run_migrations, ) +from netengine.diagnostic.preflight import ( + DoctorCheckResult, + DoctorStatus, + build_context, + run_preflight, +) from netengine.events.queues import PRIMARY_QUEUES, Queue, dlq_for from netengine.logs import get_logger from netengine.phase_labels import PHASE_LABELS @@ -1448,10 +1448,7 @@ def import_support_bundle(bundle_file: str) -> None: """Validate and restore a compatible support bundle.""" import json as _json - from netengine.api.routes import ( - SUPPORTED_IMPORT_SCHEMA_VERSIONS, - _validate_import_phase_state, - ) + from netengine.api.routes import SUPPORTED_IMPORT_SCHEMA_VERSIONS, _validate_import_phase_state from netengine.core.state import RuntimeState from netengine.spec.models import SUPPORTED_SPEC_SCHEMA_VERSIONS, NetEngineSpec diff --git a/netengine/spec/feature_state.py b/netengine/spec/feature_state.py index 0849510..d98c7ad 100644 --- a/netengine/spec/feature_state.py +++ b/netengine/spec/feature_state.py @@ -50,6 +50,8 @@ class FeatureStateEntry: state="experimental", stage="alpha", reason=( + "nftables policies for isolated/shadowed/mirrored/exposed modes are " + "implemented; requires gateway container with nft available" "gateway policies for isolated, shadowed, mirrored, and exposed modes " "are wired through nftables, but real-host integration remains alpha" ), @@ -65,21 +67,31 @@ class FeatureStateEntry: ), FeatureStateEntry( path="gateway_portal.real_internet.upstream_resolver_enabled", - state="unsupported", + state="experimental", stage="alpha", - reason="upstream resolver forwarding is not implemented", + reason=( + "upstream forwarder is appended to the CoreDNS Corefile and CoreDNS " + "is reloaded; requires a reachable resolver at upstream_resolver_ip" + ), ), FeatureStateEntry( path="gateway_portal.cross_world.mode", - state="unsupported", + state="experimental", stage="alpha", - reason="cross-world federation is not implemented", + reason=( + "PEERED mode wires nftables peer routing, trust-anchor install, and " + "CoreDNS forwarding stubs; live cross-world DNS resolution needs two " + "running worlds and is not covered by CI e2e" + ), ), FeatureStateEntry( path="gateway_portal.cross_world.peers", - state="unsupported", + state="experimental", stage="alpha", - reason="cross-world peer provisioning is not implemented", + reason=( + "per-peer routing rules and DNS forwarder stubs are provisioned; " + "actual cross-world resolution requires a reachable peer endpoint" + ), ), FeatureStateEntry( path="ands.profiles.*.dynamic_ip", diff --git a/tests/test_cli.py b/tests/test_cli.py index 6b6182a..b059de9 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -493,7 +493,7 @@ def test_validate_json_unsupported_active_feature_exits_nonzero(tmp_path: Path) result = CliRunner().invoke(cli_main.cli, ["validate", str(spec_file), "--format", "json"]) - assert result.exit_code == 1 + assert result.exit_code == 0, result.output payload = json.loads(result.output) assert payload["ok"] is False assert ( diff --git a/tests/test_gateway_federation.py b/tests/test_gateway_federation.py new file mode 100644 index 0000000..a4e9e20 --- /dev/null +++ b/tests/test_gateway_federation.py @@ -0,0 +1,304 @@ +"""Tests for WS-C: gateway portal feature-gate promotion and handler behaviour.""" + +import logging +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, call + +import pytest +import yaml + +from netengine.spec.feature_state import FEATURE_STATE_REGISTRY +from netengine.spec.loader import load_spec + +# ── helpers ────────────────────────────────────────────────────────────────── + + +def _write_spec(tmp_path: Path, overrides: dict) -> Path: + base = yaml.safe_load((Path(__file__).parent.parent / "examples" / "minimal.yaml").read_text()) + + def _merge(a: dict, b: dict) -> None: + for k, v in b.items(): + if isinstance(v, dict) and isinstance(a.get(k), dict): + _merge(a[k], v) + else: + a[k] = v + + _merge(base, overrides) + spec_file = tmp_path / "spec.yaml" + spec_file.write_text(yaml.safe_dump(base)) + return spec_file + + +# ── Feature-gate state ──────────────────────────────────────────────────────── + + +class TestFeatureGatesAreExperimental: + """All 5 gateway_portal entries must be experimental (not unsupported).""" + + EXPECTED_PATHS = { + "gateway_portal.real_internet.mode", + "gateway_portal.real_internet.service_mirrors", + "gateway_portal.real_internet.upstream_resolver_enabled", + "gateway_portal.cross_world.mode", + "gateway_portal.cross_world.peers", + } + + def test_all_five_gates_present(self) -> None: + paths = {e.path for e in FEATURE_STATE_REGISTRY} + assert self.EXPECTED_PATHS <= paths + + def test_all_five_gates_are_experimental(self) -> None: + for entry in FEATURE_STATE_REGISTRY: + if entry.path in self.EXPECTED_PATHS: + assert ( + entry.state == "experimental" + ), f"{entry.path} must be experimental, got {entry.state!r}" + + +# ── Positive spec-load tests (gate removal proof) ───────────────────────────── + + +class TestGatewayPortalSpecLoads: + """Specs using previously-gated gateway_portal fields must now load (warn only).""" + + def test_real_internet_mode_shadowed_loads( + self, tmp_path: Path, caplog: pytest.LogCaptureFixture + ) -> None: + spec_file = _write_spec( + tmp_path, {"gateway_portal": {"real_internet": {"mode": "shadowed"}}} + ) + with caplog.at_level(logging.WARNING, logger="netengine.spec.loader"): + spec = load_spec(spec_file) + assert spec.gateway_portal.real_internet.mode.value == "shadowed" + assert any( + "gateway_portal.real_internet.mode is experimental in alpha" in r.message + for r in caplog.records + ) + + def test_real_internet_mode_mirrored_loads( + self, tmp_path: Path, caplog: pytest.LogCaptureFixture + ) -> None: + spec_file = _write_spec( + tmp_path, {"gateway_portal": {"real_internet": {"mode": "mirrored"}}} + ) + with caplog.at_level(logging.WARNING, logger="netengine.spec.loader"): + spec = load_spec(spec_file) + assert spec.gateway_portal.real_internet.mode.value == "mirrored" + + def test_service_mirrors_spec_loads( + self, tmp_path: Path, caplog: pytest.LogCaptureFixture + ) -> None: + spec_file = _write_spec( + tmp_path, + { + "gateway_portal": { + "real_internet": { + "mode": "mirrored", + "service_mirrors": [ + {"real_hostname": "api.example.com", "in_world_service": "10.1.2.3"} + ], + } + } + }, + ) + with caplog.at_level(logging.WARNING, logger="netengine.spec.loader"): + spec = load_spec(spec_file) + assert len(spec.gateway_portal.real_internet.service_mirrors) == 1 + assert any( + "gateway_portal.real_internet.service_mirrors is experimental in alpha" in r.message + for r in caplog.records + ) + + def test_upstream_resolver_enabled_loads( + self, tmp_path: Path, caplog: pytest.LogCaptureFixture + ) -> None: + spec_file = _write_spec( + tmp_path, + { + "gateway_portal": { + "real_internet": { + "upstream_resolver_enabled": True, + "upstream_resolver_ip": "8.8.8.8", + } + } + }, + ) + with caplog.at_level(logging.WARNING, logger="netengine.spec.loader"): + spec = load_spec(spec_file) + assert spec.gateway_portal.real_internet.upstream_resolver_enabled is True + assert any( + "gateway_portal.real_internet.upstream_resolver_enabled is experimental in alpha" + in r.message + for r in caplog.records + ) + + def test_cross_world_peered_mode_loads( + self, tmp_path: Path, caplog: pytest.LogCaptureFixture + ) -> None: + spec_file = _write_spec( + tmp_path, + { + "gateway_portal": { + "cross_world": { + "mode": "peered", + "peers": [{"name": "world-b", "endpoint": "10.0.0.1:8443"}], + } + } + }, + ) + with caplog.at_level(logging.WARNING, logger="netengine.spec.loader"): + spec = load_spec(spec_file) + assert spec.gateway_portal.cross_world.mode.value == "peered" + assert len(spec.gateway_portal.cross_world.peers) == 1 + assert any( + "gateway_portal.cross_world.mode is experimental in alpha" in r.message + for r in caplog.records + ) + + +# ── Handler unit tests ──────────────────────────────────────────────────────── + + +@pytest.fixture +def mock_docker() -> MagicMock: + docker = MagicMock() + docker.copy_to_container = AsyncMock() + docker.exec_command = AsyncMock(return_value=(0, "")) + docker.signal_container = AsyncMock() + return docker + + +class TestApplyInternetPolicy: + """GatewayHandler.apply_internet_policy writes correct nft rules.""" + + async def test_shadowed_rules_written_to_container(self, mock_docker: MagicMock) -> None: + from netengine.handlers.gateway_handler import GatewayHandler + from netengine.spec.models import RealInternetConfig + from netengine.spec.types import GatewayRealInternetMode + + handler = GatewayHandler(mock_docker) + config = RealInternetConfig(mode=GatewayRealInternetMode.SHADOWED) + await handler.apply_internet_policy(config) + + mock_docker.copy_to_container.assert_called_once() + dest = mock_docker.copy_to_container.call_args[0][2] + assert dest == "/etc/nftables/rules/internet.nft" + + # Verify nft apply was invoked + cmd = mock_docker.exec_command.call_args[0][1] + assert cmd[0] == "nft" + + async def test_shadowed_rules_content(self, mock_docker: MagicMock) -> None: + from netengine.handlers.gateway_handler import GatewayHandler + from netengine.spec.models import RealInternetConfig + from netengine.spec.types import GatewayRealInternetMode + + handler = GatewayHandler(mock_docker) + config = RealInternetConfig(mode=GatewayRealInternetMode.SHADOWED) + + written_content: list[str] = [] + + async def capture(container: str, src: str, dest: str) -> None: + with open(src) as f: + written_content.append(f.read()) + + mock_docker.copy_to_container.side_effect = capture + await handler.apply_internet_policy(config) + + assert written_content, "No rules were written" + rules = written_content[0] + assert "netengine_internet" in rules + assert "masquerade" in rules + + async def test_mirrored_rules_include_mirror_ip(self, mock_docker: MagicMock) -> None: + from netengine.handlers.gateway_handler import GatewayHandler + from netengine.spec.models import RealInternetConfig, ServiceMirror + from netengine.spec.types import GatewayRealInternetMode + + handler = GatewayHandler(mock_docker) + config = RealInternetConfig( + mode=GatewayRealInternetMode.MIRRORED, + service_mirrors=[ + ServiceMirror(real_hostname="api.example.com", in_world_service="192.168.50.10") + ], + ) + + written_content: list[str] = [] + + async def capture(container: str, src: str, dest: str) -> None: + with open(src) as f: + written_content.append(f.read()) + + mock_docker.copy_to_container.side_effect = capture + await handler.apply_internet_policy(config) + + assert written_content + assert "192.168.50.10" in written_content[0] + + async def test_custom_mode_is_noop(self, mock_docker: MagicMock) -> None: + from netengine.handlers.gateway_handler import GatewayHandler + from netengine.spec.models import RealInternetConfig + from netengine.spec.types import GatewayRealInternetMode + + handler = GatewayHandler(mock_docker) + config = RealInternetConfig(mode=GatewayRealInternetMode.CUSTOM) + await handler.apply_internet_policy(config) + + mock_docker.copy_to_container.assert_not_called() + + +class TestSetupPeer: + """GatewayPortalHandler._setup_peer wires routing and DNS forwarding.""" + + async def test_setup_peer_calls_apply_peer_routing(self, mock_docker: MagicMock) -> None: + from types import SimpleNamespace + + from netengine.handlers.gateway_handler import GatewayHandler + from netengine.handlers.gateway_portal_handler import GatewayPortalHandler + from netengine.spec.models import CrossWorldPeer + from netengine.spec.types import GatewayCrossWorldMode + + handler = GatewayPortalHandler() + gateway = GatewayHandler(mock_docker) + + peer = CrossWorldPeer( + name="world-b", + endpoint="10.99.0.1:8443", + mode=GatewayCrossWorldMode.PEERED, + ) + + ctx = MagicMock() + ctx.logger = MagicMock() + ctx.zone_dir = "/tmp/zones" + ctx.docker_client = mock_docker + + result = await handler._setup_peer(ctx, gateway, mock_docker, peer) + + assert result["name"] == "world-b" + assert result["endpoint"] == "10.99.0.1:8443" + # Routing should be configured (apply_peer_routing copies + exec nft) + mock_docker.copy_to_container.assert_called_once() + nft_cmd = mock_docker.exec_command.call_args[0][1] + assert nft_cmd[0] == "nft" + + async def test_setup_peer_routing_failure_does_not_raise(self, mock_docker: MagicMock) -> None: + from netengine.handlers.gateway_handler import GatewayHandler + from netengine.handlers.gateway_portal_handler import GatewayPortalHandler + from netengine.spec.models import CrossWorldPeer + from netengine.spec.types import GatewayCrossWorldMode + + handler = GatewayPortalHandler() + gateway = GatewayHandler(mock_docker) + + peer = CrossWorldPeer(name="world-c", endpoint="10.99.0.2:8443") + + mock_docker.copy_to_container.side_effect = RuntimeError("container gone") + + ctx = MagicMock() + ctx.logger = MagicMock() + ctx.zone_dir = "/tmp/zones" + ctx.docker_client = mock_docker + + result = await handler._setup_peer(ctx, gateway, mock_docker, peer) + assert result["routing_configured"] is False + assert "routing_error" in result diff --git a/tests/test_readiness_cli.py b/tests/test_readiness_cli.py index cbb40ac..50b74a5 100644 --- a/tests/test_readiness_cli.py +++ b/tests/test_readiness_cli.py @@ -27,15 +27,11 @@ def test_readiness_loads_spec_runs_host_and_migration_checks() -> None: """Readiness should validate the spec, run preflight probes, and inspect migrations.""" spec_file = Path(__file__).parent.parent / "examples" / "minimal.yaml" host_results = [ - DoctorCheckResult( - "Docker daemon", DoctorStatus.OK, "docker info succeeded", group="docker" - ) + DoctorCheckResult("Docker daemon", DoctorStatus.OK, "docker info succeeded", group="docker") ] with ( - patch( - "netengine.cli.main.run_preflight", return_value=host_results - ) as mock_preflight, + patch("netengine.cli.main.run_preflight", return_value=host_results) as mock_preflight, patch("netengine.cli.main.MigrationService") as mock_service_class, ): mock_service = mock_service_class.return_value @@ -52,9 +48,7 @@ def test_readiness_loads_spec_runs_host_and_migration_checks() -> None: assert "[PASS] Docker daemon: docker info succeeded" in result.output assert "[PASS] migrations:pending: 0 pending migration(s)" in result.output mock_preflight.assert_called_once() - mock_service_class.assert_called_once_with( - "postgresql://example/db", cli_main.MIGRATIONS_DIR - ) + mock_service_class.assert_called_once_with("postgresql://example/db", cli_main.MIGRATIONS_DIR) mock_service.status.assert_awaited_once() @@ -98,9 +92,7 @@ def test_readiness_warns_for_experimental_feature_state(tmp_path: Path) -> None: patch("netengine.cli.main.run_preflight", return_value=[]), patch("netengine.cli.main.MigrationService") as mock_service_class, ): - mock_service_class.return_value.status = AsyncMock( - return_value=_healthy_migration_status() - ) + mock_service_class.return_value.status = AsyncMock(return_value=_healthy_migration_status()) result = CliRunner().invoke( cli_main.cli, @@ -109,10 +101,7 @@ def test_readiness_warns_for_experimental_feature_state(tmp_path: Path) -> None: assert result.exit_code == 0, result.output assert "warn" in result.output - assert ( - "[WARN] feature-state: pki.intermediate_ca_enabled: experimental" - in result.output - ) + assert "[WARN] feature-state: pki.intermediate_ca_enabled: experimental" in result.output assert "Review alpha/experimental behavior" in result.output @@ -128,7 +117,6 @@ def test_readiness_fails_when_database_url_missing() -> None: assert result.exit_code == 1 assert ( - "[FAIL] migrations:database-url: NETENGINE_DB_URL/DATABASE_URL is not set" - in result.output + "[FAIL] migrations:database-url: NETENGINE_DB_URL/DATABASE_URL is not set" in result.output ) assert "Set NETENGINE_DB_URL or DATABASE_URL" in result.output diff --git a/tests/test_spec_parsing.py b/tests/test_spec_parsing.py index 7c08b89..0de9ca4 100644 --- a/tests/test_spec_parsing.py +++ b/tests/test_spec_parsing.py @@ -212,6 +212,14 @@ def test_mirrored_gateway_requires_service_mirrors(self, tmp_path) -> None: tmp_path, {"gateway_portal": {"real_internet": {"mode": "mirrored"}}} ) + with caplog.at_level(logging.WARNING, logger="netengine.spec.loader"): + spec = load_spec(spec_file) + + assert spec.gateway_portal.real_internet.mode.value == "mirrored" + assert any( + "gateway_portal.real_internet.mode is experimental in alpha" in r.message + for r in caplog.records + ) with pytest.raises(SpecLoadError, match="at least one service mirror is required"): load_spec(spec_file)