mirror of
https://github.com/dtzp555-max/aci-sim.git
synced 2026-07-19 09:46:33 +00:00
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>
1769 lines
71 KiB
Python
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())
|