Files
aci-sim/aci_sim/cli.py
T
taodengandClaude Fable 5 7e9a175ce6 Initial public release — aci-sim v0.16.0
aci-sim is a stateful REST simulator of a 2-site Cisco ACI fabric
(per-site APIC + ND/NDO management planes) — for testing ACI automation
(Ansible cisco.aci / cisco.mso, aci-py, custom REST clients) and CI gates
without real hardware.

Highlights: APIC + NDO REST surface served from one in-memory MIT built
from a declarative topology.yaml; port mode + sandbox (real per-device IPs
on :443) run modes; LLDP/CDP neighbor visibility; NDO->APIC deploy mirror
(multi-site templates materialize onto the target sites' APIC stores);
real-APIC fvBD default attributes; file-backed state save/restore; an
`aci-sim` CLI (validate/show/graph/run/new/init/lldp); and an 888-test suite.

History squashed for the public release.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 20:06:36 +10:00

1769 lines
71 KiB
Python

"""aci-sim — CLI for authoring, validating, previewing, and running topology.yaml.
`topology.yaml` (loaded via `aci_sim.topology.loader.load_topology`,
validated by the Pydantic v2 models in `aci_sim.topology.schema`)
remains the single source of truth for the whole simulated fabric — this CLI
is purely a convenience layer around it. It does NOT change the config
format, the schema, the builders, or the NDO/REST code; it only:
validate — run the same load_topology()/Pydantic validation CI already
depends on, with a friendlier pass/fail summary.
show — print the DERIVED inventory (node IDs, loopback/OOB IPs, port
bindings) a user needs before pointing a client at the sim.
run — a thin wrapper around `python -m aci_sim.runtime.supervisor`
that points TOPOLOGY_PATH at the given file.
new — scaffold a minimal, valid topology.yaml from a handful of flags,
using the same node-ID scheme documented in topology/schema.py.
graph — render a self-contained SVG/HTML topology diagram (aci_sim.graph),
openable directly in a browser: no server, no CDN, no npm.
No sockets are bound directly by this module (that stays supervisor.py's
job); `run` only builds an environment + argv and execs/spawns the
supervisor, which keeps the CLI itself trivially testable.
"""
from __future__ import annotations
import argparse
import copy
import ipaddress
import json
import os
import re
import sys
from collections.abc import Callable
from pathlib import Path
from typing import Any, TextIO
import yaml
from pydantic import ValidationError
from aci_sim.build.fabric import default_serial, loopback_ip, oob_ip
from aci_sim.topology.loader import load_topology
from aci_sim.topology.schema import Auth, Topology
DEFAULT_TOPOLOGY_PATH = "topology.yaml"
# ---------------------------------------------------------------------------
# validate
# ---------------------------------------------------------------------------
def _summarize_topology(topo: Topology) -> str:
"""Build the human-readable OK summary printed by `aci-sim validate`."""
lines = [f"OK: {len(topo.sites)} site(s), {len(topo.tenants)} tenant(s)"]
for site in topo.sites:
lines.append(
f" - {site.name}: "
f"{len(site.leaf_nodes())} leaves, "
f"{len(site.spine_nodes())} spines, "
f"{len(site.border_leaves)} border leaves"
)
return "\n".join(lines)
def cmd_validate(args: argparse.Namespace) -> int:
path = Path(args.topology)
try:
topo = load_topology(path)
except FileNotFoundError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
except ValueError as exc:
# Covers both YAML parse errors (loader) and pydantic ValidationError
# (a subclass of ValueError) with a readable rendering of each error.
print(f"ERROR: topology validation failed for {path}:\n", file=sys.stderr)
print(_render_validation_error(exc), file=sys.stderr)
return 1
print(_summarize_topology(topo))
return 0
def _render_validation_error(exc: Exception) -> str:
"""Render a pydantic ValidationError (or plain ValueError) readably."""
if isinstance(exc, ValidationError):
parts = []
for i, err in enumerate(exc.errors(), start=1):
loc = " -> ".join(str(p) for p in err.get("loc", ()))
msg = err.get("msg", "")
parts.append(f" {i}. [{loc}] {msg}" if loc else f" {i}. {msg}")
return "\n".join(parts)
return f" {exc}"
# ---------------------------------------------------------------------------
# show
# ---------------------------------------------------------------------------
def _port_bindings(topo: Topology) -> list[dict[str, Any]]:
"""Per-site controller port/address bindings (mirrors supervisor.py's logic).
Returns one dict per site (APIC) plus one for NDO, each describing the
host/port a client would actually connect to, in whichever mode
(port vs sandbox) the current environment/config selects.
"""
# Imported lazily so `show`'s output reflects the environment at call
# time (config.py reads os.environ at import time).
from aci_sim.runtime import config as rt_config
from aci_sim.runtime.supervisor import apic_port_for_site
bindings = []
for i, site in enumerate(topo.sites):
if rt_config.SANDBOX:
host, port = (site.mgmt_ip or "127.0.0.1"), rt_config.SANDBOX_PORT
else:
host, port = rt_config.SIM_BIND, apic_port_for_site(i)
bindings.append(
{
"site": site.name,
"role": "apic",
"host": host,
"port": port,
"url": f"https://{host}:{port}",
}
)
if rt_config.SANDBOX:
ndo_host, ndo_port = (topo.fabric.ndo_mgmt_ip or "127.0.0.1"), rt_config.SANDBOX_PORT
else:
ndo_host = rt_config.SIM_BIND
last_apic = apic_port_for_site(len(topo.sites) - 1) if topo.sites else rt_config.APIC_A_PORT
ndo_port = rt_config.NDO_PORT if rt_config.NDO_PORT > last_apic else last_apic + 1
bindings.append(
{
"site": None,
"role": "ndo",
"host": ndo_host,
"port": ndo_port,
"url": f"https://{ndo_host}:{ndo_port}",
}
)
return bindings
def _node_role(site, node_id: int) -> str:
spine_ids = {n.id for n in site.spine_nodes()}
border_ids = {n.id for n in site.border_leaf_nodes()}
if node_id in spine_ids:
return "spine"
if node_id in border_ids:
return "border-leaf"
return "leaf"
def build_inventory(topo: Topology) -> dict[str, Any]:
"""Build the derived-inventory data structure shared by text and --json `show`."""
sites_out = []
for site in topo.sites:
nodes_out = []
for node in site.all_nodes():
nodes_out.append(
{
"id": node.id,
"name": node.name,
"role": _node_role(site, node.id),
"model": node.model,
"version": node.version,
"serial": node.serial or default_serial(site.id, node.id),
"loopback_ip": loopback_ip(site.pod, node.id),
"oob_ip": oob_ip(site.pod, node.id),
}
)
nodes_out.sort(key=lambda n: n["id"])
sites_out.append(
{
"name": site.name,
"apic_host": site.apic_host,
"mgmt_ip": site.mgmt_ip,
"asn": site.asn,
"pod": site.pod,
"controllers": site.controllers,
# PR-21: effective per-controller OOB mgmt IPs — explicit
# controller_ips if set, else sequentially derived from
# mgmt_ip (empty list if mgmt_ip is also unset, matching the
# legacy per-node oob_ip(pod, cid) fallback in build/fabric.py).
"controller_ips": site.controller_oob_ips(),
# Tier-3 (PR-20): the EFFECTIVE APIC controller version this
# site's controller topSystem/firmwareCtrlrRunning actually
# carry — fabric.default_apic_version wins if set, else this
# site's own apic_version (build/fabric.py mirrors this
# exact resolution order).
"apic_version": topo.fabric.default_apic_version or site.apic_version,
"fabric_name": site.fabric_name or topo.fabric.name,
# OOB-gateway PR: this site's OOB mgmt default gateway (None
# if unset) — lands on the real mgmtRsOoBStNode.gw MO
# (build/mgmt.py), not just stored/informational.
"oob_gateway": site.oob_gateway,
"nodes": nodes_out,
}
)
vmm_domains_out = [
{
"name": v.name,
"vcenter_ip": v.vcenter_ip,
"vcenter_dvs": v.vcenter_dvs,
"vlan_pool": v.vlan_pool,
"datacenter": v.datacenter,
}
for v in topo.access.vmm_domains
]
return {
"fabric_name": topo.fabric.name,
"ndo_mgmt_ip": topo.fabric.ndo_mgmt_ip,
"tep_pool": topo.fabric.tep_pool,
"infra_vlan": topo.fabric.infra_vlan,
"gipo_pool": topo.fabric.gipo_pool,
# Tier-3 (PR-20) fabric-wide fine-tuning params:
"oob_subnet": topo.fabric.oob_subnet,
"inb_subnet": topo.fabric.inb_subnet,
"default_bd_mac": topo.fabric.default_bd_mac,
"default_apic_version": topo.fabric.default_apic_version,
"isn_enabled": topo.isn.enabled,
"isn_peer_mesh": topo.isn.peer_mesh,
"isn_ospf_area": topo.isn.ospf_area,
"isn_mtu": topo.isn.mtu,
# Tier-3 (PR-20) ISN fine-tuning params:
"isn_ospf_hello": topo.isn.ospf_hello,
"isn_ospf_dead": topo.isn.ospf_dead,
"isn_bfd_min_rx": topo.isn.bfd_min_rx,
"isn_bfd_min_tx": topo.isn.bfd_min_tx,
"isn_bfd_multiplier": topo.isn.bfd_multiplier,
"vmm_domains": vmm_domains_out,
"sites": sites_out,
"ports": _port_bindings(topo),
}
def _format_show_text(inv: dict[str, Any]) -> str:
lines: list[str] = []
lines.append(f"Fabric: {inv['fabric_name']} (NDO mgmt IP: {inv['ndo_mgmt_ip']})")
lines.append(
f" tep_pool={inv['tep_pool']} infra_vlan={inv['infra_vlan']} gipo_pool={inv['gipo_pool']}"
)
lines.append(
f" oob_subnet={inv['oob_subnet'] or '-'} inb_subnet={inv['inb_subnet'] or '-'} "
f"(store-only) default_bd_mac={inv['default_bd_mac']}"
)
if inv["default_apic_version"]:
lines.append(f" default_apic_version={inv['default_apic_version']} (overrides all sites)")
lines.append(
f" isn: enabled={inv['isn_enabled']} peer_mesh={inv['isn_peer_mesh']} "
f"ospf_area={inv['isn_ospf_area']} mtu={inv['isn_mtu']} "
f"ospf_hello={inv['isn_ospf_hello']} ospf_dead={inv['isn_ospf_dead']}"
)
lines.append(
f" isn bfd (store-only): min_rx={inv['isn_bfd_min_rx']} "
f"min_tx={inv['isn_bfd_min_tx']} multiplier={inv['isn_bfd_multiplier']}"
)
if inv["vmm_domains"]:
lines.append(" vmm_domains:")
for v in inv["vmm_domains"]:
lines.append(
f" {v['name']:<16} vcenter_ip={v['vcenter_ip'] or '-':<15} "
f"dvs={v['vcenter_dvs'] or '-':<12} datacenter={v['datacenter'] or '-'}"
)
lines.append("")
for site in inv["sites"]:
lines.append(
f"Site {site['name']}: APIC host={site['apic_host']} mgmt_ip={site['mgmt_ip'] or '-'} "
f"asn={site['asn']} pod={site['pod']} controllers={site['controllers']} "
f"apic_version={site['apic_version']} fabric_name={site['fabric_name']} "
f"controller_ips={','.join(site['controller_ips']) or '-'} "
f"oob_gateway={site['oob_gateway'] or '-'}"
)
lines.append(
f" {'ID':<6} {'NAME':<20} {'ROLE':<12} {'MODEL':<14} {'VERSION':<16} "
f"{'SERIAL':<14} {'LOOPBACK':<14} {'OOB IP'}"
)
for n in site["nodes"]:
lines.append(
f" {n['id']:<6} {n['name']:<20} {n['role']:<12} {n['model']:<14} "
f"{n['version']:<16} {n['serial']:<14} {n['loopback_ip']:<14} {n['oob_ip']}"
)
lines.append("")
lines.append("Port bindings:")
for b in inv["ports"]:
label = b["site"] if b["site"] else "NDO"
lines.append(f" {label:<8} ({b['role']:<4}) -> {b['url']}")
return "\n".join(lines)
def cmd_show(args: argparse.Namespace) -> int:
path = Path(args.topology)
try:
topo = load_topology(path)
except (FileNotFoundError, ValueError) as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
inv = build_inventory(topo)
if args.json:
print(json.dumps(inv, indent=2))
else:
print(_format_show_text(inv))
return 0
# ---------------------------------------------------------------------------
# lldp — show lldp/cdp neighbors (materialized from a fresh build_site(), the
# same way `run`/the REST server derive their store — this CLI never starts
# a server, it just builds in-process and reads the resulting MIT store).
# ---------------------------------------------------------------------------
_ADJ_DN_RE = re.compile(r"^topology/pod-\d+/node-(\d+)/sys/(?:lldp|cdp)/inst/if-\[([^\]]+)\]/adj-1$")
def _parse_adj_dn(dn: str) -> tuple[int, str] | None:
"""Extract (local_node_id, local_port) from an lldpAdjEp/cdpAdjEp DN.
Returns None for a DN that doesn't match the expected shape (defensive —
should not happen for MOs this sim itself produces).
"""
m = _ADJ_DN_RE.match(dn)
if not m:
return None
return int(m.group(1)), m.group(2)
def _platform_from_lldp_sysdesc(sys_desc: str) -> str:
"""Best-effort model/platform token out of lldpAdjEp.sysDesc.
sysDesc is either "Cisco APIC" (controller neighbor) or
"{model} running {version}" (switch neighbor, see build/neighbors.py) —
the model token is everything before " running ".
"""
if not sys_desc:
return ""
if " running " in sys_desc:
return sys_desc.split(" running ", 1)[0]
return sys_desc
def _collect_neighbors(topo: Topology, site, *, cdp: bool, node_filter: int | None) -> list[dict[str, Any]]:
"""Build site's store and return one row per in-scope lldpAdjEp/cdpAdjEp."""
from aci_sim.build.orchestrator import build_site
store = build_site(topo, site)
node_names = {n.id: n.name for n in site.all_nodes()}
# Controllers aren't in site.all_nodes() (switches only) but a leaf can
# legitimately have no local-node match if node_filter is a controller
# id — real APIC controllers don't run their own lldpAdjEp query target
# in this sim, so this is only used to label the LOCAL node (always a
# switch: the adjacency root is always .../node-{N}/sys/lldp|cdp/...).
cls = "cdpAdjEp" if cdp else "lldpAdjEp"
rows: list[dict[str, Any]] = []
for mo in store.by_class(cls):
parsed = _parse_adj_dn(mo.dn)
if parsed is None:
continue
local_node_id, local_port = parsed
if node_filter is not None and local_node_id != node_filter:
continue
attrs = mo.attrs
neighbor_port = (attrs.get("portId") if cdp else attrs.get("portIdV")) or "-"
platform = attrs.get("platId") or _platform_from_lldp_sysdesc(attrs.get("sysDesc", ""))
rows.append(
{
"local_node_id": local_node_id,
"local_node_name": node_names.get(local_node_id, f"node-{local_node_id}"),
"local_port": local_port,
"neighbor": attrs.get("sysName", ""),
"neighbor_port": neighbor_port,
"mgmt_ip": attrs.get("mgmtIp", ""),
"platform": platform,
}
)
rows.sort(key=lambda r: (r["local_node_id"], r["local_port"]))
return rows
def _format_lldp_text(site_name: str, rows: list[dict[str, Any]]) -> str:
lines: list[str] = []
by_node: dict[tuple[int, str], list[dict[str, Any]]] = {}
for r in rows:
key = (r["local_node_id"], r["local_node_name"])
by_node.setdefault(key, []).append(r)
for (node_id, node_name) in sorted(by_node.keys()):
lines.append(f"Site {site_name} — node-{node_id} ({node_name})")
lines.append(f" {'Local Port':<11} {'Neighbor':<17} {'Neighbor Port':<14} {'Mgmt IP':<14} {'Platform'}")
for r in by_node[(node_id, node_name)]:
lines.append(
f" {r['local_port']:<11} {r['neighbor']:<17} {r['neighbor_port']:<14} "
f"{r['mgmt_ip']:<14} {r['platform']}"
)
lines.append("")
return "\n".join(lines).rstrip("\n")
def cmd_lldp(args: argparse.Namespace) -> int:
path = Path(args.topology)
try:
topo = load_topology(path)
except (FileNotFoundError, ValueError) as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
sites = [s for s in topo.sites if not args.site or s.name == args.site]
node_filter = args.node
all_rows: list[dict[str, Any]] = []
for site in sites:
rows = _collect_neighbors(topo, site, cdp=args.cdp, node_filter=node_filter)
for r in rows:
r["site"] = site.name
all_rows.extend(rows)
if args.json:
print(json.dumps(all_rows, indent=2))
return 0
if not all_rows:
print("No neighbors found.")
return 0
for site in sites:
site_rows = [r for r in all_rows if r["site"] == site.name]
if not site_rows:
continue
text = _format_lldp_text(site.name, site_rows)
if text:
print(text)
return 0
# ---------------------------------------------------------------------------
# run
# ---------------------------------------------------------------------------
# Environment variables passed through unmodified from the caller's
# environment into the supervisor subprocess/exec, if present.
_PASSTHROUGH_ENV_VARS = (
"SIM_BIND",
"SIM_SANDBOX",
"SIM_SANDBOX_PORT",
"SIM_USERNAME",
"SIM_PASSWORD",
"APIC_A_PORT",
"APIC_B_PORT",
"NDO_PORT",
"CERT_FILE",
"KEY_FILE",
"SIM_CERT_STRICT",
)
def resolve_admin_credentials(
base_env: dict[str, str],
auth: Auth | None,
) -> tuple[str | None, str | None, str]:
"""Resolve which SIM_USERNAME/SIM_PASSWORD `aci-sim run` should inject, and why.
Precedence (highest first):
1. `base_env` already has EITHER `SIM_USERNAME` or `SIM_PASSWORD` set —
an explicit env override wins outright (preserves CI/env-override
behavior); `auth` is ignored entirely. Returns `(None, None,
"env override")` — i.e. inject nothing, so the caller's env is
authoritative and `rest_aci/auth.py` fills any var the caller left
unset with its own admin/cisco default. The override is atomic ON
PURPOSE: checking only `SIM_USERNAME` would let a caller who sets
only `SIM_PASSWORD` (e.g. rotating just the password from a secret,
keeping the default username) have their password SILENTLY discarded
in favor of the plaintext topology password — a partial-config trap.
So if the caller touched either credential var, they own both.
2. `auth` is not None (the topology has an `auth:` section, written by
the wizard's Admin account step) — returns `(auth.username,
auth.password, "topology.yaml")` so the caller injects both vars.
3. Neither — returns `(None, None, "built-in default")`; injecting
nothing lets `rest_aci/auth.py` fall back to its own
SIM_USERNAME/SIM_PASSWORD-env-or-admin/cisco default, unchanged.
Only APIC is affected — the NDO plane is deliberately lenient (accepts
any credential) and has no env var this function would set.
"""
if base_env.get("SIM_USERNAME") or base_env.get("SIM_PASSWORD"):
return None, None, "env override"
if auth is not None:
return auth.username, auth.password, "topology.yaml"
return None, None, "built-in default"
def build_run_env_argv(
topology: str,
base_env: dict[str, str] | None = None,
executable: str | None = None,
admin_username: str | None = None,
admin_password: str | None = None,
) -> tuple[dict[str, str], list[str]]:
"""Build the (env, argv) pair `aci-sim run` execs — factored out for testing.
Does not touch the filesystem or spawn anything; pure function of its
inputs so tests can assert on the env/argv without booting a real
supervisor (a live boot binds real sockets, which we deliberately do not
exercise in the test suite).
`admin_username`/`admin_password` (optional): when both are given, they
are injected as `SIM_USERNAME`/`SIM_PASSWORD` in the returned env. Callers
(`cmd_run`) are expected to have already resolved these via
`resolve_admin_credentials` — this function stays filesystem-pure and
topology-agnostic (it takes credentials as plain params, it never loads
a topology file itself).
"""
env = copy.deepcopy(base_env) if base_env is not None else dict(os.environ)
env["TOPOLOGY_PATH"] = str(topology)
if admin_username is not None and admin_password is not None:
env["SIM_USERNAME"] = admin_username
env["SIM_PASSWORD"] = admin_password
exe = executable or sys.executable
argv = [exe, "-m", "aci_sim.runtime.supervisor"]
return env, argv
def _ensure_certs(cert_file: str, key_file: str) -> None:
"""Generate a self-signed cert/key pair if missing (mirrors scripts/gen_certs.sh).
Keeps `aci-sim run` a drop-in replacement for `scripts/run.sh` (which
generates certs on first run) without requiring the caller to invoke a
separate script first.
"""
if Path(cert_file).exists() and Path(key_file).exists():
return
import subprocess
Path(cert_file).parent.mkdir(parents=True, exist_ok=True)
subprocess.run(
[
"openssl", "req", "-x509", "-newkey", "rsa:4096",
"-keyout", key_file, "-out", cert_file,
"-days", "3650", "-nodes",
"-subj", "/CN=localhost",
"-addext", "subjectAltName=DNS:localhost,IP:127.0.0.1",
],
check=True,
)
print(f"[aci-sim] generated self-signed certs: {cert_file}, {key_file}")
def cmd_graph(args: argparse.Namespace) -> int:
path = Path(args.topology)
try:
topo = load_topology(path)
except (FileNotFoundError, ValueError) as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
out_path = Path(args.output)
suffix = out_path.suffix.lower()
if suffix == ".svg":
fmt = "svg"
elif suffix in (".html", ".htm"):
fmt = "html"
else:
print(
f"ERROR: Unrecognized output extension {suffix!r} for {out_path}; "
f"use .html or .svg",
file=sys.stderr,
)
return 1
from aci_sim.graph import render_topology
content = render_topology(topo, fmt=fmt)
out_path.write_text(content, encoding="utf-8")
print(f"[aci-sim] wrote topology diagram: {out_path}")
return 0
def cmd_run(args: argparse.Namespace) -> int:
path = Path(args.topology)
if not path.exists():
print(f"ERROR: Topology file not found: {path}", file=sys.stderr)
return 1
# Fail fast with a friendly message before handing off to the supervisor,
# which would otherwise raise a raw ValidationError traceback. Also gives
# us the parsed Topology (specifically its optional `auth:` section) so
# we can resolve which admin credentials the supervisor should enforce —
# see resolve_admin_credentials's precedence docstring.
try:
topo = load_topology(path)
except ValueError as exc:
print(f"ERROR: topology validation failed for {path}:\n", file=sys.stderr)
print(_render_validation_error(exc), file=sys.stderr)
return 1
from aci_sim.runtime.config import CERT_FILE, KEY_FILE
_ensure_certs(CERT_FILE, KEY_FILE)
admin_username, admin_password, source = resolve_admin_credentials(dict(os.environ), topo.auth)
env, argv = build_run_env_argv(str(path), admin_username=admin_username, admin_password=admin_password)
# The operator should never be surprised about which credential is live —
# print the username + where it came from, but NEVER the password.
live_username = admin_username or env.get("SIM_USERNAME") or "admin"
if source == "env override":
print(f"[aci-sim] Auth: APIC admin {live_username!r} (from SIM_USERNAME/SIM_PASSWORD env override)")
elif source == "topology.yaml":
print(f"[aci-sim] Auth: APIC admin {live_username!r} (from {path}'s auth: section)")
else:
print(f"[aci-sim] Auth: APIC admin {live_username!r} (built-in default)")
print(f"[aci-sim] running supervisor with TOPOLOGY_PATH={path}")
os.execvpe(argv[0], argv, env)
return 0 # pragma: no cover — os.execvpe never returns on success
# ---------------------------------------------------------------------------
# new (scaffold)
# ---------------------------------------------------------------------------
def _default_asns(n_sites: int) -> list[int]:
return [65001 + i for i in range(n_sites)]
def _parse_asn_list(raw: str | None, n_sites: int) -> list[int]:
if not raw:
return _default_asns(n_sites)
values = [int(x.strip()) for x in raw.split(",") if x.strip()]
if len(values) != n_sites:
raise ValueError(
f"--asn must supply exactly {n_sites} value(s) (one per site), got {len(values)}: {raw!r}"
)
return values
def _site_letter(index: int) -> str:
"""A, B, C, ... Z, AA, AB, ... for site naming beyond 26 sites."""
letters = ""
n = index
while True:
n, rem = divmod(n, 26)
letters = chr(ord("A") + rem) + letters
if n == 0:
break
n -= 1
return letters
def generate_topology(
sites: int = 2,
leaves_per_site: int | list[int] = 2,
spines_per_site: int | list[int] = 2,
border_pairs: int | list[int] = 1,
asn: str | None = None,
fabric_name_prefix: str = "LAB",
ndo_ip: str = "10.192.0.10",
apic_ip_base: str = "10.192",
controllers: int = 1,
tep_pool: str = "10.0.0.0/16",
infra_vlan: int = 3967,
gipo_pool: str = "225.0.0.0/15",
isn_ospf_area: str = "0.0.0.0",
isn_mtu: int = 9150,
isn_ospf_hello: int = 10,
isn_ospf_dead: int = 40,
isn_bfd_min_rx: int = 50,
isn_bfd_min_tx: int = 50,
isn_bfd_multiplier: int = 3,
default_bd_mac: str = "00:22:BD:F8:19:FF",
site_overrides: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Build a fresh, minimal, valid topology dict (same shape as topology.yaml).
Node-ID scheme matches topology/schema.py's documented convention:
leaf base=101, site_offset=200 -> site i: 101+200*i, 102+200*i, ...
spine base=201, site_offset=200 -> site i: 201+200*i, 202+200*i, ...
border leaves: explicit ids outside those ranges. The default shape
(2 leaves, 2 spines) reproduces the documented example
(site 0: 103,104; site 1: 303,304); for larger
--leaves-per-site/--spines-per-site counts, the border-
leaf base is pushed past whichever auto-generated range
(leaves or spines) extends further, so it can never
collide with an auto-generated leaf/spine id — this is
required by Topology.normalize_and_validate's global
node-id collision check.
Border leaves are generated in vPC pairs (`border_pairs` pairs = 2 * N
border leaves), each pair sharing a vpc_domain and CSW.
`controllers` (PR-18 Tier-1 param) sets APIC cluster size per site
(schema default 1, single-APIC by design as of PR-21 — see
Site.controllers docstring; range 1-5); `tep_pool`/`infra_vlan`/`gipo_pool`
are the Tier-1
fabric-wide params (schema defaults 10.0.0.0/16, 3967, 225.0.0.0/15) —
all four are simply forwarded into the scaffolded YAML, using the same
schema defaults if the caller doesn't override them.
`isn_ospf_area`/`isn_mtu` (Tier-2, PR-19) are the ISN underlay params
(schema defaults "0.0.0.0", 9150) forwarded into the scaffolded `isn:`
block the same way.
`isn_ospf_hello`/`isn_ospf_dead`/`isn_bfd_min_rx`/`isn_bfd_min_tx`/
`isn_bfd_multiplier` (Tier-3, PR-20) are further ISN timer params
(schema defaults 10, 40, 50, 50, 3) forwarded the same way; OSPF
hello/dead are wired into `ospfIf` (build/underlay.py), BFD timers are
store+validate+surface only (no `bfdIfP` MO built anywhere in this
sim). `default_bd_mac` (Tier-3, PR-20; schema default the real ACI
default `"00:22:BD:F8:19:FF"`) is the fabric-wide default gateway MAC
forwarded into `fabric.default_bd_mac`.
`site_overrides` (PR-22, optional): a list of length `sites`, each entry a
dict of per-site overrides applied ON TOP of the auto-derived site_def
below (name/id/apic_host/mgmt_ip/fabric_name/asn/pod/controllers/
controller_ips) — e.g. `[{"mgmt_ip": "10.192.0.11", "controller_ips":
["10.192.0.11", "10.192.0.12"]}, {...}]`. Added so `aci-sim init` (the
interactive wizard) can collect per-site answers (including PR-21's
`controller_ips`, which the flat `--controllers`/`apic_ip_base` flags
above cannot express per-site) and feed them through this SAME generator
instead of duplicating topology-emission logic. `None` (default) leaves
every site's auto-derived fields untouched — fully backward compatible
with every pre-PR-22 caller of this function.
`leaves_per_site`/`spines_per_site`/`border_pairs` (PR-22, widened): each
now also accepts a `list[int]` of length `sites` — a PER-SITE count —
instead of only a single `int` applied uniformly to every site (the
pre-PR-22 behavior, still the default and still what every existing
caller/flag passes). Added so `aci-sim init` can express sites with
different node counts (asked per-site in the wizard) without a second
topology-emission code path.
"""
if sites < 1:
raise ValueError("--sites must be >= 1")
if site_overrides is not None and len(site_overrides) != sites:
raise ValueError(
f"site_overrides must have exactly {sites} entries (one per site), "
f"got {len(site_overrides)}"
)
def _per_site(value: int | list[int], flag: str) -> list[int]:
if isinstance(value, list):
if len(value) != sites:
raise ValueError(f"{flag} list must have exactly {sites} entries (one per site), got {len(value)}")
return list(value)
return [value] * sites
leaves_list = _per_site(leaves_per_site, "leaves_per_site")
spines_list = _per_site(spines_per_site, "spines_per_site")
border_list = _per_site(border_pairs, "border_pairs")
if any(n < 0 for n in leaves_list) or any(n < 0 for n in spines_list) or any(n < 0 for n in border_list):
raise ValueError("--leaves-per-site/--spines-per-site/--border-pairs must be >= 0")
# Tier-2 (PR-19) leaf/spine-count-sugar guard: the node-ID scheme
# (topology/schema.py docstring) gives each site a 200-wide ID budget —
# leaves start at 101+offset, spines at 201+offset, and the NEXT site's
# leaf block starts at 301+offset. Large --leaves-per-site/
# --spines-per-site/--border-pairs values can overrun that budget in
# three distinct ways that Topology.normalize_and_validate would only
# catch AFTER the fact with a confusing "node ids collide" error that
# doesn't say which flag caused it:
# 1. leaf block itself reaches into the spine block (leaves_per_site > 100)
# 2. spine block itself reaches into the NEXT site's leaf block
# (spines_per_site > 100)
# 3. the border-leaf block (pushed past whichever of leaves/spines
# extends further) runs past the next site's leaf block
# (base_after_leaves_and_spines + 2*border_pairs - 1 >= 300)
# Fail fast here, at the CLI layer, with a message that names the
# specific flag(s) to reduce — same spirit as PR-17's border-ID fix,
# which pushed the border base to avoid case 3 for modest counts but
# did not guard against leaves/spines themselves overrunning the budget.
# (PR-22: checked per-site now that these can vary per site.)
for site_idx, (lps, sps, bp) in enumerate(zip(leaves_list, spines_list, border_list, strict=True)):
if lps > 100:
raise ValueError(
f"--leaves-per-site={lps} (site index {site_idx}) exceeds the per-site node-ID "
f"budget (max 100 — leaf ids run 101..200+site_offset before "
f"colliding with the auto-generated spine block at 201+site_offset)."
)
if sps > 100:
raise ValueError(
f"--spines-per-site={sps} (site index {site_idx}) exceeds the per-site node-ID "
f"budget (max 100 — spine ids run 201..300+site_offset before "
f"colliding with the NEXT site's auto-generated leaf block at "
f"301+site_offset)."
)
if bp > 0:
leaf_top = 100 + lps if lps else 100
spine_top = 200 + sps if sps else 200
border_base = max(leaf_top, spine_top) + 1
border_top = border_base + 2 * bp - 1
if border_top > 300:
raise ValueError(
f"--border-pairs={bp} (site index {site_idx}, combined with "
f"--leaves-per-site={lps}/--spines-per-site="
f"{sps}) would push border-leaf ids up to "
f"{border_top}+site_offset, past the per-site node-ID budget "
f"of 300+site_offset (the NEXT site's auto-generated leaf "
f"block starts at 301+site_offset). Reduce --border-pairs or "
f"--leaves-per-site/--spines-per-site."
)
asns = _parse_asn_list(asn, sites)
site_defs = []
tenant_site_names = []
fault_seeds = []
for i in range(sites):
letter = _site_letter(i)
site_name = f"{fabric_name_prefix}{letter}" if fabric_name_prefix else f"SITE{letter}"
site_offset = i * 200
pod = 1
n_leaves_i = leaves_list[i]
n_spines_i = spines_list[i]
n_border_i = border_list[i]
leaves = [
{"id": 101 + site_offset + j, "name": f"{site_name}-LF{101 + site_offset + j}"}
for j in range(n_leaves_i)
]
spines = [
{"id": 201 + site_offset + j, "name": f"{site_name}-SP{201 + site_offset + j}"}
for j in range(n_spines_i)
]
# Prefer the gap right after the leaf block and before the spine
# block (matches the documented example: leaves 101-102, border
# 103-104, spines 201-202). If leaves_per_site is large enough that
# the leaf block would run into the spine block, fall back to
# starting right after whichever range extends further, so border
# ids can never collide with an auto-generated leaf/spine id
# regardless of how many were requested.
leaf_top = 101 + site_offset + n_leaves_i - 1 if n_leaves_i else 101 + site_offset - 1
spine_base = 201 + site_offset
spine_top = spine_base + n_spines_i - 1 if n_spines_i else spine_base - 1
gap_start = leaf_top + 1
gap_needed = 2 * n_border_i
if n_spines_i == 0 or gap_start + gap_needed - 1 < spine_base:
border_base = gap_start
else:
border_base = max(leaf_top, spine_top) + 1
border_leaves = []
for p in range(n_border_i):
vpc_domain = f"vpcdom-{site_name}-{p + 1}"
bl_base = border_base + (p * 2)
csw_asn = 65100
peer_ip = f"10.{100 + i}.{p}.1"
for leg, bl_id in enumerate((bl_base, bl_base + 1)):
border_leaves.append(
{
"id": bl_id,
"name": f"{site_name}-LF{bl_id}",
"vpc_domain": vpc_domain,
"csw": {
"name": f"{site_name}-CSW{p + 1:02d}",
"asn": csw_asn,
"peer_ip": peer_ip,
"local_ip": f"10.{100 + i}.{p}.{2 + leg}",
"vlan": 100 + i * 100 + p,
},
}
)
apic_ip = f"{apic_ip_base}.{i}.11" if apic_ip_base else ""
site_def: dict[str, Any] = {
"name": site_name,
"id": str(i + 1),
"apic_host": f"127.0.0.1:{8443 + i}",
"mgmt_ip": apic_ip,
"fabric_name": f"{site_name}-IT-ACI",
"asn": asns[i],
"pod": pod,
"controllers": controllers,
"spines": spines,
"leaves": leaves,
"border_leaves": border_leaves,
"cabling": "auto",
}
if site_overrides is not None:
# Shallow-merge the caller's per-site overrides on top of the
# auto-derived fields above (spines/leaves/border_leaves are
# deliberately NOT overridable this way — node topology stays
# driven by leaves_per_site/spines_per_site/border_pairs).
site_def.update(site_overrides[i])
site_name = site_def["name"] # re-read in case an override renamed the site
site_defs.append(site_def)
tenant_site_names.append(site_name)
if leaves:
fault_seeds.append(
{
"severity": "warning",
"code": "F0532",
"node": leaves[0]["id"],
"descr": f"Link flap detected on {leaves[0]['name']} eth1/1 (scaffolded seed)",
}
)
# One trivial, single-site tenant per site so the file is runnable
# end-to-end (endpoints/health/query builders all expect >=0 tenants,
# but a demo file should have at least something to look at).
tenants = []
for site_name in tenant_site_names:
tenants.append(
{
"name": f"SF-DEMO-{site_name}",
"stretch": False,
"sites": [site_name],
"vrfs": [{"name": f"vrf-L3_{site_name}"}],
"bds": [
{
"name": f"bd-DEMO_{site_name}",
"vrf": f"vrf-L3_{site_name}",
"subnets": ["192.168.100.0/24"],
}
],
"aps": [
{
"name": f"app-Demo_{site_name}",
"epgs": [
{
"name": f"epg-Demo_{site_name}",
"bd": f"bd-DEMO_{site_name}",
"contracts": {"provide": [], "consume": []},
}
],
}
],
"contracts": [],
"l3outs": [],
}
)
topology: dict[str, Any] = {
"fabric": {
"name": f"{fabric_name_prefix}-IT-ACI" if fabric_name_prefix else "SIM-IT-ACI",
"evpn_rr": "spine",
"loopback_pool": "10.0.0.0/16",
"oob_pool": "192.168.0.0/16",
"ndo_mgmt_ip": ndo_ip,
"tep_pool": tep_pool,
"infra_vlan": infra_vlan,
"gipo_pool": gipo_pool,
"default_bd_mac": default_bd_mac,
},
"sites": site_defs,
"isn": {
"enabled": sites >= 2,
"peer_mesh": "full",
"ospf_area": isn_ospf_area,
"mtu": isn_mtu,
"ospf_hello": isn_ospf_hello,
"ospf_dead": isn_ospf_dead,
"bfd_min_rx": isn_bfd_min_rx,
"bfd_min_tx": isn_bfd_min_tx,
"bfd_multiplier": isn_bfd_multiplier,
},
"tenants": tenants,
"endpoints": {"per_epg": 3, "ip_pool_from_subnet": True},
"faults": {
"seed": fault_seeds,
"health_defaults": {"node": 95, "tenant": 98},
},
"access": {
"vlan_pools": [{"name": "vlp-general", "from_vlan": 100, "to_vlan": 200}],
"phys_domains": [{"name": "phy-general"}],
"l3_domains": [{"name": "l3d-l3out"}],
"aaeps": [{"name": "aep-general"}],
},
}
return topology
_SCAFFOLD_HEADER = """\
# Generated by `aci-sim new` — a minimal, valid topology.yaml.
# topology.yaml (whichever file you point the sim at) remains the single
# source of truth; edit freely, then re-validate with `aci-sim validate`.
"""
def cmd_new(args: argparse.Namespace) -> int:
try:
topology = generate_topology(
sites=args.sites,
leaves_per_site=args.leaves_per_site,
spines_per_site=args.spines_per_site,
border_pairs=args.border_pairs,
asn=args.asn,
fabric_name_prefix=args.fabric_name_prefix,
ndo_ip=args.ndo_ip,
apic_ip_base=args.apic_ip_base,
controllers=args.controllers,
tep_pool=args.tep_pool,
infra_vlan=args.infra_vlan,
gipo_pool=args.gipo_pool,
isn_ospf_area=args.isn_ospf_area,
isn_mtu=args.isn_mtu,
isn_ospf_hello=args.isn_ospf_hello,
isn_ospf_dead=args.isn_ospf_dead,
isn_bfd_min_rx=args.isn_bfd_min_rx,
isn_bfd_min_tx=args.isn_bfd_min_tx,
isn_bfd_multiplier=args.isn_bfd_multiplier,
default_bd_mac=args.default_bd_mac,
)
except ValueError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
yaml_text = _SCAFFOLD_HEADER + yaml.dump(topology, sort_keys=False, default_flow_style=False)
# Self-check: the generated file must pass the same validation `aci-sim
# validate` runs, so `new` never hands the user a broken starting point.
try:
Topology.model_validate(topology)
except ValidationError as exc:
print(
"ERROR: internal error — generated topology failed validation:\n"
+ _render_validation_error(exc),
file=sys.stderr,
)
return 1
if args.output:
Path(args.output).write_text(yaml_text, encoding="utf-8")
print(f"Wrote {args.output}")
else:
print(yaml_text, end="")
return 0
# ---------------------------------------------------------------------------
# init (interactive APIC-setup-style wizard)
# ---------------------------------------------------------------------------
#
# Modeled on the real APIC first-boot "setup dialog" (the console Q&A a
# fresh APIC controller runs on first power-on): each field is prompted with
# a suggested default shown in [brackets] — press ENTER to accept it, or
# type a custom value. This wizard asks for the SAME fields `aci-sim new`
# takes as flags (fabric name, ASN, pod, TEP pool, infra VLAN, GIPo pool,
# OOB mgmt IP/gateway, controller count) plus this sim's multi-site extras
# (ISN OSPF area/MTU, NDO mgmt IP), then calls `generate_topology()` — the
# SAME generator `aci-sim new` uses — so it never duplicates topology-
# emission logic. It is purely a thin interactive front-end.
#
# Real APIC's dialog also asks about "multi-pod" — this sim doesn't build
# true multi-pod fabrics (no per-pod spine/leaf partitioning or IPN), so
# that choice is deliberately NOT offered; a single fabric can still set a
# non-default `pod` id per PR-19's pod-elasticity field, mentioned in the
# per-site prompt.
class WizardAbort(Exception):
"""Raised internally when the user declines to write the file at the end."""
def _prompt(
label: str,
default: str,
stream_in: TextIO,
stream_out: TextIO,
validator: Callable[[str], str | None] | None = None,
accept_defaults: bool = False,
) -> str:
"""Print `label [default]: `, read one line, return default on empty input.
Re-prompts (by recursing) if `validator` is given and returns a non-None
error message for the candidate value (default included — a bad default
should never happen, but we don't special-case it).
`accept_defaults=True` (non-interactive: `--defaults` or a non-TTY stdin)
skips reading entirely and returns `default` immediately, so `aci-sim
init` can run unattended in CI/tests without ever blocking on stdin.
"""
if accept_defaults:
return default
stream_out.write(f"{label} [{default}]: ")
stream_out.flush()
raw = stream_in.readline()
if raw == "":
# EOF on stdin (e.g. a scripted/short answer stream ran out) — treat
# exactly like accept_defaults for THIS field rather than hanging or
# raising, so scripted stdin that supplies answers for only a prefix
# of the prompts still completes deterministically.
return default
value = raw.strip()
if value == "":
value = default
if validator is not None:
error = validator(value)
if error is not None:
stream_out.write(f" ! {error}\n")
return _prompt(label, default, stream_in, stream_out, validator, accept_defaults)
return value
def _prompt_yes_no(label: str, default_yes: bool, stream_in: TextIO, stream_out: TextIO, accept_defaults: bool) -> bool:
default = "yes" if default_yes else "no"
while True:
value = _prompt(label, default, stream_in, stream_out, accept_defaults=accept_defaults)
lowered = value.strip().lower()
if lowered in ("y", "yes"):
return True
if lowered in ("n", "no"):
return False
if accept_defaults:
return default_yes
stream_out.write(" ! please answer yes or no\n")
def _validate_int_range(lo: int, hi: int) -> Callable[[str], str | None]:
def _v(value: str) -> str | None:
try:
n = int(value)
except ValueError:
return f"must be an integer, got {value!r}"
if not (lo <= n <= hi):
return f"must be between {lo} and {hi}, got {n}"
return None
return _v
def _validate_ip(value: str) -> str | None:
try:
ipaddress.ip_address(value)
except ValueError as exc:
return f"not a valid IP address: {exc}"
return None
def _validate_cidr(value: str) -> str | None:
try:
ipaddress.ip_network(value, strict=False)
except ValueError as exc:
return f"not a valid CIDR: {exc}"
return None
def _next_ip(ip_str: str) -> str:
"""Return `ip_str` + 1, e.g. '10.192.0.11' -> '10.192.0.12' (PR-21 scheme)."""
return str(ipaddress.ip_address(ip_str) + 1)
class _AnswerSource:
"""Wraps an optional pre-filled answers dict (from `--answers FILE`).
`get(key, default)` returns the answers-file value for `key` if present
(coerced to str, matching what an interactive prompt would return), else
`default`. This lets `--answers` partially override just a few fields
while everything else still falls back to the wizard's own defaults —
scriptable without having to enumerate every field.
"""
def __init__(self, data: dict[str, Any] | None) -> None:
self._data = data or {}
def get(self, key: str, default: str) -> str:
if key in self._data:
return str(self._data[key])
return default
def has(self, key: str) -> bool:
return key in self._data
def _load_answers_file(path: str) -> dict[str, Any]:
text = Path(path).read_text(encoding="utf-8")
data = yaml.safe_load(text) # yaml.safe_load also parses plain JSON
if data is None:
return {}
if not isinstance(data, dict):
raise ValueError(f"--answers file {path!r} must contain a mapping/object, got {type(data).__name__}")
return data
def _wizard_prompt_field(
key: str,
label: str,
default: str,
answers: _AnswerSource,
stream_in: TextIO,
stream_out: TextIO,
accept_defaults: bool,
validator: Callable[[str], str | None] | None = None,
) -> str:
"""One wizard field: --answers value (if present) wins outright (no re-prompt,
matching a scriptable non-interactive contract); otherwise prompt/accept-default
with `default` as the suggested value."""
if answers.has(key):
return answers.get(key, default)
return _prompt(label, default, stream_in, stream_out, validator, accept_defaults)
def run_wizard(
*,
stream_in: TextIO,
stream_out: TextIO,
accept_defaults: bool,
answers: dict[str, Any] | None = None,
) -> tuple[dict[str, Any], str]:
"""Drive the full Q&A flow; return `(topology_dict, oob_gateway_note)`.
Pure-ish: takes explicit I/O streams (never touches real sys.stdin/stdout
directly) so tests can pass io.StringIO. Does not write any file — the
caller (`cmd_init`) handles the confirm-and-write + auto-validate steps.
OOB-gateway PR: every site's answered OOB gateway is now written into
that site's own dict in `topology_dict["sites"][i]["oob_gateway"]`
(Site.oob_gateway — a real schema field with a real MO to land on,
build/mgmt.py's mgmtRsOoBStNode.gw), no longer discarded. The second
return value is kept for backward compatibility with existing callers
of this function (`cmd_init`'s summary print) — it is site-1's answered
gateway specifically, now simply a convenience echo of
`topology_dict["sites"][0]["oob_gateway"]` rather than the only place
the value lives.
"""
ans = _AnswerSource(answers)
out = stream_out
out.write("This wizard collects the same setup information as a real APIC's\n")
out.write("first-boot dialog, then generates a topology.yaml for aci-sim.\n")
out.write("Press ENTER to accept a suggested default shown in [brackets].\n\n")
# ---- Step 0: admin account ---------------------------------------------
# The account the APIC plane will ENFORCE (rest_aci/auth.py's aaaLogin
# 401s on a mismatch) once `aci-sim run` starts — see cmd_run's
# credential-resolution precedence. NDO shares this account by default
# (ndo_same_account=yes) but stays lenient regardless (accepts any
# credential, by design — see Auth's docstring in topology/schema.py); a
# "no" answer here only changes what NDO is TOLD, never what it checks.
out.write("Step 0: Admin account\n")
admin_username = _wizard_prompt_field(
"admin_username", " Admin username", "admin", ans, stream_in, out, accept_defaults
)
admin_password = _wizard_prompt_field(
"admin_password", " Admin password", "cisco", ans, stream_in, out, accept_defaults
)
if ans.has("ndo_same_account"):
ndo_same_account = ans.get("ndo_same_account", "yes").strip().lower() in ("y", "yes", "true", "1")
else:
ndo_same_account = _prompt_yes_no(
" NDO uses the same admin account as APIC?", True, stream_in, out, accept_defaults
)
if not ndo_same_account:
ndo_username = _wizard_prompt_field(
"ndo_username", " NDO admin username", admin_username, ans, stream_in, out, accept_defaults
)
ndo_password = _wizard_prompt_field(
"ndo_password", " NDO admin password", admin_password, ans, stream_in, out, accept_defaults
)
else:
ndo_username = admin_username
ndo_password = admin_password
out.write("\n")
# ---- Step 1: deployment type -----------------------------------------
out.write("Step 1: Deployment type\n")
deploy_choice = _wizard_prompt_field(
"deployment_type",
"Deployment type: 1) Single fabric 2) Multi-site (2+ fabrics via ISN+NDO)",
"2",
ans,
stream_in,
out,
accept_defaults,
_validate_int_range(1, 2),
)
multi_site = deploy_choice.strip() == "2"
if multi_site:
n_sites_str = _wizard_prompt_field(
"num_sites", "Number of sites", "2", ans, stream_in, out, accept_defaults, _validate_int_range(2, 26)
)
n_sites = int(n_sites_str)
else:
n_sites = 1
out.write("\n")
# ---- Step 2: per-site fields -------------------------------------------
site_overrides: list[dict[str, Any]] = []
per_site_leaves: list[int] = []
per_site_spines: list[int] = []
per_site_border_pairs: list[int] = []
first_site_tep_pool = "10.0.0.0/16"
first_site_infra_vlan = 3967
first_site_gipo_pool = "225.0.0.0/15"
first_site_oob_subnet = "192.168.0.0/16"
first_site_gateway = ""
for i in range(n_sites):
idx = i + 1
out.write(f"Step 2: Site {idx} of {n_sites}\n")
default_name = f"Site{idx}-IT-ACI"
name = _wizard_prompt_field(
f"site{idx}_name", " Fabric/site name", default_name, ans, stream_in, out, accept_defaults
)
site_id = _wizard_prompt_field(
f"site{idx}_id", " Site ID", str(idx), ans, stream_in, out, accept_defaults, _validate_int_range(1, 9999)
)
default_asn = str(65000 + idx)
asn = _wizard_prompt_field(
f"site{idx}_asn",
" BGP AS (fabric)",
default_asn,
ans,
stream_in,
out,
accept_defaults,
_validate_int_range(1, 4294967295),
)
pod = _wizard_prompt_field(
f"site{idx}_pod", " Pod ID", "1", ans, stream_in, out, accept_defaults, _validate_int_range(1, 9999)
)
n_controllers_str = _wizard_prompt_field(
f"site{idx}_controllers",
" Number of APIC controllers",
"1",
ans,
stream_in,
out,
accept_defaults,
_validate_int_range(1, 5),
)
n_controllers = int(n_controllers_str)
# Sequential default OOB IP block per site: site1 10.192.0.11, site2
# 10.192.128.11, ... — matches the existing sandbox convention
# (topology.yaml's LAB1/LAB2 10.192.0.0/25 vs 10.192.128.0/25 split).
default_apic_ip = f"10.192.{(i * 128) % 256}.11"
apic1_ip = _wizard_prompt_field(
f"site{idx}_apic1_ip",
" APIC OOB mgmt IP",
default_apic_ip,
ans,
stream_in,
out,
accept_defaults,
_validate_ip,
)
controller_ips = [apic1_ip]
for c in range(2, n_controllers + 1):
suggested = _next_ip(controller_ips[-1])
ctrl_ip = _wizard_prompt_field(
f"site{idx}_apic{c}_ip",
f" APIC-{c} OOB IP",
suggested,
ans,
stream_in,
out,
accept_defaults,
_validate_ip,
)
controller_ips.append(ctrl_ip)
gw_block = ipaddress.ip_address(apic1_ip)
default_gateway = str(ipaddress.ip_address(int(gw_block) - (int(gw_block) % 256) + 1))
gateway = _wizard_prompt_field(
f"site{idx}_gateway",
" APIC OOB gateway (shared by all controllers at this site)",
default_gateway,
ans,
stream_in,
out,
accept_defaults,
_validate_ip,
)
tep_pool = _wizard_prompt_field(
f"site{idx}_tep_pool", " TEP pool", "10.0.0.0/16", ans, stream_in, out, accept_defaults, _validate_cidr
)
infra_vlan = _wizard_prompt_field(
f"site{idx}_infra_vlan",
" Infra VLAN (1-4094)",
"3967",
ans,
stream_in,
out,
accept_defaults,
_validate_int_range(1, 4094),
)
gipo_pool = _wizard_prompt_field(
f"site{idx}_gipo_pool",
" Multicast/GIPo pool",
"225.0.0.0/15",
ans,
stream_in,
out,
accept_defaults,
_validate_cidr,
)
oob_subnet = _wizard_prompt_field(
f"site{idx}_oob_subnet",
" OOB mgmt subnet",
"192.168.0.0/16",
ans,
stream_in,
out,
accept_defaults,
_validate_cidr,
)
n_spines = _wizard_prompt_field(
f"site{idx}_spines", " Number of spines", "2", ans, stream_in, out, accept_defaults, _validate_int_range(0, 100)
)
n_leaves = _wizard_prompt_field(
f"site{idx}_leaves", " Number of leaves", "2", ans, stream_in, out, accept_defaults, _validate_int_range(0, 100)
)
n_border_pairs = _wizard_prompt_field(
f"site{idx}_border_pairs",
" Number of border-leaf pairs",
"1",
ans,
stream_in,
out,
accept_defaults,
_validate_int_range(0, 20),
)
out.write("\n")
site_overrides.append(
{
"name": name,
"id": site_id,
"mgmt_ip": apic1_ip,
"fabric_name": f"{name}-IT-ACI" if not name.endswith("-IT-ACI") else name,
"asn": int(asn),
"pod": int(pod),
"controllers": n_controllers,
"controller_ips": controller_ips,
# OOB-gateway PR: this site's own answered gateway now has a
# real per-site schema field (Site.oob_gateway) and a real MO
# to land on (build/mgmt.py's mgmtRsOoBStNode.gw) — unlike
# oob_subnet/tep_pool/infra_vlan/gipo_pool below, which stay
# fabric-wide (only site 1's answer applies) because their
# schema fields (Fabric.oob_subnet/tep_pool/infra_vlan/
# gipo_pool) are fabric-wide by design, matching real APIC
# where these values are set once and inherited by every
# later controller joining the fabric.
"oob_gateway": gateway,
}
)
per_site_leaves.append(int(n_leaves))
per_site_spines.append(int(n_spines))
per_site_border_pairs.append(int(n_border_pairs))
# oob_subnet has no PER-SITE schema field (Fabric.oob_subnet is
# fabric-wide) — only site 1's answer is applied fabric-wide;
# tep_pool/infra_vlan/gipo_pool are likewise fabric-wide
# (Fabric.tep_pool/infra_vlan/gipo_pool), matching real APIC where
# these ARE fabric-wide values that only the first controller sets
# (later controllers join the existing fabric and inherit them).
if i == 0:
first_site_tep_pool = tep_pool
first_site_infra_vlan = int(infra_vlan)
first_site_gipo_pool = gipo_pool
first_site_oob_subnet = oob_subnet
first_site_gateway = gateway
# ---- Step 3: multi-site only (NDO + ISN) -------------------------------
if multi_site:
out.write("Step 3: Multi-site (NDO + ISN)\n")
ndo_ip = _wizard_prompt_field(
"ndo_mgmt_ip", " NDO/ND mgmt IP", "10.192.0.10", ans, stream_in, out, accept_defaults, _validate_ip
)
isn_ospf_area = _wizard_prompt_field(
"isn_ospf_area", " ISN OSPF area", "0.0.0.0", ans, stream_in, out, accept_defaults
)
isn_mtu = int(
_wizard_prompt_field(
"isn_mtu", " ISN MTU", "9150", ans, stream_in, out, accept_defaults, _validate_int_range(576, 9216)
)
)
out.write(
" note: inter-site EVPN uses each fabric's own BGP AS above — "
"there is no separate ISN AS.\n\n"
)
else:
ndo_ip = "10.192.0.10"
isn_ospf_area = "0.0.0.0"
isn_mtu = 9150
# ---- Assemble the generate_topology() call -----------------------------
# A SINGLE call, reusing exactly the same generator `aci-sim new` calls —
# per-site heterogeneity (name/id/asn/pod/controllers/controller_ips/
# mgmt_ip, and now leaves/spines/border_pairs counts too, PR-22) is
# expressed via `site_overrides` + the newly-list-capable
# leaves_per_site/spines_per_site/border_pairs params, so nothing here
# re-implements node-ID derivation, tenant scaffolding, or YAML shape.
fabric_name = "MULTI-SITE-ACI" if multi_site else site_overrides[0]["name"]
topology = generate_topology(
sites=n_sites,
leaves_per_site=per_site_leaves,
spines_per_site=per_site_spines,
border_pairs=per_site_border_pairs,
fabric_name_prefix="",
ndo_ip=ndo_ip,
tep_pool=first_site_tep_pool,
infra_vlan=first_site_infra_vlan,
gipo_pool=first_site_gipo_pool,
isn_ospf_area=isn_ospf_area,
isn_mtu=isn_mtu,
site_overrides=site_overrides,
)
topology["fabric"]["name"] = f"{fabric_name}-IT-ACI" if not fabric_name.endswith("-IT-ACI") else fabric_name
topology["fabric"]["oob_subnet"] = first_site_oob_subnet
topology["isn"]["enabled"] = multi_site
# Admin account (Step 0 above) — always written, even when every field is
# the wizard's own default (admin/cisco), so a generated topology.yaml is
# always explicit about which account `aci-sim run` will enforce on the
# APIC plane rather than relying on cmd_run's silent admin/cisco fallback.
auth: dict[str, Any] = {"username": admin_username, "password": admin_password}
if not ndo_same_account:
auth["ndo_username"] = ndo_username
auth["ndo_password"] = ndo_password
topology["auth"] = auth
return topology, first_site_gateway
def _mask_password(password: str) -> str:
"""First char + '***' (or just '***' if empty) — never echo it in full."""
return f"{password[0]}***" if password else "***"
def _print_summary(topology: dict[str, Any], oob_gateway: str, stream_out: TextIO) -> None:
stream_out.write("Summary:\n")
auth = topology.get("auth") or {}
if auth:
admin_line = f" Admin account: username={auth['username']} password={_mask_password(auth['password'])}"
if "ndo_username" in auth:
admin_line += (
f" (NDO: username={auth['ndo_username']} "
f"password={_mask_password(auth.get('ndo_password', ''))})"
)
else:
admin_line += " (NDO: same account — lenient by design, accepts any credential)"
stream_out.write(admin_line + "\n")
stream_out.write(f" Fabric: {topology['fabric']['name']}\n")
stream_out.write(f" ISN/multi-site enabled: {topology['isn']['enabled']}\n")
if topology["isn"]["enabled"]:
stream_out.write(
f" NDO mgmt IP: {topology['fabric']['ndo_mgmt_ip']} "
f"ISN ospf_area: {topology['isn']['ospf_area']} ISN mtu: {topology['isn']['mtu']}\n"
)
if oob_gateway:
stream_out.write(f" APIC OOB gateway (site 1): {oob_gateway}\n")
for site in topology["sites"]:
n_leaves = len(site["leaves"]) if isinstance(site["leaves"], list) else site["leaves"]
n_spines = len(site["spines"]) if isinstance(site["spines"], list) else site["spines"]
n_border = len(site.get("border_leaves", []))
stream_out.write(
f" Site {site['name']} (id={site['id']}, asn={site['asn']}, pod={site['pod']}): "
f"{site['controllers']} controller(s) {site.get('controller_ips')}, "
f"oob_gateway={site.get('oob_gateway') or '-'}, "
f"{n_spines} spines, {n_leaves} leaves, {n_border} border leaves ({n_border // 2} pair(s))\n"
)
stream_out.write("\n")
def cmd_init(args: argparse.Namespace) -> int:
accept_defaults = bool(args.defaults) or not sys.stdin.isatty()
stream_in: TextIO = sys.stdin
stream_out: TextIO = sys.stdout
answers: dict[str, Any] = {}
if args.answers:
try:
answers = _load_answers_file(args.answers)
except (OSError, ValueError) as exc:
print(f"ERROR: could not read --answers file: {exc}", file=sys.stderr)
return 1
topology, oob_gateway = run_wizard(
stream_in=stream_in,
stream_out=stream_out,
accept_defaults=accept_defaults,
answers=answers,
)
_print_summary(topology, oob_gateway, stream_out)
ans = _AnswerSource(answers)
if ans.has("confirm_write"):
confirmed = ans.get("confirm_write", "yes").strip().lower() in ("y", "yes", "true", "1")
else:
confirmed = _prompt_yes_no("Write to topology.yaml?", True, stream_in, stream_out, accept_defaults)
if not confirmed:
stream_out.write("Aborted — nothing written.\n")
return 1
# Self-check (mirrors `aci-sim new`): the generated file must pass the
# same validation `aci-sim validate` runs before we ever write it.
try:
Topology.model_validate(topology)
except ValidationError as exc:
print(
"ERROR: internal error — generated topology failed validation:\n"
+ _render_validation_error(exc),
file=sys.stderr,
)
return 1
yaml_text = _SCAFFOLD_HEADER + yaml.dump(topology, sort_keys=False, default_flow_style=False)
output_path = args.output or DEFAULT_TOPOLOGY_PATH
Path(output_path).write_text(yaml_text, encoding="utf-8")
stream_out.write(f"Wrote {output_path}\n")
# Auto-run validate on the result and print OK (or the errors), per spec.
try:
topo = load_topology(Path(output_path))
except (FileNotFoundError, ValueError) as exc:
stream_out.write(f"ERROR: {exc}\n")
return 1
stream_out.write(_summarize_topology(topo) + "\n")
return 0
# ---------------------------------------------------------------------------
# argparse wiring
# ---------------------------------------------------------------------------
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="aci-sim",
description="Author, validate, preview, and run aci-sim topology.yaml files.",
)
sub = parser.add_subparsers(dest="command", required=True)
p_validate = sub.add_parser("validate", help="Validate a topology.yaml (CI gate).")
p_validate.add_argument(
"topology", nargs="?", default=DEFAULT_TOPOLOGY_PATH, help="Path to topology.yaml (default: ./topology.yaml)"
)
p_validate.set_defaults(func=cmd_validate)
p_show = sub.add_parser("show", help="Print the derived inventory (IDs, IPs, ports).")
p_show.add_argument(
"topology", nargs="?", default=DEFAULT_TOPOLOGY_PATH, help="Path to topology.yaml (default: ./topology.yaml)"
)
p_show.add_argument("--json", action="store_true", help="Emit machine-readable JSON instead of text.")
p_show.set_defaults(func=cmd_show)
p_lldp = sub.add_parser(
"lldp", help="Show LLDP (or --cdp) neighbors, show-lldp-neighbors-style."
)
p_lldp.add_argument(
"topology", nargs="?", default=DEFAULT_TOPOLOGY_PATH, help="Path to topology.yaml (default: ./topology.yaml)"
)
p_lldp.add_argument("--site", default=None, help="Filter to one site by name (default: all sites)")
p_lldp.add_argument("--node", type=int, default=None, help="Filter to one node id (default: all nodes)")
p_lldp.add_argument("--cdp", action="store_true", help="Show CDP neighbors instead of LLDP.")
p_lldp.add_argument("--json", action="store_true", help="Emit machine-readable JSON instead of text.")
p_lldp.set_defaults(func=cmd_lldp)
p_graph = sub.add_parser(
"graph", help="Render a self-contained SVG/HTML topology diagram (no server, no CDN)."
)
p_graph.add_argument(
"topology", nargs="?", default=DEFAULT_TOPOLOGY_PATH, help="Path to topology.yaml (default: ./topology.yaml)"
)
p_graph.add_argument(
"-o", "--output", default="topology.html",
help="Output file path; format is inferred from the extension (.html or .svg). Default: topology.html"
)
p_graph.set_defaults(func=cmd_graph)
p_run = sub.add_parser("run", help="Run the simulator supervisor against a topology.yaml.")
p_run.add_argument(
"topology", nargs="?", default=DEFAULT_TOPOLOGY_PATH, help="Path to topology.yaml (default: ./topology.yaml)"
)
p_run.set_defaults(func=cmd_run)
p_new = sub.add_parser("new", help="Scaffold a fresh topology.yaml.")
p_new.add_argument("--sites", type=int, default=2, help="Number of sites (default: 2)")
p_new.add_argument("--leaves-per-site", type=int, default=2, help="Regular leaves per site (default: 2)")
p_new.add_argument("--spines-per-site", type=int, default=2, help="Spines per site (default: 2)")
p_new.add_argument(
"--border-pairs", type=int, default=1, help="Border-leaf vPC pairs per site (default: 1 = 2 border leaves)"
)
p_new.add_argument("--asn", default=None, help="Comma-separated per-site fabric ASNs (default: 65001,65002,...)")
p_new.add_argument("--fabric-name-prefix", default="LAB", help="Fabric/site name prefix (default: LAB)")
p_new.add_argument("--ndo-ip", default="10.192.0.10", help="NDO/ND mgmt IP (default: 10.192.0.10)")
p_new.add_argument("--apic-ip-base", default="10.192", help="First two octets for per-site APIC mgmt IPs (default: 10.192)")
p_new.add_argument(
"--controllers", type=int, default=1, help="APIC cluster size per site (default: 1, range 1-5; single APIC by design — see README)"
)
p_new.add_argument(
"--tep-pool", default="10.0.0.0/16", help="Fabric infra TEP pool CIDR (default: 10.0.0.0/16)"
)
p_new.add_argument(
"--infra-vlan", type=int, default=3967, help="Fabric infrastructure VLAN, 1-4094 (default: 3967)"
)
p_new.add_argument(
"--gipo-pool", default="225.0.0.0/15", help="Multicast GIPo pool CIDR (default: 225.0.0.0/15)"
)
p_new.add_argument(
"--isn-ospf-area", default="0.0.0.0", help="ISN OSPF area, dotted-decimal or decimal (default: 0.0.0.0)"
)
p_new.add_argument(
"--isn-mtu", type=int, default=9150, help="ISN/IPN inter-site uplink MTU, 576-9216 (default: 9150)"
)
p_new.add_argument(
"--isn-ospf-hello", type=int, default=10, help="ISN OSPF hello timer, seconds (default: 10; Tier-3, PR-20)"
)
p_new.add_argument(
"--isn-ospf-dead", type=int, default=40, help="ISN OSPF dead timer, seconds (default: 40; Tier-3, PR-20)"
)
p_new.add_argument(
"--isn-bfd-min-rx", type=int, default=50,
help="ISN BFD min-rx timer, ms; store-only, no bfdIfP MO built (default: 50; Tier-3, PR-20)"
)
p_new.add_argument(
"--isn-bfd-min-tx", type=int, default=50,
help="ISN BFD min-tx timer, ms; store-only, no bfdIfP MO built (default: 50; Tier-3, PR-20)"
)
p_new.add_argument(
"--isn-bfd-multiplier", type=int, default=3,
help="ISN BFD detect multiplier, 1-50; store-only, no bfdIfP MO built (default: 3; Tier-3, PR-20)"
)
p_new.add_argument(
"--default-bd-mac", default="00:22:BD:F8:19:FF",
help="Fabric-wide default BD gateway MAC (default: 00:22:BD:F8:19:FF, the real ACI default; Tier-3, PR-20)"
)
p_new.add_argument("-o", "--output", default=None, help="Write to this file instead of stdout")
p_new.set_defaults(func=cmd_new)
p_init = sub.add_parser(
"init", help="Interactive APIC-setup-style wizard: Q&A, then write + validate a topology.yaml."
)
p_init.add_argument("-o", "--output", default=None, help="Write to this path (default: ./topology.yaml)")
p_init.add_argument(
"--defaults", action="store_true",
help="Accept every suggested default without prompting (also auto-enabled when stdin is not a TTY)"
)
p_init.add_argument(
"--answers", default=None,
help="Path to a YAML/JSON file of pre-filled answers (field name -> value); "
"unlisted fields fall back to prompting/defaults as usual"
)
p_init.set_defaults(func=cmd_init)
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
return args.func(args)
if __name__ == "__main__":
raise SystemExit(main())