Skip to content

Commit 85506bc

Browse files
committed
feat: add netengine init command to scaffold a new world spec
New users had no way to create a valid spec without hand-copying from examples/. `netengine init` prompts for a world name and lifecycle mode, writes a minimal but fully-valid YAML spec, and prints numbered next steps so a fresh install can go from zero to `netengine up` in one command. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P4RwkvhxDXpypYHNiXWevp
1 parent 30eb2d2 commit 85506bc

2 files changed

Lines changed: 256 additions & 0 deletions

File tree

netengine/cli/main.py

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -582,5 +582,198 @@ def _print_status(state: RuntimeState) -> None:
582582
click.echo(f"step-ca container: {state.step_ca_container_id}")
583583

584584

585+
_INIT_SPEC_TEMPLATE = """\
586+
metadata:
587+
name: {name}
588+
version: "1.0"
589+
lifecycle: {lifecycle}
590+
591+
substrate:
592+
orchestrator: swarm
593+
ntp:
594+
enabled: true
595+
servers:
596+
- pool.ntp.org
597+
networks:
598+
platform:
599+
type: bridge
600+
subnet: 172.28.0.0/16
601+
description: "Platform management network"
602+
core:
603+
type: bridge
604+
subnet: 10.0.0.0/24
605+
description: "In-world core network"
606+
gateway:
607+
platform_ip: 172.28.0.1
608+
core_ip: 10.0.0.1
609+
description: "Gateway stub"
610+
611+
dns:
612+
root:
613+
enabled: true
614+
type: authoritative
615+
server: coredns
616+
listen_ip: 10.0.0.2
617+
soa_primary_ns: root.internal
618+
soa_email: admin.internal
619+
serial_policy: timestamp
620+
platform_zone:
621+
name: platform.internal
622+
type: authoritative
623+
listen_ip: 10.0.0.3
624+
tlds:
625+
- name: internal
626+
description: "Default in-world TLD"
627+
type: authoritative
628+
listen_ip: 10.0.0.4
629+
630+
pki:
631+
root_ca:
632+
cn: "{name} Root CA"
633+
o: "{name}"
634+
c: "US"
635+
key_storage_mode: ephemeral
636+
cert_lifetime_days: 3650
637+
acme:
638+
enabled: true
639+
listen_ip: 10.0.0.6
640+
canonical_name: ca.platform.internal
641+
dnssec_enabled: true
642+
dnssec_ksk_lifetime_days: 365
643+
dnssec_zsk_lifetime_days: 30
644+
645+
identity_platform:
646+
oidc_provider: keycloak
647+
listen_ip: 10.0.0.7
648+
canonical_name: auth.platform.internal
649+
realm_name: platform
650+
admin_user:
651+
username: admin
652+
email: admin@platform.internal
653+
scopes:
654+
- "netengines:read"
655+
- "netengines:write"
656+
- "netengines:admin"
657+
658+
world_registry:
659+
enabled: true
660+
listen_ip: 10.0.0.8
661+
canonical_name: registry.platform.internal
662+
organizations: []
663+
operators: []
664+
whois:
665+
enabled: true
666+
listen_ip: 10.0.0.9
667+
port: 43
668+
669+
domain_registry:
670+
enabled: true
671+
listen_ip: 10.0.0.10
672+
canonical_name: domainreg.platform.internal
673+
tld_delegations: []
674+
address_space: []
675+
registrar:
676+
enabled: true
677+
listen_ip: 10.0.0.11
678+
canonical_name: registrar.platform.internal
679+
680+
identity_inworld:
681+
oidc_provider: keycloak
682+
listen_ip: 10.0.0.12
683+
canonical_name: auth.internal
684+
realm_name: inworld
685+
org_users: []
686+
scopes:
687+
- profile
688+
- email
689+
- openid
690+
691+
ands:
692+
profiles: {{}}
693+
instances: []
694+
695+
world_services:
696+
mail:
697+
enabled: false
698+
storage:
699+
enabled: false
700+
701+
org_apps:
702+
enabled: true
703+
catalog: []
704+
deployments: []
705+
706+
gateway_portal:
707+
enabled: true
708+
real_internet:
709+
mode: isolated
710+
cross_world:
711+
mode: none
712+
713+
operator:
714+
api:
715+
enabled: true
716+
listen_ip: 172.28.0.11
717+
port: 8080
718+
canonical_name: api.platform.internal
719+
auth:
720+
provider: oidc
721+
issuer: "https://auth.platform.internal/realms/platform"
722+
required_scope: "netengines:read"
723+
"""
724+
725+
726+
@cli.command()
727+
@click.option("--name", default=None, help="World name (e.g. my-world).")
728+
@click.option(
729+
"--lifecycle",
730+
type=click.Choice(["ephemeral", "persistent"]),
731+
default=None,
732+
help="World lifecycle mode.",
733+
)
734+
@click.option("--output", "-o", default=None, help="Output file path (default: <name>.yaml).")
735+
@click.option(
736+
"--yes", "-y", is_flag=True, default=False, help="Accept all defaults without prompting."
737+
)
738+
def init(name: str | None, lifecycle: str | None, output: str | None, yes: bool) -> None:
739+
"""Scaffold a new world spec file and print next steps."""
740+
if not name:
741+
if yes:
742+
name = "my-world"
743+
else:
744+
name = click.prompt("World name", default="my-world")
745+
746+
if not lifecycle:
747+
if yes:
748+
lifecycle = "ephemeral"
749+
else:
750+
lifecycle = click.prompt(
751+
"Lifecycle",
752+
type=click.Choice(["ephemeral", "persistent"]),
753+
default="ephemeral",
754+
)
755+
756+
out_path = Path(output) if output else Path(f"{name}.yaml")
757+
758+
if out_path.exists() and not yes:
759+
click.confirm(f"{out_path} already exists — overwrite?", abort=True)
760+
761+
spec_content = _INIT_SPEC_TEMPLATE.format(name=name, lifecycle=lifecycle)
762+
out_path.write_text(spec_content)
763+
764+
click.echo(f"\nCreated {out_path}\n")
765+
click.echo("Next steps:\n")
766+
click.echo(" 1. Start a local Postgres + pgmq instance:")
767+
click.echo(" docker compose up -d db\n")
768+
click.echo(" 2. Boot your world:")
769+
click.echo(f" netengine up {out_path}\n")
770+
click.echo(" 3. Check status at any time:")
771+
click.echo(" netengine status\n")
772+
click.echo(" 4. Tear down when done:")
773+
click.echo(" netengine down\n")
774+
click.echo(f"Edit {out_path} to add orgs, ANDs, mail, storage, and more.")
775+
click.echo("See examples/ for reference specs.")
776+
777+
585778
if __name__ == "__main__":
586779
cli()

tests/test_cli.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from unittest.mock import AsyncMock, patch
77

88
import click
9+
import pytest
910
from click.testing import CliRunner
1011

1112
from netengine.cli import main as cli_main
@@ -86,6 +87,68 @@ def test_up_supports_environment_loader_option():
8687
mock_orchestrator.execute_phases.assert_awaited_once_with(up_to_phase=9)
8788

8889

90+
def test_init_creates_spec_file(tmp_path: Path) -> None:
91+
"""The init command should write a valid parseable spec and print next steps."""
92+
from netengine.spec.loader import load_spec
93+
94+
out_file = tmp_path / "hello.yaml"
95+
result = CliRunner().invoke(
96+
cli_main.cli,
97+
["init", "--name", "hello", "--lifecycle", "ephemeral", "--output", str(out_file), "--yes"],
98+
)
99+
100+
assert result.exit_code == 0, result.output
101+
assert out_file.exists()
102+
spec = load_spec(str(out_file))
103+
assert spec.metadata.name == "hello"
104+
assert "netengine up" in result.output
105+
assert "netengine status" in result.output
106+
107+
108+
def test_init_uses_name_as_default_output_path(tmp_path: Path) -> None:
109+
"""Without --output the init command writes to <name>.yaml in cwd."""
110+
import os
111+
112+
original_cwd = os.getcwd()
113+
try:
114+
os.chdir(tmp_path)
115+
result = CliRunner().invoke(
116+
cli_main.cli,
117+
["init", "--name", "my-world", "--lifecycle", "ephemeral", "--yes"],
118+
)
119+
assert result.exit_code == 0, result.output
120+
assert (tmp_path / "my-world.yaml").exists()
121+
finally:
122+
os.chdir(original_cwd)
123+
124+
125+
def test_init_aborts_on_existing_file_without_yes(tmp_path: Path) -> None:
126+
"""Without --yes the init command should prompt before overwriting an existing file."""
127+
out_file = tmp_path / "world.yaml"
128+
out_file.write_text("original")
129+
130+
result = CliRunner().invoke(
131+
cli_main.cli,
132+
["init", "--name", "world", "--lifecycle", "ephemeral", "--output", str(out_file)],
133+
input="n\n",
134+
)
135+
136+
assert result.exit_code != 0
137+
assert out_file.read_text() == "original"
138+
139+
140+
@pytest.mark.parametrize("lifecycle", ["ephemeral", "persistent"])
141+
def test_init_lifecycle_propagates_to_spec(tmp_path: Path, lifecycle: str) -> None:
142+
"""The lifecycle flag should appear verbatim in the written spec."""
143+
out_file = tmp_path / "world.yaml"
144+
CliRunner().invoke(
145+
cli_main.cli,
146+
["init", "--name", "world", "--lifecycle", lifecycle, "--output", str(out_file), "--yes"],
147+
)
148+
content = out_file.read_text()
149+
assert f"lifecycle: {lifecycle}" in content
150+
151+
89152
def test_up_supports_repeatable_set_overrides():
90153
"""The up command should pass repeatable dotted --set overrides into composition loading."""
91154
spec_file = Path(__file__).parent.parent / "examples" / "minimal.yaml"

0 commit comments

Comments
 (0)