From ab312c1495ec9d87f991d4c90741a1a9c0bc0016 Mon Sep 17 00:00:00 2001 From: aidankhogg Date: Mon, 29 Jun 2026 22:53:44 +0100 Subject: [PATCH] Add spec subnet checks to doctor --- README.md | 6 +- docs/alpha-quickstart.md | 6 +- docs/troubleshooting.md | 4 +- netengine/cli/doctor.py | 65 ++++++++++++-- netengine/cli/main.py | 29 ++++-- netengine/diagnostic/preflight.py | 106 +++++++++++++++++----- tests/test_doctor.py | 141 ++++++++++++++++++++++++++---- 7 files changed, 299 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index dcee97c..d702701 100644 --- a/README.md +++ b/README.md @@ -50,8 +50,8 @@ cd NetEngine # 2. Install dependencies poetry install -# 3. Verify host prerequisites -poetry run netengine doctor +# 3. Verify host prerequisites and requested world subnets +poetry run netengine doctor --spec examples/minimal.yaml # 4. Start local Postgres + pgmq (includes pgmq extension pre-installed) docker compose up -d postgres @@ -63,7 +63,7 @@ poetry run python -m netengine.utils.run_migrations poetry run netengine up examples/minimal.yaml ``` -If you only want host/container checks before configuring Postgres, run `poetry run netengine doctor --skip-db`. Check status at any time: +If you only want host/container checks before configuring Postgres, run `poetry run netengine doctor --skip-db`. Add `--spec examples/minimal.yaml` (or pass the spec as a positional argument) to include subnet-overlap checks for `spec.substrate.networks[*].subnet` before bootstrapping. Check status at any time: ```bash poetry run netengine status diff --git a/docs/alpha-quickstart.md b/docs/alpha-quickstart.md index ecc3845..059325d 100644 --- a/docs/alpha-quickstart.md +++ b/docs/alpha-quickstart.md @@ -16,15 +16,15 @@ This guide is the focused alpha operator path. The root `README.md` keeps the hi git clone https://github.com/Forebase/NetEngine.git cd NetEngine poetry install -poetry run netengine doctor --skip-db +poetry run netengine doctor --skip-db --spec examples/minimal.yaml docker compose up -d postgres poetry run python -m netengine.utils.run_migrations -poetry run netengine doctor +poetry run netengine doctor --spec examples/minimal.yaml poetry run netengine up examples/minimal.yaml poetry run netengine status ``` -Use `NETENGINE_MOCK=true` when you want to exercise orchestration and spec validation without creating Docker, DNS, PKI, or identity resources. +Use `poetry run netengine doctor --spec examples/minimal.yaml` before bootstrapping a world to check host prerequisites plus Docker subnet conflicts against `spec.substrate.networks[*].subnet`. Use `NETENGINE_MOCK=true` when you want to exercise orchestration and spec validation without creating Docker, DNS, PKI, or identity resources. ## Pre-release mock smoke test diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 628feaf..d84acec 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -3,7 +3,7 @@ Start with the commands below before changing state: ```bash -poetry run netengine doctor +poetry run netengine doctor --spec examples/minimal.yaml poetry run netengine status poetry run netengine diagnose poetry run netengine events @@ -22,7 +22,7 @@ Do not manually mark phases complete. Fix the underlying dependency and re-run ` ## Docker unavailable or stale resources -If Docker is expected to be available, start Docker Desktop or the Docker daemon and rerun `doctor`. If you intentionally want no infrastructure side effects, set mock mode: +If Docker is expected to be available, start Docker Desktop or the Docker daemon and rerun `doctor`. To catch Docker subnet conflicts before `up`, run `poetry run netengine doctor --spec `; if it reports that an existing Docker network reuses or overlaps a requested world subnet, remove the stale network with `docker network rm ` or choose a different subnet in the spec. If you intentionally want no infrastructure side effects, set mock mode: ```bash NETENGINE_MOCK=true poetry run netengine up examples/minimal.yaml diff --git a/netengine/cli/doctor.py b/netengine/cli/doctor.py index c637cd6..edd5a46 100644 --- a/netengine/cli/doctor.py +++ b/netengine/cli/doctor.py @@ -25,6 +25,7 @@ build_context, run_preflight, ) +from netengine.spec.loader import SpecLoadError, load_spec __all__ = [ "DoctorCheckResult", @@ -55,13 +56,29 @@ def run_checks( - db_url: str | None, state_file: Path, *, skip_db: bool = False + db_url: str | None, + state_file: Path, + *, + skip_db: bool = False, + spec_subnets: tuple[str, ...] = (), ) -> list[DoctorCheckResult]: """Run host-readiness checks without requiring a loaded NetEngine spec.""" # Keep monkeypatches of the legacy CLI module effective for callers/tests. _preflight._run = _run _preflight._can_bind = _can_bind - return _preflight.run_checks(db_url, state_file, skip_db=skip_db) + return _preflight.run_checks( + db_url, state_file, skip_db=skip_db, spec_subnets=spec_subnets + ) + + +def _spec_subnets_from_file(spec_path: Path) -> tuple[str, ...]: + """Load a world spec and return substrate network subnets for doctor checks.""" + spec = load_spec(str(spec_path)) + return tuple( + str(network.subnet) + for network in spec.substrate.networks.values() + if getattr(network, "subnet", None) + ) def _print_report(results: Iterable[DoctorCheckResult]) -> None: @@ -81,10 +98,48 @@ def _print_report(results: Iterable[DoctorCheckResult]) -> None: help="Runtime state file path.", ) @click.option("--json", "as_json", is_flag=True, help="Output machine-readable JSON.") -@click.option("--skip-db", is_flag=True, help="Skip database connectivity and pgmq checks.") -def doctor(db_url: str | None, state_file: Path, as_json: bool, skip_db: bool) -> None: +@click.option( + "--skip-db", is_flag=True, help="Skip database connectivity and pgmq checks." +) +@click.option( + "--spec", + "spec_option", + type=click.Path(exists=True, dir_okay=False, path_type=Path), + help="World spec whose substrate subnets should be checked for Docker conflicts.", +) +@click.argument( + "spec_arg", + required=False, + type=click.Path(exists=True, dir_okay=False, path_type=Path), +) +def doctor( + db_url: str | None, + state_file: Path, + as_json: bool, + skip_db: bool, + spec_option: Path | None, + spec_arg: Path | None, +) -> None: """Run local host preflight checks before booting; use diagnose for world health.""" - results = run_checks(db_url, state_file, skip_db=skip_db) + if spec_option and spec_arg: + raise click.UsageError( + "provide a spec path either as --spec or as the positional argument, not both" + ) + + spec_path = spec_option or spec_arg + spec_subnets: tuple[str, ...] = () + if spec_path is not None: + try: + spec_subnets = _spec_subnets_from_file(spec_path) + except SpecLoadError as exc: + raise click.ClickException(f"spec validation failed: {exc}") from exc + + if spec_path is None: + results = run_checks(db_url, state_file, skip_db=skip_db) + else: + results = run_checks( + db_url, state_file, skip_db=skip_db, spec_subnets=spec_subnets + ) if as_json: click.echo(json.dumps([asdict(r) for r in results], indent=2)) else: diff --git a/netengine/cli/main.py b/netengine/cli/main.py index 369f895..758a081 100644 --- a/netengine/cli/main.py +++ b/netengine/cli/main.py @@ -86,7 +86,9 @@ def _load_spec_for_cli( ): """Load a spec using the same composition semantics as ``up``.""" overrides = _parse_set_overrides(set_values) - feature_state_kwargs = {} if validate_feature_states else {"validate_feature_states": False} + feature_state_kwargs = ( + {} if validate_feature_states else {"validate_feature_states": False} + ) if environment: return load_spec_with_environment( spec_file, @@ -95,7 +97,9 @@ def _load_spec_for_cli( **feature_state_kwargs, ) if overrides: - return load_spec_with_composition(spec_file, overrides=overrides, **feature_state_kwargs) + return load_spec_with_composition( + spec_file, overrides=overrides, **feature_state_kwargs + ) return load_spec(spec_file, **feature_state_kwargs) @@ -478,7 +482,16 @@ async def _readiness( group="spec", ) ) - ctx = build_context(db_url, state_file, skip_db=skip_db) + ctx = build_context( + db_url, + state_file, + skip_db=skip_db, + spec_subnets=tuple( + str(network.subnet) + for network in spec.substrate.networks.values() + if getattr(network, "subnet", None) + ), + ) results.extend(run_preflight(ctx)) results.extend(await _check_migration_readiness(db_url)) results.extend(_feature_state_readiness_results(spec)) @@ -549,7 +562,11 @@ def validate( ) except SpecLoadError as exc: if output_format == "json": - click.echo(json.dumps({"ok": False, "error": str(exc), "feature_states": []}, indent=2)) + click.echo( + json.dumps( + {"ok": False, "error": str(exc), "feature_states": []}, indent=2 + ) + ) else: click.echo(f"Spec validation failed: {exc}", err=True) sys.exit(1) @@ -570,7 +587,9 @@ def validate( ) else: if unsupported: - click.echo("Spec validation failed: Unsupported spec features enabled:", err=True) + click.echo( + "Spec validation failed: Unsupported spec features enabled:", err=True + ) for item in unsupported: click.echo( f" - {item['path']} is {item['state']} in {item['stage']}: {item['reason']}", diff --git a/netengine/diagnostic/preflight.py b/netengine/diagnostic/preflight.py index 1fd83d7..6c1c3eb 100644 --- a/netengine/diagnostic/preflight.py +++ b/netengine/diagnostic/preflight.py @@ -59,6 +59,7 @@ class DoctorContext: required_commands: tuple[str, ...] = ("docker",) optional_commands: tuple[str, ...] = ("step",) feature_flags: dict[str, bool] | None = None + spec_subnets: tuple[str, ...] = () DoctorProbe = Callable[[DoctorContext], DoctorCheckResult | Iterable[DoctorCheckResult]] @@ -114,8 +115,12 @@ def _repo_root() -> Path: return Path(__file__).resolve().parents[2] -def _run(command: list[str], *, timeout: float = 8.0) -> subprocess.CompletedProcess[str]: - return subprocess.run(command, capture_output=True, text=True, timeout=timeout, check=False) +def _run( + command: list[str], *, timeout: float = 8.0 +) -> subprocess.CompletedProcess[str]: + return subprocess.run( + command, capture_output=True, text=True, timeout=timeout, check=False + ) def _compose_config(project_root: Path | None = None) -> dict[str, Any]: @@ -160,12 +165,20 @@ def _compose_ports_and_resources( config = _compose_config(project_root) ports: set[tuple[int, str]] = {(p.port, p.proto) for p in KNOWN_LOCAL_PORTS} containers: set[str] = set(KNOWN_DOCKER_CONTAINERS) - volumes: set[str] = set(KNOWN_DOCKER_VOLUMES) | set((config.get("volumes") or {}).keys()) - networks: set[str] = set(KNOWN_DOCKER_NETWORKS) | set((config.get("networks") or {}).keys()) + volumes: set[str] = set(KNOWN_DOCKER_VOLUMES) | set( + (config.get("volumes") or {}).keys() + ) + networks: set[str] = set(KNOWN_DOCKER_NETWORKS) | set( + (config.get("networks") or {}).keys() + ) for service_name, service in (config.get("services") or {}).items(): containers.add(service.get("container_name") or f"netengine-{service_name}-1") for volume in service.get("volumes") or []: - if isinstance(volume, str) and volume and not volume.startswith((".", "/", "~")): + if ( + isinstance(volume, str) + and volume + and not volume.startswith((".", "/", "~")) + ): volumes.add(volume.split(":", 1)[0]) for port in service.get("ports") or []: parsed = _parse_compose_port(port) @@ -218,7 +231,9 @@ def _check_command(name: str, *, required: bool = True) -> DoctorCheckResult: path = shutil.which(name) return DoctorCheckResult( f"command:{name}", - DoctorStatus.OK if path else (DoctorStatus.FAIL if required else DoctorStatus.SKIP), + DoctorStatus.OK + if path + else (DoctorStatus.FAIL if required else DoctorStatus.SKIP), path or "not found on PATH", None if path else f"Install {name} or add it to PATH.", "host", @@ -310,7 +325,11 @@ def _check_psql(db_url: str, sql: str, name: str, *, hint: str) -> DoctorCheckRe detail = (result.stdout or "ok").strip() if name == "pgmq extension" and detail != "pgmq": return DoctorCheckResult( - name, DoctorStatus.FAIL, "pgmq extension is not installed", hint, "database" + name, + DoctorStatus.FAIL, + "pgmq extension is not installed", + hint, + "database", ) return DoctorCheckResult(name, DoctorStatus.OK, detail, group="database") return DoctorCheckResult( @@ -325,9 +344,13 @@ def _check_psql(db_url: str, sql: str, name: str, *, hint: str) -> DoctorCheckRe def _check_dir_writable(path: Path, name: str) -> DoctorCheckResult: try: path.mkdir(parents=True, exist_ok=True) - with tempfile.NamedTemporaryFile(dir=path, prefix=".netengine-doctor-", delete=True): + with tempfile.NamedTemporaryFile( + dir=path, prefix=".netengine-doctor-", delete=True + ): pass - return DoctorCheckResult(name, DoctorStatus.OK, f"writable: {path}", group="filesystem") + return DoctorCheckResult( + name, DoctorStatus.OK, f"writable: {path}", group="filesystem" + ) except Exception as exc: return DoctorCheckResult( name, @@ -341,7 +364,10 @@ def _check_dir_writable(path: Path, name: str) -> DoctorCheckResult: def _check_state_file(path: Path) -> DoctorCheckResult: if not path.exists(): return DoctorCheckResult( - "State file", DoctorStatus.OK, f"no existing state at {path}", group="filesystem" + "State file", + DoctorStatus.OK, + f"no existing state at {path}", + group="filesystem", ) try: data = json.loads(path.read_text()) @@ -375,19 +401,27 @@ def _can_bind(port: int, proto: str) -> bool: def _check_port(port: int, proto: str) -> DoctorCheckResult: name = f"port:{port}/{proto}" - label = next((p.label for p in KNOWN_LOCAL_PORTS if p.port == port and p.proto == proto), None) + label = next( + (p.label for p in KNOWN_LOCAL_PORTS if p.port == port and p.proto == proto), + None, + ) detail_suffix = f" ({label})" if label else "" try: _can_bind(port, proto) return DoctorCheckResult( - name, DoctorStatus.OK, f"available on 127.0.0.1{detail_suffix}", group="ports" + name, + DoctorStatus.OK, + f"available on 127.0.0.1{detail_suffix}", + group="ports", ) except PermissionError as exc: return DoctorCheckResult( name, DoctorStatus.WARN, f"permission denied while probing{detail_suffix}: {exc}", - _DNS_PORT_HINT if port == 53 else "Run with privileges or check listeners manually.", + _DNS_PORT_HINT + if port == 53 + else "Run with privileges or check listeners manually.", "ports", required=False, ) @@ -411,9 +445,13 @@ def _docker_names(kind: str) -> set[str]: if kind == "container": result = _run(["docker", "ps", "--format", "{{.Names}}"], timeout=2.0) elif kind == "volume": - result = _run(["docker", "volume", "ls", "--format", "{{.Name}}"], timeout=2.0) + result = _run( + ["docker", "volume", "ls", "--format", "{{.Name}}"], timeout=2.0 + ) else: - result = _run(["docker", "network", "ls", "--format", "{{.Name}}"], timeout=2.0) + result = _run( + ["docker", "network", "ls", "--format", "{{.Name}}"], timeout=2.0 + ) except Exception: return set() return ( @@ -431,13 +469,16 @@ def _check_docker_subnet_conflicts(ctx: DoctorContext) -> DoctorCheckResult: if isinstance(network_cfg, dict): for ipam_config in (network_cfg.get("ipam") or {}).get("config") or []: if isinstance(ipam_config, dict) and "subnet" in ipam_config: - ne_subnets.append(ipam_config["subnet"]) + ne_subnets.append(str(ipam_config["subnet"])) + for subnet in ctx.spec_subnets: + if subnet not in ne_subnets: + ne_subnets.append(subnet) if not ne_subnets: return DoctorCheckResult( "Docker subnet conflicts", DoctorStatus.SKIP, - "no subnets defined in compose config", + "no subnets defined in compose config or spec", group="docker", required=False, ) @@ -482,15 +523,18 @@ def _check_docker_subnet_conflicts(ctx: DoctorContext) -> DoctorCheckResult: continue for ne_net in ne_networks: if existing.overlaps(ne_net): - conflicts.append(f"{name} ({subnet_str}) overlaps {ne_net}") + relation = "reuses" if existing == ne_net else "overlaps" + conflicts.append( + f"Docker network {name} ({subnet_str}) {relation} requested subnet {ne_net}" + ) if conflicts: return DoctorCheckResult( "Docker subnet conflicts", DoctorStatus.WARN, "; ".join(conflicts), - "Remove conflicting networks with" - " `docker network rm ` or `docker network prune`.", + "Remove conflicting Docker networks with" + " `docker network rm ` or choose non-overlapping subnets in the world spec.", "docker", required=False, ) @@ -514,7 +558,11 @@ def _check_docker_subnet_conflicts(ctx: DoctorContext) -> DoctorCheckResult: def _check_docker_conflicts(ctx: DoctorContext) -> list[DoctorCheckResult]: _, containers, volumes, networks = _compose_ports_and_resources(ctx.project_root) checks = [] - for kind, expected in (("container", containers), ("volume", volumes), ("network", networks)): + for kind, expected in ( + ("container", containers), + ("volume", volumes), + ("network", networks), + ): conflicts = sorted(expected & _docker_names(kind)) checks.append( DoctorCheckResult( @@ -522,7 +570,9 @@ def _check_docker_conflicts(ctx: DoctorContext) -> list[DoctorCheckResult]: ( DoctorStatus.FAIL if kind == "container" and conflicts - else DoctorStatus.WARN if conflicts else DoctorStatus.OK + else DoctorStatus.WARN + if conflicts + else DoctorStatus.OK ), ", ".join(conflicts) if conflicts else "no known name conflicts", ( @@ -550,6 +600,7 @@ def build_context( *, skip_db: bool = False, project_root: Path | None = None, + spec_subnets: tuple[str, ...] = (), ) -> DoctorContext: """Build the default doctor context from CLI inputs and compose metadata.""" root = project_root or _repo_root() @@ -560,6 +611,7 @@ def build_context( project_root=root, required_ports=tuple(sorted(ports)), feature_flags={"skip_db": skip_db}, + spec_subnets=spec_subnets, ) @@ -687,10 +739,16 @@ def run_preflight( def run_checks( - db_url: str | None, state_file: Path, *, skip_db: bool = False + db_url: str | None, + state_file: Path, + *, + skip_db: bool = False, + spec_subnets: tuple[str, ...] = (), ) -> list[DoctorCheckResult]: """Compatibility wrapper for callers that do not build ``DoctorContext``.""" - return run_preflight(build_context(db_url, state_file, skip_db=skip_db)) + return run_preflight( + build_context(db_url, state_file, skip_db=skip_db, spec_subnets=spec_subnets) + ) def _print_report(results: Iterable[DoctorCheckResult]) -> None: diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 7936d37..a57cbc7 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -32,14 +32,20 @@ def fake_run_checks(db_url: str | None, state_file: Path, *, skip_db: bool): captured["skip_db"] = skip_db return [ DoctorCheckResult("Python runtime", DoctorStatus.OK, "ok", group="host"), - DoctorCheckResult("Docker daemon", DoctorStatus.FAIL, "not reachable", group="docker"), + DoctorCheckResult( + "Docker daemon", DoctorStatus.FAIL, "not reachable", group="docker" + ), ] state_file = tmp_path / "state.json" - monkeypatch.setenv("NETENGINE_DB_URL", "postgresql://netengine:dev@localhost:5432/netengine") + monkeypatch.setenv( + "NETENGINE_DB_URL", "postgresql://netengine:dev@localhost:5432/netengine" + ) monkeypatch.setattr(doctor_mod, "run_checks", fake_run_checks) - result = CliRunner().invoke(cli_main.cli, ["doctor", "--json", "--state-file", str(state_file)]) + result = CliRunner().invoke( + cli_main.cli, ["doctor", "--json", "--state-file", str(state_file)] + ) assert result.exit_code == 1 assert '"status": "fail"' in result.output @@ -68,7 +74,8 @@ def fake_run_checks(db_url: str | None, state_file: Path, *, skip_db: bool): monkeypatch.setattr(doctor_mod, "run_checks", fake_run_checks) result = CliRunner().invoke( - cli_main.cli, ["doctor", "--skip-db", "--state-file", str(tmp_path / "state.json")] + cli_main.cli, + ["doctor", "--skip-db", "--state-file", str(tmp_path / "state.json")], ) assert result.exit_code == 0, result.output @@ -79,7 +86,9 @@ def test_run_checks_uses_asyncpg_for_connectivity_pgmq_and_queues( monkeypatch, tmp_path: Path ) -> None: calls = [] - expected_queues = [q.value for q in PRIMARY_QUEUES] + [dlq_for(q).value for q in PRIMARY_QUEUES] + expected_queues = [q.value for q in PRIMARY_QUEUES] + [ + dlq_for(q).value for q in PRIMARY_QUEUES + ] class FakeConnection: async def fetchval(self, sql: str): @@ -118,15 +127,26 @@ async def fake_connect(db_url: str, *, timeout: float): skip_db=False, ) - assert any(r.name == "Postgres connectivity" and r.status == DoctorStatus.OK for r in results) - assert any(r.name == "pgmq extension" and r.status == DoctorStatus.OK for r in results) + assert any( + r.name == "Postgres connectivity" and r.status == DoctorStatus.OK + for r in results + ) + assert any( + r.name == "pgmq extension" and r.status == DoctorStatus.OK for r in results + ) assert any(r.name == "pgmq queues" and r.status == DoctorStatus.OK for r in results) - assert ("connect", "postgresql://netengine:dev@localhost:5432/netengine", 3.0) in calls + assert ( + "connect", + "postgresql://netengine:dev@localhost:5432/netengine", + 3.0, + ) in calls assert ("fetchval", "SELECT 1 FROM pg_extension WHERE extname = 'pgmq';") in calls assert ("fetch", "SELECT queue_name FROM pgmq.list_queues();") in calls -def test_doctor_missing_db_url_prints_actionable_remediation(monkeypatch, tmp_path: Path) -> None: +def test_doctor_missing_db_url_prints_actionable_remediation( + monkeypatch, tmp_path: Path +) -> None: monkeypatch.delenv("NETENGINE_DB_URL", raising=False) monkeypatch.delenv("DATABASE_URL", raising=False) @@ -255,14 +275,18 @@ def fake_can_bind(port: int, proto: str) -> bool: assert "dnsmasq" in result.hint -def test_docker_resource_conflicts_use_short_format_commands(monkeypatch, tmp_path: Path) -> None: +def test_docker_resource_conflicts_use_short_format_commands( + monkeypatch, tmp_path: Path +) -> None: commands = [] def fake_run(command: list[str], *, timeout: float = 8.0): commands.append((command, timeout)) joined = " ".join(command) if joined.startswith("docker ps"): - return SimpleNamespace(returncode=0, stdout="netengine_postgres\n", stderr="") + return SimpleNamespace( + returncode=0, stdout="netengine_postgres\n", stderr="" + ) if joined.startswith("docker volume ls"): return SimpleNamespace(returncode=0, stdout="netengine_data\n", stderr="") if joined.startswith("docker network ls"): @@ -293,14 +317,18 @@ def fake_run(command: list[str], *, timeout: float = 8.0): assert (["docker", "network", "ls", "--format", "{{.Name}}"], 2.0) in commands -def test_python_dependency_probe_passes_when_required_modules_import(monkeypatch) -> None: +def test_python_dependency_probe_passes_when_required_modules_import( + monkeypatch, +) -> None: imported = [] def fake_import_module(name: str): imported.append(name) return SimpleNamespace(__name__=name) - monkeypatch.setattr(doctor_mod._preflight.importlib, "import_module", fake_import_module) + monkeypatch.setattr( + doctor_mod._preflight.importlib, "import_module", fake_import_module + ) result = doctor_mod._check_python_dependencies() @@ -308,10 +336,14 @@ def fake_import_module(name: str): assert result.name == "Python dependencies" assert result.group == "host" assert "required runtime modules importable" in result.detail - assert imported == [module for _, module in doctor_mod._preflight.PYTHON_DEPENDENCY_MODULES] + assert imported == [ + module for _, module in doctor_mod._preflight.PYTHON_DEPENDENCY_MODULES + ] -def test_python_dependency_probe_reports_missing_modules_with_poetry_install(monkeypatch) -> None: +def test_python_dependency_probe_reports_missing_modules_with_poetry_install( + monkeypatch, +) -> None: missing_modules = {"yaml", "docker", "prometheus_client"} def fake_import_module(name: str): @@ -319,7 +351,9 @@ def fake_import_module(name: str): raise ImportError(f"No module named {name}") return SimpleNamespace(__name__=name) - monkeypatch.setattr(doctor_mod._preflight.importlib, "import_module", fake_import_module) + monkeypatch.setattr( + doctor_mod._preflight.importlib, "import_module", fake_import_module + ) result = doctor_mod._check_python_dependencies() @@ -329,3 +363,78 @@ def fake_import_module(name: str): assert "docker (docker)" in result.detail assert "prometheus-client (prometheus_client)" in result.detail assert "poetry install" in (result.hint or "") + + +def test_doctor_accepts_spec_option_and_passes_subnets( + monkeypatch, tmp_path: Path +) -> None: + captured = {} + + def fake_run_checks( + db_url: str | None, + state_file: Path, + *, + skip_db: bool, + spec_subnets: tuple[str, ...] = (), + ): + captured["spec_subnets"] = spec_subnets + return [DoctorCheckResult("ok", DoctorStatus.OK, "ready")] + + monkeypatch.setattr(doctor_mod, "run_checks", fake_run_checks) + + result = CliRunner().invoke( + cli_main.cli, + [ + "doctor", + "--skip-db", + "--state-file", + str(tmp_path / "state.json"), + "--spec", + "examples/minimal.yaml", + ], + ) + + assert result.exit_code == 0, result.output + assert captured["spec_subnets"] == ("172.28.0.0/16", "10.0.0.0/24") + + +def test_docker_subnet_conflicts_include_spec_subnets( + monkeypatch, tmp_path: Path +) -> None: + commands = [] + + def fake_run(command: list[str], *, timeout: float = 8.0): + commands.append(command) + if command == ["docker", "network", "ls", "-q"]: + return SimpleNamespace(returncode=0, stdout="abc123\n", stderr="") + if command == ["docker", "network", "inspect", "abc123"]: + return SimpleNamespace( + returncode=0, + stdout=( + '[{"Name":"old_world","IPAM":{"Config":' + '[{"Subnet":"10.0.0.0/24"}]}}]' + ), + stderr="", + ) + raise AssertionError(command) + + monkeypatch.setattr(doctor_mod._preflight, "_run", fake_run) + ctx = DoctorContext( + db_url=None, + state_file=tmp_path / "state.json", + project_root=tmp_path, + required_ports=(), + required_commands=(), + optional_commands=(), + feature_flags={}, + spec_subnets=("10.0.0.0/24",), + ) + + result = doctor_mod._preflight._check_docker_subnet_conflicts(ctx) + + assert result.status == DoctorStatus.WARN + assert result.required is False + assert "Docker network old_world" in result.detail + assert "reuses requested subnet 10.0.0.0/24" in result.detail + assert "docker network rm " in (result.hint or "") + assert ["docker", "network", "ls", "-q"] in commands