merge: testing → main (reconcile 2-week divergence)
This commit is contained in:
348
decnet/cli/topology.py
Normal file
348
decnet/cli/topology.py
Normal file
@@ -0,0 +1,348 @@
|
||||
"""MazeNET topology CLI: generate / deploy / teardown / list / show."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Optional
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
from decnet.topology.config import TopologyConfig
|
||||
from decnet.topology.generator import generate
|
||||
from decnet.topology.persistence import hydrate, persist
|
||||
from decnet.topology.status import TopologyStatus
|
||||
|
||||
from .gating import _require_master_mode
|
||||
|
||||
_console = Console()
|
||||
|
||||
_group = typer.Typer(
|
||||
name="topology",
|
||||
help="MazeNET nested-topology commands (DECNET master only).",
|
||||
no_args_is_help=True,
|
||||
)
|
||||
|
||||
|
||||
async def _repo():
|
||||
from decnet.web.db.factory import get_repository
|
||||
r = get_repository()
|
||||
await r.initialize()
|
||||
return r
|
||||
|
||||
|
||||
@_group.command("generate")
|
||||
def _generate(
|
||||
name: str = typer.Option(..., "--name", help="Topology name"),
|
||||
depth: int = typer.Option(3, "--depth", min=1, max=16),
|
||||
branching: int = typer.Option(2, "--branching", min=1, max=8),
|
||||
deckies_per_lan: str = typer.Option(
|
||||
"1-3",
|
||||
"--deckies-per-lan",
|
||||
help="Min-max deckies per LAN, e.g. 1-3",
|
||||
),
|
||||
bridge_forward_probability: float = typer.Option(1.0, "--bridge-forward-p", min=0.0, max=1.0),
|
||||
cross_edge_probability: float = typer.Option(0.0, "--cross-edge-p", min=0.0, max=1.0),
|
||||
services: Optional[str] = typer.Option(None, "--services", help="Comma-separated explicit services"),
|
||||
randomize_services: bool = typer.Option(True, "--randomize-services/--no-randomize-services"),
|
||||
seed: Optional[int] = typer.Option(None, "--seed", min=0),
|
||||
) -> None:
|
||||
"""Generate a topology plan and persist it as pending."""
|
||||
_require_master_mode("topology generate")
|
||||
|
||||
try:
|
||||
lo, hi = (int(x) for x in deckies_per_lan.split("-", 1))
|
||||
except ValueError:
|
||||
_console.print("[red]--deckies-per-lan must be formatted as MIN-MAX, e.g. 1-3.[/]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
services_explicit = (
|
||||
[s.strip() for s in services.split(",") if s.strip()] if services else None
|
||||
)
|
||||
|
||||
try:
|
||||
cfg = TopologyConfig(
|
||||
name=name,
|
||||
depth=depth,
|
||||
branching_factor=branching,
|
||||
deckies_per_lan_min=lo,
|
||||
deckies_per_lan_max=hi,
|
||||
bridge_forward_probability=bridge_forward_probability,
|
||||
cross_edge_probability=cross_edge_probability,
|
||||
services_explicit=services_explicit,
|
||||
randomize_services=randomize_services if not services_explicit else False,
|
||||
seed=seed,
|
||||
)
|
||||
except ValueError as e:
|
||||
_console.print(f"[red]{e}[/]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
plan = generate(cfg)
|
||||
|
||||
async def _go() -> str:
|
||||
repo = await _repo()
|
||||
return await persist(repo, plan)
|
||||
|
||||
tid = asyncio.run(_go())
|
||||
_console.print(f"[green]Topology persisted as pending[/] — id=[bold]{tid}[/]")
|
||||
_console.print(
|
||||
f" LANs: {len(plan.lans)} deckies: {len(plan.deckies)} edges: {len(plan.edges)}"
|
||||
)
|
||||
|
||||
|
||||
@_group.command("list")
|
||||
def _list() -> None:
|
||||
"""List all topologies."""
|
||||
_require_master_mode("topology list")
|
||||
|
||||
async def _go() -> list[dict]:
|
||||
repo = await _repo()
|
||||
return await repo.list_topologies()
|
||||
|
||||
rows = asyncio.run(_go())
|
||||
if not rows:
|
||||
_console.print("[yellow]No topologies.[/]")
|
||||
return
|
||||
table = Table(title="DECNET / MazeNET Topologies")
|
||||
for col in ("id", "name", "mode", "status", "created_at"):
|
||||
table.add_column(col)
|
||||
for r in rows:
|
||||
table.add_row(
|
||||
str(r["id"]),
|
||||
str(r["name"]),
|
||||
str(r["mode"]),
|
||||
str(r["status"]),
|
||||
str(r.get("created_at", "")),
|
||||
)
|
||||
_console.print(table)
|
||||
|
||||
|
||||
@_group.command("show")
|
||||
def _show(topology_id: str = typer.Argument(..., help="Topology id")) -> None:
|
||||
"""Print a structured summary of a topology."""
|
||||
_require_master_mode("topology show")
|
||||
|
||||
async def _go():
|
||||
repo = await _repo()
|
||||
return await hydrate(repo, topology_id)
|
||||
|
||||
hydrated = asyncio.run(_go())
|
||||
if hydrated is None:
|
||||
_console.print(f"[red]No such topology: {topology_id}[/]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
topo = hydrated["topology"]
|
||||
_console.print(
|
||||
f"[bold]{topo['name']}[/] id={topo['id']} status={topo['status']}"
|
||||
f" mode={topo['mode']}"
|
||||
)
|
||||
|
||||
def _decky_name(d: dict) -> str:
|
||||
cfg = d.get("decky_config") or {}
|
||||
return cfg.get("name") or d.get("name") or d["uuid"]
|
||||
|
||||
deckies_by_name = {_decky_name(d): d for d in hydrated["deckies"]}
|
||||
edges_by_lan: dict[str, list[dict]] = {}
|
||||
for e in hydrated["edges"]:
|
||||
edges_by_lan.setdefault(e["lan_id"], []).append(e)
|
||||
|
||||
for lan in hydrated["lans"]:
|
||||
dmz_tag = " [dim](DMZ)[/]" if lan["is_dmz"] else ""
|
||||
_console.print(f"\n[cyan]LAN[/] {lan['name']} {lan['subnet']}{dmz_tag}")
|
||||
lan_edges = edges_by_lan.get(lan["id"], [])
|
||||
for e in lan_edges:
|
||||
# Find the decky name via uuid.
|
||||
decky = next(
|
||||
(d for d in hydrated["deckies"] if d["uuid"] == e["decky_uuid"]),
|
||||
None,
|
||||
)
|
||||
if decky is None:
|
||||
continue
|
||||
cfg = decky.get("decky_config") or {}
|
||||
name = _decky_name(decky)
|
||||
ip = (cfg.get("ips_by_lan") or {}).get(lan["name"]) or decky.get("ip") or "?"
|
||||
tags = []
|
||||
if e["is_bridge"]:
|
||||
tags.append("bridge")
|
||||
if e["forwards_l3"]:
|
||||
tags.append("L3-forward")
|
||||
tag_s = f" [yellow]({', '.join(tags)})[/]" if tags else ""
|
||||
svcs = ",".join(cfg.get("services") or decky.get("services") or []) or "-"
|
||||
_console.print(f" • {name} {ip} svcs={svcs}{tag_s}")
|
||||
|
||||
_ = deckies_by_name # for future cross-reference extensions
|
||||
|
||||
|
||||
@_group.command("deploy")
|
||||
def _deploy(
|
||||
topology_id: str = typer.Argument(..., help="Topology id (must be pending)"),
|
||||
dry_run: bool = typer.Option(False, "--dry-run", help="Write compose + create nets, skip containers"),
|
||||
) -> None:
|
||||
"""Deploy a pending topology."""
|
||||
_require_master_mode("topology deploy")
|
||||
from decnet.engine.deployer import deploy_topology
|
||||
|
||||
async def _go() -> None:
|
||||
repo = await _repo()
|
||||
await deploy_topology(repo, topology_id, dry_run=dry_run)
|
||||
|
||||
asyncio.run(_go())
|
||||
_console.print(f"[green]Topology {topology_id} deployed.[/]")
|
||||
|
||||
|
||||
@_group.command("teardown")
|
||||
def _teardown(
|
||||
topology_id: str = typer.Argument(..., help="Topology id"),
|
||||
) -> None:
|
||||
"""Tear down a topology. Legal from active|degraded|failed|deploying."""
|
||||
_require_master_mode("topology teardown")
|
||||
from decnet.engine.deployer import teardown_topology
|
||||
|
||||
async def _go() -> None:
|
||||
repo = await _repo()
|
||||
await teardown_topology(repo, topology_id)
|
||||
|
||||
asyncio.run(_go())
|
||||
_console.print(f"[green]Topology {topology_id} torn down.[/]")
|
||||
|
||||
|
||||
@_group.command("delete")
|
||||
def _delete(
|
||||
topology_id: str = typer.Argument(..., help="Topology id"),
|
||||
force: bool = typer.Option(
|
||||
False,
|
||||
"--force",
|
||||
help="Skip the confirmation prompt (required for non-interactive use).",
|
||||
),
|
||||
) -> None:
|
||||
"""Delete a topology and all its children (LANs, deckies, edges, mutations).
|
||||
|
||||
Refuses while containers are running — teardown first.
|
||||
"""
|
||||
_require_master_mode("topology delete")
|
||||
|
||||
_RUNNING = {
|
||||
TopologyStatus.DEPLOYING,
|
||||
TopologyStatus.ACTIVE,
|
||||
TopologyStatus.DEGRADED,
|
||||
TopologyStatus.TEARING_DOWN,
|
||||
}
|
||||
|
||||
async def _go() -> tuple[bool, Optional[str]]:
|
||||
repo = await _repo()
|
||||
topo = await repo.get_topology(topology_id)
|
||||
if topo is None:
|
||||
return False, "not-found"
|
||||
if topo["status"] in _RUNNING:
|
||||
return False, str(topo["status"])
|
||||
ok = await repo.delete_topology_cascade(topology_id)
|
||||
return ok, None
|
||||
|
||||
if not force and not typer.confirm(
|
||||
f"Delete topology {topology_id} and all its children? This cannot be undone.",
|
||||
default=False,
|
||||
):
|
||||
_console.print("[yellow]Cancelled.[/]")
|
||||
raise typer.Exit(0)
|
||||
|
||||
ok, reason = asyncio.run(_go())
|
||||
if reason == "not-found":
|
||||
_console.print(f"[red]No such topology: {topology_id}[/]")
|
||||
raise typer.Exit(1)
|
||||
if reason is not None:
|
||||
_console.print(
|
||||
f"[red]Cannot delete while status={reason!r}. Run "
|
||||
f"[bold]decnet topology teardown {topology_id}[/] first.[/]"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
if not ok:
|
||||
_console.print(f"[red]Delete failed: {topology_id}[/]")
|
||||
raise typer.Exit(1)
|
||||
_console.print(f"[green]Topology {topology_id} deleted.[/]")
|
||||
|
||||
|
||||
@_group.command("mutate")
|
||||
def _mutate(
|
||||
topology_id: str = typer.Argument(..., help="Topology id (active or degraded)"),
|
||||
op: str = typer.Argument(
|
||||
...,
|
||||
help=(
|
||||
"One of: add_lan, remove_lan, add_decky, attach_decky, "
|
||||
"detach_decky, remove_decky, update_decky, update_lan"
|
||||
),
|
||||
),
|
||||
payload_json: str = typer.Option(
|
||||
"{}",
|
||||
"--payload-json",
|
||||
help="JSON payload for the op (see mutator.ops for keys)",
|
||||
),
|
||||
expected_version: Optional[int] = typer.Option(
|
||||
None,
|
||||
"--expected-version",
|
||||
help="Optimistic-concurrency guard; enqueue fails with a "
|
||||
"VersionConflict if the topology has since been mutated.",
|
||||
),
|
||||
) -> None:
|
||||
"""Enqueue a live mutation. The mutator's watch loop applies it."""
|
||||
_require_master_mode("topology mutate")
|
||||
import json
|
||||
|
||||
try:
|
||||
payload = json.loads(payload_json)
|
||||
except ValueError as e:
|
||||
_console.print(f"[red]Invalid JSON: {e}[/]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
async def _go() -> str:
|
||||
repo = await _repo()
|
||||
return await repo.enqueue_topology_mutation(
|
||||
topology_id, op, payload, expected_version=expected_version,
|
||||
)
|
||||
|
||||
mid = asyncio.run(_go())
|
||||
_console.print(
|
||||
f"[green]Mutation enqueued[/] — id=[bold]{mid}[/] op={op} "
|
||||
f"(watch for state=applied on [cyan]topology mutations {topology_id}[/])"
|
||||
)
|
||||
|
||||
|
||||
@_group.command("mutations")
|
||||
def _mutations(
|
||||
topology_id: str = typer.Argument(..., help="Topology id"),
|
||||
state: Optional[str] = typer.Option(
|
||||
None,
|
||||
"--state",
|
||||
help="Filter to one of pending|applying|applied|failed",
|
||||
),
|
||||
) -> None:
|
||||
"""List queued/applied mutations for a topology."""
|
||||
_require_master_mode("topology mutations")
|
||||
|
||||
async def _go() -> list[dict]:
|
||||
repo = await _repo()
|
||||
return await repo.list_topology_mutations(topology_id, state=state)
|
||||
|
||||
rows = asyncio.run(_go())
|
||||
if not rows:
|
||||
_console.print("[yellow]No mutations.[/]")
|
||||
return
|
||||
table = Table(title=f"Mutations — topology {topology_id}")
|
||||
for col in ("id", "op", "state", "requested_at", "applied_at", "reason"):
|
||||
table.add_column(col)
|
||||
for r in rows:
|
||||
table.add_row(
|
||||
str(r["id"]),
|
||||
str(r["op"]),
|
||||
str(r["state"]),
|
||||
str(r.get("requested_at", "")),
|
||||
str(r.get("applied_at") or ""),
|
||||
str(r.get("reason") or ""),
|
||||
)
|
||||
_console.print(table)
|
||||
|
||||
|
||||
def register(app: typer.Typer) -> None:
|
||||
app.add_typer(_group, name="topology")
|
||||
|
||||
|
||||
__all__ = ["register", "TopologyStatus"]
|
||||
Reference in New Issue
Block a user