Skip to content

Commit 2a5eb99

Browse files
authored
feat(CLI): spec-aware subnet checks to netengine doctor
2 parents 0871b29 + ab312c1 commit 2a5eb99

7 files changed

Lines changed: 299 additions & 58 deletions

File tree

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,8 @@ cd NetEngine
5050
# 2. Install dependencies
5151
poetry install
5252

53-
# 3. Verify host prerequisites
54-
poetry run netengine doctor
53+
# 3. Verify host prerequisites and requested world subnets
54+
poetry run netengine doctor --spec examples/minimal.yaml
5555

5656
# 4. Start local Postgres + pgmq (includes pgmq extension pre-installed)
5757
docker compose up -d postgres
@@ -63,7 +63,7 @@ poetry run python -m netengine.utils.run_migrations
6363
poetry run netengine up examples/minimal.yaml
6464
```
6565

66-
If you only want host/container checks before configuring Postgres, run `poetry run netengine doctor --skip-db`. Check status at any time:
66+
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:
6767

6868
```bash
6969
poetry run netengine status

docs/alpha-quickstart.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,15 @@ This guide is the focused alpha operator path. The root `README.md` keeps the hi
1616
git clone https://github.com/Forebase/NetEngine.git
1717
cd NetEngine
1818
poetry install
19-
poetry run netengine doctor --skip-db
19+
poetry run netengine doctor --skip-db --spec examples/minimal.yaml
2020
docker compose up -d postgres
2121
poetry run python -m netengine.utils.run_migrations
22-
poetry run netengine doctor
22+
poetry run netengine doctor --spec examples/minimal.yaml
2323
poetry run netengine up examples/minimal.yaml
2424
poetry run netengine status
2525
```
2626

27-
Use `NETENGINE_MOCK=true` when you want to exercise orchestration and spec validation without creating Docker, DNS, PKI, or identity resources.
27+
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.
2828

2929
## Pre-release mock smoke test
3030

docs/troubleshooting.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
Start with the commands below before changing state:
44

55
```bash
6-
poetry run netengine doctor
6+
poetry run netengine doctor --spec examples/minimal.yaml
77
poetry run netengine status
88
poetry run netengine diagnose <spec.yaml>
99
poetry run netengine events
@@ -22,7 +22,7 @@ Do not manually mark phases complete. Fix the underlying dependency and re-run `
2222

2323
## Docker unavailable or stale resources
2424

25-
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:
25+
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 <spec.yaml>`; if it reports that an existing Docker network reuses or overlaps a requested world subnet, remove the stale network with `docker network rm <name>` or choose a different subnet in the spec. If you intentionally want no infrastructure side effects, set mock mode:
2626

2727
```bash
2828
NETENGINE_MOCK=true poetry run netengine up examples/minimal.yaml

netengine/cli/doctor.py

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
build_context,
2626
run_preflight,
2727
)
28+
from netengine.spec.loader import SpecLoadError, load_spec
2829

2930
__all__ = [
3031
"DoctorCheckResult",
@@ -55,13 +56,29 @@
5556

5657

5758
def run_checks(
58-
db_url: str | None, state_file: Path, *, skip_db: bool = False
59+
db_url: str | None,
60+
state_file: Path,
61+
*,
62+
skip_db: bool = False,
63+
spec_subnets: tuple[str, ...] = (),
5964
) -> list[DoctorCheckResult]:
6065
"""Run host-readiness checks without requiring a loaded NetEngine spec."""
6166
# Keep monkeypatches of the legacy CLI module effective for callers/tests.
6267
_preflight._run = _run
6368
_preflight._can_bind = _can_bind
64-
return _preflight.run_checks(db_url, state_file, skip_db=skip_db)
69+
return _preflight.run_checks(
70+
db_url, state_file, skip_db=skip_db, spec_subnets=spec_subnets
71+
)
72+
73+
74+
def _spec_subnets_from_file(spec_path: Path) -> tuple[str, ...]:
75+
"""Load a world spec and return substrate network subnets for doctor checks."""
76+
spec = load_spec(str(spec_path))
77+
return tuple(
78+
str(network.subnet)
79+
for network in spec.substrate.networks.values()
80+
if getattr(network, "subnet", None)
81+
)
6582

6683

6784
def _print_report(results: Iterable[DoctorCheckResult]) -> None:
@@ -81,10 +98,48 @@ def _print_report(results: Iterable[DoctorCheckResult]) -> None:
8198
help="Runtime state file path.",
8299
)
83100
@click.option("--json", "as_json", is_flag=True, help="Output machine-readable JSON.")
84-
@click.option("--skip-db", is_flag=True, help="Skip database connectivity and pgmq checks.")
85-
def doctor(db_url: str | None, state_file: Path, as_json: bool, skip_db: bool) -> None:
101+
@click.option(
102+
"--skip-db", is_flag=True, help="Skip database connectivity and pgmq checks."
103+
)
104+
@click.option(
105+
"--spec",
106+
"spec_option",
107+
type=click.Path(exists=True, dir_okay=False, path_type=Path),
108+
help="World spec whose substrate subnets should be checked for Docker conflicts.",
109+
)
110+
@click.argument(
111+
"spec_arg",
112+
required=False,
113+
type=click.Path(exists=True, dir_okay=False, path_type=Path),
114+
)
115+
def doctor(
116+
db_url: str | None,
117+
state_file: Path,
118+
as_json: bool,
119+
skip_db: bool,
120+
spec_option: Path | None,
121+
spec_arg: Path | None,
122+
) -> None:
86123
"""Run local host preflight checks before booting; use diagnose for world health."""
87-
results = run_checks(db_url, state_file, skip_db=skip_db)
124+
if spec_option and spec_arg:
125+
raise click.UsageError(
126+
"provide a spec path either as --spec or as the positional argument, not both"
127+
)
128+
129+
spec_path = spec_option or spec_arg
130+
spec_subnets: tuple[str, ...] = ()
131+
if spec_path is not None:
132+
try:
133+
spec_subnets = _spec_subnets_from_file(spec_path)
134+
except SpecLoadError as exc:
135+
raise click.ClickException(f"spec validation failed: {exc}") from exc
136+
137+
if spec_path is None:
138+
results = run_checks(db_url, state_file, skip_db=skip_db)
139+
else:
140+
results = run_checks(
141+
db_url, state_file, skip_db=skip_db, spec_subnets=spec_subnets
142+
)
88143
if as_json:
89144
click.echo(json.dumps([asdict(r) for r in results], indent=2))
90145
else:

netengine/cli/main.py

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,9 @@ def _load_spec_for_cli(
8686
):
8787
"""Load a spec using the same composition semantics as ``up``."""
8888
overrides = _parse_set_overrides(set_values)
89-
feature_state_kwargs = {} if validate_feature_states else {"validate_feature_states": False}
89+
feature_state_kwargs = (
90+
{} if validate_feature_states else {"validate_feature_states": False}
91+
)
9092
if environment:
9193
return load_spec_with_environment(
9294
spec_file,
@@ -95,7 +97,9 @@ def _load_spec_for_cli(
9597
**feature_state_kwargs,
9698
)
9799
if overrides:
98-
return load_spec_with_composition(spec_file, overrides=overrides, **feature_state_kwargs)
100+
return load_spec_with_composition(
101+
spec_file, overrides=overrides, **feature_state_kwargs
102+
)
99103
return load_spec(spec_file, **feature_state_kwargs)
100104

101105

@@ -478,7 +482,16 @@ async def _readiness(
478482
group="spec",
479483
)
480484
)
481-
ctx = build_context(db_url, state_file, skip_db=skip_db)
485+
ctx = build_context(
486+
db_url,
487+
state_file,
488+
skip_db=skip_db,
489+
spec_subnets=tuple(
490+
str(network.subnet)
491+
for network in spec.substrate.networks.values()
492+
if getattr(network, "subnet", None)
493+
),
494+
)
482495
results.extend(run_preflight(ctx))
483496
results.extend(await _check_migration_readiness(db_url))
484497
results.extend(_feature_state_readiness_results(spec))
@@ -549,7 +562,11 @@ def validate(
549562
)
550563
except SpecLoadError as exc:
551564
if output_format == "json":
552-
click.echo(json.dumps({"ok": False, "error": str(exc), "feature_states": []}, indent=2))
565+
click.echo(
566+
json.dumps(
567+
{"ok": False, "error": str(exc), "feature_states": []}, indent=2
568+
)
569+
)
553570
else:
554571
click.echo(f"Spec validation failed: {exc}", err=True)
555572
sys.exit(1)
@@ -570,7 +587,9 @@ def validate(
570587
)
571588
else:
572589
if unsupported:
573-
click.echo("Spec validation failed: Unsupported spec features enabled:", err=True)
590+
click.echo(
591+
"Spec validation failed: Unsupported spec features enabled:", err=True
592+
)
574593
for item in unsupported:
575594
click.echo(
576595
f" - {item['path']} is {item['state']} in {item['stage']}: {item['reason']}",

0 commit comments

Comments
 (0)