Files
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

1022 lines
48 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Pydantic v2 topology models for aci-sim.
Node-ID numbering scheme (deterministic, globally unique within a Topology):
Regular leaves : base=101, site_offset=200 → site-0: 101,102,… site-1: 301,302,…
Spines : base=201, site_offset=200 → site-0: 201,202,… site-1: 401,402,…
Border leaves : always explicitly specified in the YAML (no auto-generation).
A site_offset of 200 guarantees no collision between any node type across the
first four sites (max ~40 leaves + ~40 spines per site before the ranges touch).
Border-leaf IDs must be chosen outside those ranges or they will be caught by
the cross-site collision check in Topology.normalize_and_validate.
All YAML field names match the Python attribute names except:
- FilterEntry: uses `from_port`/`to_port`/`ether_type` (DESIGN used `from`/`to`/`ether`,
which are Python reserved keywords or ambiguous abbreviations).
- VlanPool: uses `from_vlan`/`to_vlan` for the same reason.
"""
from __future__ import annotations
import ipaddress
import re
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, model_validator
# ---------------------------------------------------------------------------
# Leaf-level value objects
# ---------------------------------------------------------------------------
class Node(BaseModel):
"""A single fabric node (spine, leaf, or border-leaf) with inventory attrs.
`version` feeds both fabricNode.version and topSystem.version (CONTRACT §6).
Auto-expanded nodes inherit the default; explicit node dicts may override it.
"""
id: int
name: str
model: str = "N9K-C9332C"
serial: str = ""
version: str = "n9000-14.2(7f)"
class CSW(BaseModel):
"""Core-switch peer config attached to a border-leaf interface."""
name: str
asn: int
peer_ip: str # CSW's BGP peer IP (remote)
local_ip: str # border-leaf's own IP on the peering interface
vlan: int
class BorderLeaf(BaseModel):
"""A border-leaf node; two entries with the same vpc_domain form a vPC pair."""
id: int
name: str
vpc_domain: str # shared by the two nodes in a vPC pair
csw: CSW
class Fabric(BaseModel):
"""Fabric-wide settings + address spaces the underlay/overlay builders draw from.
Address derivation the builders SHOULD use (documented here so it is one place):
- Node loopback (router-id / topSystem.address / EVPN peer source):
10.<pod>.<node_id>.1/32 drawn from `loopback_pool` (default 10.0.0.0/16)
- OOB mgmt (topSystem.oobMgmtAddr):
192.168.<pod>.<node_id>/24 drawn from `oob_pool` (default 192.168.0.0/16)
- ISN inter-site EVPN peer (bgpPeer.addr, always a /32):
the remote spine's loopback /32 (same 10.<pod>.<node_id>.1 rule).
Pools are strings (CIDR) so a YAML author can move the whole address space at once.
"""
name: str
evpn_rr: str = "spine" # which role acts as BGP-EVPN route-reflectors; see build/overlay.py::_route_reflectors
# loopback_pool/oob_pool (#27): the address-derivation scheme in build/fabric.py
# (loopback_ip/oob_ip) is hardcoded to the "10.<pod>.<node_id>.1" / "192.168.
# <pod>.<node_id>" families documented there — it is NOT parameterized by an
# arbitrary CIDR (6+ call sites across build/*.py call loopback_ip(pod, node_id)
# with no pool argument, and multiple tests assert the literal 10.x/192.168.x
# output). Rather than leave these fields purely cosmetic, normalize_and_validate
# below enforces that they match the base network the builder actually produces,
# so a topology author who edits them gets a clear error instead of silent
# config/behavior divergence.
loopback_pool: str = "10.0.0.0/16" # spine/leaf loopbacks + ISN /32 peers
oob_pool: str = "192.168.0.0/16" # topSystem.oobMgmtAddr space
ndo_mgmt_ip: str = "10.192.0.10" # sandbox: NDO/ND IP (served on :443 in sandbox mode)
# ---- Tier-1 fabric parameters (PR-18) --------------------------------
# These describe real ACI infra address-space knobs that Ansible/aci-py
# fidelity depends on (e.g. `cisco.aci.aci_fabric_node`'s serial, infra
# VLAN policy, etc.). All optional + defaulted so existing topology.yaml
# files keep validating and producing byte-identical builder output.
#
# tep_pool: the fabric's infra TEP (Tunnel End-Point) address space —
# real ACI default is 10.0.0.0/16. No builder currently derives a
# per-node address FROM this pool (loopback_ip()/oob_ip() above are the
# actual address source, per the loopback_pool/oob_pool note); tep_pool
# is stored + validated + surfaced (aci-sim show/new) so it is available
# for a topology author to record and for future infra-address wiring,
# rather than silently absent from the schema. See normalize_and_validate
# for the CIDR check.
tep_pool: str = "10.0.0.0/16"
# infra_vlan: the fabric infrastructure VLAN (ACI's dedicated VLAN
# carrying VXLAN/iVXLAN + APIC↔switch discovery traffic). Real ACI
# default is 3967; valid range is 1-4094. Store + validate + surface;
# no MIT object in this sim currently carries an infra-VLAN attribute
# to wire it into (see docs note in normalize_and_validate).
infra_vlan: int = 3967
# gipo_pool: the multicast GIPo (Group IP outer) pool ACI uses for
# BUM traffic replication per bridge-domain. Real ACI default range is
# 225.0.0.0/15. Store + validate + surface.
gipo_pool: str = "225.0.0.0/15"
# ---- Tier-3 fine-tuning parameters (PR-20) ---------------------------
# oob_subnet: an explicit CIDR describing the OOB mgmt address space, in
# addition to the existing `oob_pool` (#27, above). build/fabric.py's
# `oob_ip()` derives every node's OOB address from `(pod, node_id)`
# only — it does not consume an arbitrary pool/subnet CIDR (same
# documented limitation as `oob_pool` itself). `oob_subnet` is stored +
# validated (must fall within `192.168.0.0/16`, mirroring `oob_pool`'s
# own validation) so a topology author can record the *intended* OOB
# subnet distinctly from the pool `oob_ip()` actually draws from, and it
# is surfaced in `aci-sim show`. Default `None` (omitted) is fully
# backward compatible — when unset, `aci-sim show` falls back to
# displaying `oob_pool` as before.
oob_subnet: str | None = None
# inb_subnet: the fabric's in-band (INB) management address space. This
# sim has NO `mgmtInB`/`inbMgmtAddr`-style MO anywhere in build/*.py
# (grepped: only OOB — `topSystem.oobMgmtAddr` — is built); real ACI's
# in-band EPG/bridge-domain (`mgmtInB`, in the special `mgmt` tenant) is
# out of scope for this PR (would require a new `mgmt` tenant + `mgmtInB`
# + `mgmtRsMgmtBD` builder, a materially bigger addition than "wire an
# existing attr"). Store + validate (must fall within a private RFC1918
# range) + surface only — explicitly STORE-ONLY, no MO reflects it.
inb_subnet: str | None = None
# default_bd_mac: fabric-wide default gateway MAC for every `fvBD` that
# doesn't set its own `BD.mac` (below). Real ACI's own default gateway
# MAC is `00:22:BD:F8:19:FF` — build/tenants.py's `_build_bd` already
# hardcoded exactly this literal for every BD pre-PR-20, so this default
# reproduces that value exactly (byte-identical `fvBD.mac` for any BD
# that doesn't override it — true backward compat, not just "a
# plausible-looking default").
default_bd_mac: str = "00:22:BD:F8:19:FF"
# default_apic_version: fabric-wide override for the APIC controller
# version (`site.apic_version`, PR-18 lineage — used for the controller
# `fabricNode`/`topSystem`/`firmwareCtrlrRunning` in build/fabric.py).
# `site.apic_version` already defaults to `"4.2(7f)"` per-site and is
# already fully wired/independent of switch-node `Node.version`; this
# field lets a topology author set ONE fabric-wide APIC version instead
# of repeating `apic_version:` on every site. Default `None` (omitted)
# means "use each site's own `apic_version`" — unchanged pre-PR-20
# behavior. When set, it takes precedence over `site.apic_version` for
# every site (build/fabric.py: `topo.fabric.default_apic_version or
# site.apic_version`).
default_apic_version: str | None = None
# ---------------------------------------------------------------------------
# Site
# ---------------------------------------------------------------------------
class Site(BaseModel):
"""Per-site APIC context.
`spines` and `leaves` accept either:
- an integer N → auto-expanded to N Node objects with deterministic IDs
- a list of node dicts/Node objects → used as-is
The expansion is triggered by Topology.model_validator (which knows each
site's index). Use `spine_nodes()`, `leaf_nodes()`, `all_nodes()` to
access the expanded lists.
"""
model_config = ConfigDict(arbitrary_types_allowed=True)
name: str
id: str
apic_host: str
asn: int
pod: int = 1
spines: int | list[Node] = 2
leaves: int | list[Node] = 2
border_leaves: list[BorderLeaf] = Field(default_factory=list)
cabling: str = "auto" # "auto" = full spine×leaf mesh; "explicit" = cabling section
# controllers: APIC cluster size (role=controller nodes 1..N). Default is
# 1 — DELIBERATE (PR-21): APIC cluster size is irrelevant to Ansible
# playbook deployment testing (a playbook connects to and pushes config
# through exactly ONE APIC endpoint; cluster size only shows up in
# cluster-health/appliance-vector tooling like autoACI's health_score.py,
# which reads infraWiNode across the cluster). Single-APIC-per-site is
# therefore the shipped default; set controllers: 3 (the pre-PR-21
# default, and real ACI's minimum supported cluster size) on a site that
# specifically needs to exercise multi-controller/cluster-health
# semantics. Validated range 1-5 (real ACI supports 3/5/7 for production
# clusters, but this sim does not enforce odd-only since it never runs
# real cluster consensus — 1-5 covers "single APIC" through "test a
# meaningfully large cluster" without inviting nonsense values).
controllers: int = 1
# controller_ips: OPTIONAL explicit per-controller OOB mgmt IP list (PR-21).
# If set, entry i (0-indexed) is controller (i+1)'s topSystem.oobMgmtAddr;
# len(controller_ips) must equal `controllers` when both are set (see
# Topology.normalize_and_validate). If NOT set (default, None), OOB IPs
# are auto-derived SEQUENTIALLY starting from this site's `mgmt_ip`:
# controller 1 = mgmt_ip, controller 2 = mgmt_ip + 1, controller 3 =
# mgmt_ip + 2, ... (build/fabric.py `_controller_oob_ip`). The sim's REST
# server always binds to `mgmt_ip` regardless of `controllers` (it never
# runs N separate APIC processes) — `mgmt_ip` is controller-1 / the
# cluster VIP from the REST server's point of view.
controller_ips: list[str] | None = None
apic_version: str = "4.2(7f)" # APIC version (pairs with switch n9000-14.2(7f))
fabric_name: str = "" # topSystem.fabricDomain; defaults to topo.fabric.name
mgmt_ip: str = "" # sandbox: this APIC's IP (served on :443; NDO advertises it portless)
# oob_gateway (OOB-gateway PR): the OOB management default gateway for
# this site. Real ACI's first-boot dialog collects this same value (see
# `aci-sim init`'s "APIC OOB gateway" prompt, previously discarded — see
# cli.py history) and models it on the real MO
# `uni/tn-mgmt/mgmtp-default/oob-default/rsooBStNode-[<node-dn>]`
# (`mgmtRsOoBStNode.gw`) alongside each node's own OOB address (`.addr`).
# Per-site (not fabric-wide) because a real gateway is a property of the
# site's own OOB subnet/VLAN, matching the wizard's existing per-site
# question. Optional — default `None` is fully backward compatible: a
# topology that never sets this still builds the mgmt-tenant OOB
# scaffolding (build/mgmt.py), just with an empty `gw` attribute on every
# mgmtRsOoBStNode (no gateway is invented/derived — see build/mgmt.py's
# module docstring for why).
oob_gateway: str | None = None
# Populated by Topology.model_validator after expansion
_spine_nodes: list[Node] = PrivateAttr(default_factory=list)
_leaf_nodes: list[Node] = PrivateAttr(default_factory=list)
# ------------------------------------------------------------------ #
# Normalisation (called by Topology, not by users directly) #
# ------------------------------------------------------------------ #
def _expand_nodes(self, site_index: int) -> None:
"""Expand integer counts into concrete Node lists.
ID scheme:
leaf_id = 101 + site_index * 200 + node_index (node_index starts at 0)
spine_id = 201 + site_index * 200 + node_index
"""
if isinstance(self.spines, int):
self._spine_nodes = [
Node(
id=201 + site_index * 200 + i,
name=f"spine-{201 + site_index * 200 + i}",
)
for i in range(self.spines)
]
else:
self._spine_nodes = list(self.spines)
if isinstance(self.leaves, int):
self._leaf_nodes = [
Node(
id=101 + site_index * 200 + i,
name=f"leaf-{101 + site_index * 200 + i}",
)
for i in range(self.leaves)
]
else:
self._leaf_nodes = list(self.leaves)
# ------------------------------------------------------------------ #
# Helpers used by builders #
# ------------------------------------------------------------------ #
def spine_nodes(self) -> list[Node]:
"""Expanded spine nodes (call after topology validation)."""
return self._spine_nodes
def leaf_nodes(self) -> list[Node]:
"""Expanded regular-leaf nodes (call after topology validation)."""
return self._leaf_nodes
def border_leaf_nodes(self) -> list[Node]:
"""Border-leaf nodes as Node objects (id/name only)."""
return [Node(id=bl.id, name=bl.name) for bl in self.border_leaves]
def all_nodes(self) -> list[Node]:
"""All nodes: spines + regular leaves + border leaves."""
return self._spine_nodes + self._leaf_nodes + self.border_leaf_nodes()
def controller_oob_ips(self) -> list[str]:
"""Effective per-controller OOB mgmt IP list, length == self.controllers.
- If `controller_ips` is explicitly set, return it verbatim (already
length-validated against `controllers` by
Topology.normalize_and_validate).
- Otherwise auto-derive SEQUENTIALLY from `mgmt_ip`: controller 1 =
mgmt_ip, controller 2 = mgmt_ip + 1, controller 3 = mgmt_ip + 2, ...
If `mgmt_ip` is unset (""), falls back to the legacy per-node
`oob_ip(pod, cid)` scheme by returning an empty list — callers
(build/fabric.py) detect this and keep the pre-PR-21 behavior.
"""
if self.controller_ips is not None:
return list(self.controller_ips)
if not self.mgmt_ip:
return []
base = ipaddress.ip_address(self.mgmt_ip)
return [str(base + i) for i in range(self.controllers)]
# ---------------------------------------------------------------------------
# ISN
# ---------------------------------------------------------------------------
class ISN(BaseModel):
enabled: bool = True
transit_asn: int | None = None # optional transit ASN for ISN underlay
peer_mesh: str = "full" # "full" = every spine peers every remote spine; "partial" is
# validated-and-rejected (see build/overlay.py) — not implemented.
# ---- Tier-2 ISN parameters (PR-19) ------------------------------------
# ospf_area: the OSPF area spines run toward the IPN/ISN on their
# dedicated uplink (build/underlay.py's ospfIf/ospfAdjEp). Real ACI
# default is the backbone area "0.0.0.0" (some deployments write the
# decimal-integer form "0" — both are accepted; ACI itself renders area
# "0.0.0.0" or "0" interchangeably in ospfIf.area, and autoACI's
# fabric_ospf.py reads the field as an opaque string so both round-trip).
# Threaded into ospfIf.area in build/underlay.py instead of the prior
# hardcoded "0.0.0.0" literal.
ospf_area: str = "0.0.0.0"
# mtu: the ISN/IPN inter-pod/inter-site uplink MTU. Real ACI's default
# inter-pod-network / inter-site MTU is 9150 (accounts for VXLAN +
# outer-header overhead vs. the fabric's own 9216 intra-fabric MTU).
# Threaded into the ISN uplink l1PhysIf.mtu in build/interfaces.py
# instead of that module's general fabric-port mtu="9216" literal.
mtu: int = 9150
# ---- Tier-3 fine-tuning parameters (PR-20) ------------------------
# ospf_hello / ospf_dead: the spine<->IPN/ISN OSPF adjacency's
# hello/dead timers (real ACI defaults: hello 10s, dead 40s — the
# standard OSPF 4x-hello-interval dead-timer relationship). Threaded
# into `ospfIf`'s `helloIntvl`/`deadIntvl` attrs in build/underlay.py
# (that MO is already built for `isn.ospf_area`, PR-19 — this PR adds
# two more attrs to the same object rather than hardcoding them).
ospf_hello: int = 10
ospf_dead: int = 40
# bfd_min_rx / bfd_min_tx / bfd_multiplier: BFD session timers (real ACI
# defaults: 50ms/50ms, multiplier 3). This sim builds NO `bfdIfP`/
# `bfdRsIfPol`-style MO anywhere (grepped build/*.py — zero matches for
# "bfd"/"Bfd"/"BFD"); adding a faithful bfdIfP would additionally require
# a believable `bfdIfStats`/oper-state tree with no verified consumer to
# anchor its shape against. Store + validate + surface only — explicitly
# STORE-ONLY, no MO reflects these three fields.
bfd_min_rx: int = 50
bfd_min_tx: int = 50
bfd_multiplier: int = 3
# ---------------------------------------------------------------------------
# Tenant sub-models
# ---------------------------------------------------------------------------
class VRF(BaseModel):
name: str
class BD(BaseModel):
name: str
vrf: str
subnets: list[str] = Field(default_factory=list) # CIDR strings
l2stretch: bool = False
# mac (Tier-3, PR-20): per-BD override of the default-gateway MAC
# (`fvBD.mac`). Optional — `None` means "use `fabric.default_bd_mac`"
# (build/tenants.py: `bd.mac or topo.fabric.default_bd_mac`), which
# itself defaults to the real ACI default `00:22:BD:F8:19:FF` (the
# literal this sim already hardcoded for every BD pre-PR-20).
mac: str | None = None
class FilterEntry(BaseModel):
"""A single contract filter entry.
Field names use `from_port`/`to_port`/`ether_type` instead of the DESIGN's
abbreviated `from`/`to`/`ether` because `from` is a Python reserved keyword.
"""
proto: str = "tcp"
from_port: str = "unspecified"
to_port: str = "unspecified"
ether_type: str = "ip"
class Filter(BaseModel):
name: str
entries: list[FilterEntry] = Field(default_factory=list)
class Contract(BaseModel):
name: str
scope: str = "context"
filters: list[Filter] = Field(default_factory=list)
class ContractRefs(BaseModel):
provide: list[str] = Field(default_factory=list)
consume: list[str] = Field(default_factory=list)
class EPG(BaseModel):
name: str
bd: str
contracts: ContractRefs = Field(default_factory=ContractRefs)
class AP(BaseModel):
name: str
epgs: list[EPG] = Field(default_factory=list)
class ExtEPG(BaseModel):
name: str
subnets: list[str] = Field(default_factory=list)
class CswPeer(BaseModel):
"""Summary CSW peer info attached to an L3Out (name + BGP peering details)."""
name: str
asn: int
peer_ip: str
# keepalive / hold (Tier-3, PR-20): the eBGP session's keepalive/hold
# timers (real ACI defaults: 60s/180s, the standard 1:3 keepalive:hold
# ratio). Threaded into `bgpPeerP` (build/l3out.py's
# `_build_node_profile`, the control-plane BGP peer policy object) as
# its `ctrl`-adjacent timer attrs. Optional — default matches the value
# this sim's `bgpPeerP`/`bgpPeer` MOs implicitly carried pre-PR-20 (real
# ACI's own out-of-the-box default), so an unset `csw_peer` produces the
# same session timers as before.
keepalive: int = 60
hold: int = 180
class L3Out(BaseModel):
name: str
vrf: str
site: str | None = None # optional explicit site association (validated if set)
border_leaves: list[int] = Field(default_factory=list) # border-leaf node IDs
csw_peer: CswPeer | None = None
ext_epgs: list[ExtEPG] = Field(default_factory=list)
class Tenant(BaseModel):
name: str
stretch: bool = False
sites: list[str] = Field(default_factory=list)
vrfs: list[VRF] = Field(default_factory=list)
bds: list[BD] = Field(default_factory=list)
aps: list[AP] = Field(default_factory=list)
contracts: list[Contract] = Field(default_factory=list)
l3outs: list[L3Out] = Field(default_factory=list)
# ---------------------------------------------------------------------------
# Endpoints / Faults / Access
# ---------------------------------------------------------------------------
class Endpoints(BaseModel):
per_epg: int = 3
ip_pool_from_subnet: bool = True
class FaultSeed(BaseModel):
severity: str # critical | major | minor | warning
code: str # e.g. "F0532"
node: int # node ID
descr: str
class HealthDefaults(BaseModel):
node: int = 95
tenant: int = 98
class Faults(BaseModel):
seed: list[FaultSeed] = Field(default_factory=list)
health_defaults: HealthDefaults = Field(default_factory=HealthDefaults)
class VlanPool(BaseModel):
"""VLAN pool.
Uses `from_vlan`/`to_vlan` instead of `from`/`to` (DESIGN notation) because
`from` is a Python reserved keyword.
"""
name: str
from_vlan: int
to_vlan: int
class PhysDomain(BaseModel):
name: str
class L3Domain(BaseModel):
"""L3 external domain; the l3out builder binds l3extRsL3DomAtt(tDn) to it."""
name: str
class AAEP(BaseModel):
name: str
class VMMDomain(BaseModel):
"""VMM domain (vmmDomP); optional vCenter connection attrs (Tier-2, PR-19).
All connection attrs are optional + defaulted so a bare `{name: ...}`
entry (or an omitted `vmm_domains` list entirely) keeps validating —
the sim seeds no VMM domain by default (unlike phys/l3 domains/AAEPs,
which have a default single-entry list), since no existing builder or
production chain (autoACI e2e / aci-py MS-TN1) reads a vmmDomP/vmmCtrlrP
MIT object today (see docs/DESIGN.md PR-19 note — `bind_epg_to_vmm_domain`
in the real aci-py chain writes an NDO schema `domainAssociations` entry
referencing the domain purely by DN string, never touching an APIC-side
vmmDomP). Populating these fields lets `aci-sim show`/`new` surface a
vCenter-backed VMM domain and produces a matching vmmCtrlrP/vmmProvP tree
in build/access.py for a topology author who wants one, without any
existing flow depending on it.
"""
name: str
vcenter_ip: str = "" # vmmCtrlrP.hostOrIp; empty = no vmmCtrlrP emitted
vcenter_dvs: str = "" # DVS name (informational; stored on vmmCtrlrP.name-adjacent attr)
vlan_pool: str = "" # name of a topo.access.vlan_pools[] entry to bind (dynamic pool)
datacenter: str = "" # vCenter datacenter; vmmCtrlrP.rootContName
class Access(BaseModel):
vlan_pools: list[VlanPool] = Field(
default_factory=lambda: [VlanPool(name="vlan-pool-1", from_vlan=100, to_vlan=200)]
)
phys_domains: list[PhysDomain] = Field(
default_factory=lambda: [PhysDomain(name="phys-dom-1")]
)
l3_domains: list[L3Domain] = Field(
default_factory=lambda: [L3Domain(name="l3dom-1")]
)
aaeps: list[AAEP] = Field(default_factory=lambda: [AAEP(name="aaep-1")])
# vmm_domains: Tier-2 (PR-19) — optional, defaults to an EMPTY list (unlike
# the other access collections above, which default to one seeded entry).
# No existing builder/topology.yaml/production-chain references a VMM
# domain by name today, so an empty default is the true backward-compat
# baseline: omitting `access.vmm_domains` entirely produces byte-identical
# builder output to pre-PR-19.
vmm_domains: list[VMMDomain] = Field(default_factory=list)
class Auth(BaseModel):
"""Fabric admin credentials, set by the `aci-sim init` wizard's Admin
account step.
The APIC plane ENFORCES these — `rest_aci/auth.py`'s `aaaLogin` 401s on a
username/password mismatch, resolved into `SIM_USERNAME`/`SIM_PASSWORD`
by `cli.py`'s `cmd_run` (topology `auth:` loses to an explicit
`SIM_USERNAME`/`SIM_PASSWORD` already set in the caller's environment —
see `build_run_env_argv`'s docstring for the exact precedence). The NDO
plane (`ndo/app.py`) is lenient BY DESIGN — it accepts any credential
(client-compat for cisco.mso/aci-py) and is not affected by this model at
all. `ndo_username`/`ndo_password` exist only so an operator can
deliberately give NDO a different (informational-only, since NDO never
checks it) account than APIC; `None` (the default) means "same account as
APIC", which is what the wizard offers by default.
"""
username: str = "admin"
password: str = "cisco"
ndo_username: str | None = None
ndo_password: str | None = None
# ---------------------------------------------------------------------------
# Top-level Topology
# ---------------------------------------------------------------------------
class Topology(BaseModel):
"""Root topology model. Load via `topology.loader.load_topology(path)`."""
fabric: Fabric
sites: list[Site]
isn: ISN = Field(default_factory=ISN)
tenants: list[Tenant] = Field(default_factory=list)
endpoints: Endpoints = Field(default_factory=Endpoints)
faults: Faults = Field(default_factory=Faults)
access: Access = Field(default_factory=Access)
# auth: optional fabric admin credentials (see Auth docstring above).
# None (the default) means no `auth:` section was set — every topology.yaml
# predating this field keeps validating and behaves exactly as before:
# `cmd_run` injects nothing and auth.py falls back to its own
# SIM_USERNAME/SIM_PASSWORD env-or-admin/cisco default.
auth: Auth | None = None
@model_validator(mode="after")
def normalize_and_validate(self) -> Topology:
"""Expand int counts → Node lists, then check all cross-references."""
# Step 1 — expand
for idx, site in enumerate(self.sites):
site._expand_nodes(site_index=idx)
# Step 1b — controllers range + controller_ips (PR-21). APIC cluster
# size is configurable 1-5 (default 1, single-APIC-per-site by
# design — see Site.controllers docstring); controller_ips, if set,
# must be a valid-IP list of exactly `controllers` entries.
for site in self.sites:
if not (1 <= site.controllers <= 5):
raise ValueError(
f"Site '{site.name}': controllers={site.controllers} is out of range. "
f"Must be 1-5 (default 1 — single APIC per site by design; set 3 for "
f"real ACI's minimum multi-controller cluster size if you need to "
f"exercise cluster-health/infraWiNode semantics)."
)
if site.controller_ips is not None:
if len(site.controller_ips) != site.controllers:
raise ValueError(
f"Site '{site.name}': controller_ips has {len(site.controller_ips)} "
f"entries but controllers={site.controllers}. When controller_ips is "
f"set it must have exactly one IP per controller."
)
for ip in site.controller_ips:
try:
ipaddress.ip_address(ip)
except ValueError as exc:
raise ValueError(
f"Site '{site.name}': controller_ips entry {ip!r} is not a valid IP "
f"address: {exc}"
) from exc
# oob_gateway (OOB-gateway PR): if set, must be a valid IP address.
# Optional (None = no gateway configured for this site — see
# Site.oob_gateway docstring); no further range/subnet check is
# enforced here (the gateway may legitimately sit outside
# fabric.oob_pool/oob_subnet, e.g. a shared OOB VLAN gateway that
# predates this sim's address scheme).
if site.oob_gateway is not None:
try:
ipaddress.ip_address(site.oob_gateway)
except ValueError as exc:
raise ValueError(
f"Site '{site.name}': oob_gateway {site.oob_gateway!r} is not a valid "
f"IP address: {exc}"
) from exc
# Step 2 — collect global node/border identity sets (used by several checks)
site_names = {s.name for s in self.sites}
all_node_ids: list[int] = [] # every node id across every site (with dups)
border_leaf_ids: set[int] = set() # all border-leaf ids across all sites
for site in self.sites:
all_node_ids.extend(n.id for n in site.all_nodes())
border_leaf_ids.update(bl.id for bl in site.border_leaves)
node_id_set = set(all_node_ids)
# Global node-id uniqueness — border-leaf ids must NOT collide with the
# auto-generated spine/leaf ids, and no two sites may share a node id.
duplicates = sorted({nid for nid in all_node_ids if all_node_ids.count(nid) > 1})
if duplicates:
raise ValueError(
f"Node ids collide across sites (border-leaf vs auto-generated spine/leaf, "
f"or two sites overlapping): {duplicates}. "
f"Adjust border_leaf ids or the site offset."
)
# Step 3 — cross-reference validation
for tenant in self.tenants:
vrf_names = {v.name for v in tenant.vrfs}
bd_names = {b.name for b in tenant.bds}
contract_names = {c.name for c in tenant.contracts}
# tenant.sites → real site names
for sname in tenant.sites:
if sname not in site_names:
raise ValueError(
f"Tenant '{tenant.name}' references unknown site '{sname}'. "
f"Known sites: {sorted(site_names)}"
)
# stretched tenant must name ≥2 sites
if tenant.stretch and len(tenant.sites) < 2:
raise ValueError(
f"Tenant '{tenant.name}' has stretch=true but lists fewer than 2 sites: "
f"{tenant.sites}"
)
# BD.vrf → tenant VRFs
for bd in tenant.bds:
if bd.vrf not in vrf_names:
raise ValueError(
f"Tenant '{tenant.name}', BD '{bd.name}' references unknown VRF "
f"'{bd.vrf}'. Known VRFs: {sorted(vrf_names)}"
)
# EPG.bd → tenant BDs + EPG contract refs → tenant contracts
for ap in tenant.aps:
for epg in ap.epgs:
if epg.bd not in bd_names:
raise ValueError(
f"Tenant '{tenant.name}', AP '{ap.name}', EPG '{epg.name}' "
f"references unknown BD '{epg.bd}'. Known BDs: {sorted(bd_names)}"
)
for cname in epg.contracts.provide + epg.contracts.consume:
if cname not in contract_names:
raise ValueError(
f"Tenant '{tenant.name}', AP '{ap.name}', EPG '{epg.name}' "
f"references unknown contract '{cname}'. "
f"Known contracts: {sorted(contract_names)}"
)
# L3Out cross-refs: vrf, optional site, border-leaf ids
for l3out in tenant.l3outs:
if l3out.vrf not in vrf_names:
raise ValueError(
f"Tenant '{tenant.name}', L3Out '{l3out.name}' references unknown VRF "
f"'{l3out.vrf}'. Known VRFs: {sorted(vrf_names)}"
)
if l3out.site is not None and l3out.site not in site_names:
raise ValueError(
f"Tenant '{tenant.name}', L3Out '{l3out.name}' references unknown site "
f"'{l3out.site}'. Known sites: {sorted(site_names)}"
)
for bl_id in l3out.border_leaves:
if bl_id not in border_leaf_ids:
raise ValueError(
f"Tenant '{tenant.name}', L3Out '{l3out.name}' references border-leaf "
f"id {bl_id} which is not a border-leaf of any site. "
f"Known border-leaf ids: {sorted(border_leaf_ids)}"
)
# Fault seeds must target a real node id
for fseed in self.faults.seed:
if fseed.node not in node_id_set:
raise ValueError(
f"Fault seed (code {fseed.code}) targets node {fseed.node} which is not a "
f"real node id. Known node ids: {sorted(node_id_set)}"
)
# ISN peer_mesh:full requires ≥2 sites
if self.isn.enabled and self.isn.peer_mesh == "full" and len(self.sites) < 2:
raise ValueError("ISN peer_mesh:'full' requires at least 2 sites.")
# fabric.loopback_pool/oob_pool (#27): builders hardcode the "10.x"/
# "192.168.x" families (see build/fabric.py loopback_ip/oob_ip) — the
# pool fields are not threaded through as an arbitrary CIDR, so make
# sure they at least describe the network the builder actually uses,
# instead of being silently ignorable config drift.
try:
loopback_net = ipaddress.ip_network(self.fabric.loopback_pool, strict=False)
except ValueError as exc:
raise ValueError(
f"fabric.loopback_pool {self.fabric.loopback_pool!r} is not a valid CIDR: {exc}"
) from exc
if not loopback_net.overlaps(ipaddress.ip_network("10.0.0.0/8")):
raise ValueError(
f"fabric.loopback_pool {self.fabric.loopback_pool!r} must fall within "
f"10.0.0.0/8 — the builder's loopback_ip() always emits "
f"10.<pod>.<node_id>.1-style addresses (build/fabric.py) and does not "
f"honor an arbitrary pool."
)
try:
oob_net = ipaddress.ip_network(self.fabric.oob_pool, strict=False)
except ValueError as exc:
raise ValueError(
f"fabric.oob_pool {self.fabric.oob_pool!r} is not a valid CIDR: {exc}"
) from exc
if not oob_net.overlaps(ipaddress.ip_network("192.168.0.0/16")):
raise ValueError(
f"fabric.oob_pool {self.fabric.oob_pool!r} must fall within "
f"192.168.0.0/16 — the builder's oob_ip() always emits "
f"192.168.<pod>.<node_id>-style addresses (build/fabric.py) and does "
f"not honor an arbitrary pool."
)
# Tier-1 fabric params (PR-18): tep_pool/gipo_pool must be valid CIDRs;
# infra_vlan must fall in the real ACI VLAN range (1-4094). None of
# these are threaded into a builder's address-derivation the way
# loopback_pool/oob_pool partially are (see Fabric docstring above),
# so this is a pure well-formedness check — it exists to catch a
# typo'd CIDR/VLAN before it reaches `aci-sim show`/CI, not to
# enforce agreement with a builder's hardcoded output.
try:
ipaddress.ip_network(self.fabric.tep_pool, strict=False)
except ValueError as exc:
raise ValueError(
f"fabric.tep_pool {self.fabric.tep_pool!r} is not a valid CIDR: {exc}"
) from exc
try:
ipaddress.ip_network(self.fabric.gipo_pool, strict=False)
except ValueError as exc:
raise ValueError(
f"fabric.gipo_pool {self.fabric.gipo_pool!r} is not a valid CIDR: {exc}"
) from exc
if not (1 <= self.fabric.infra_vlan <= 4094):
raise ValueError(
f"fabric.infra_vlan {self.fabric.infra_vlan!r} must be in the range "
f"1-4094 (real ACI VLAN ID range)."
)
# Tier-2 ISN params (PR-19): ospf_area must be a valid OSPF area
# (dotted-decimal like an IPv4 address, e.g. "0.0.0.0", or the
# decimal-integer shorthand ACI also accepts, e.g. "0"); mtu must
# fall within real ACI's inter-pod/inter-site MTU range (576-9216,
# matching the fabric's own l1PhysIf.mtu ceiling in interfaces.py).
area = self.isn.ospf_area
area_ok = False
if area.isdigit():
area_ok = True # decimal-integer area form (e.g. "0")
else:
try:
ipaddress.IPv4Address(area)
area_ok = True # dotted-decimal area form (e.g. "0.0.0.0")
except ValueError:
area_ok = False
if not area_ok:
raise ValueError(
f"isn.ospf_area {area!r} must be a dotted-decimal OSPF area "
f"(e.g. '0.0.0.0') or a decimal-integer area (e.g. '0')."
)
if not (576 <= self.isn.mtu <= 9216):
raise ValueError(
f"isn.mtu {self.isn.mtu!r} must be in the range 576-9216 "
f"(real ACI inter-pod/inter-site MTU range)."
)
# Tier-3 fine-tuning params (PR-20) ------------------------------
# OSPF hello/dead: both must be positive, and (matching real ACI's
# own OSPF sanity check) dead must exceed hello — a dead timer
# shorter than (or equal to) the hello interval can never detect a
# live neighbor before declaring it down.
if self.isn.ospf_hello <= 0 or self.isn.ospf_dead <= 0:
raise ValueError(
f"isn.ospf_hello ({self.isn.ospf_hello}) and isn.ospf_dead "
f"({self.isn.ospf_dead}) must both be positive integers (seconds)."
)
if self.isn.ospf_dead <= self.isn.ospf_hello:
raise ValueError(
f"isn.ospf_dead ({self.isn.ospf_dead}) must be greater than "
f"isn.ospf_hello ({self.isn.ospf_hello}) — a dead timer at or "
f"below the hello interval can never detect a live neighbor."
)
# BFD timers: store+validate only (no bfdIfP MO built anywhere in
# this sim — see ISN docstring). min_rx/min_tx must be positive
# milliseconds; multiplier must be a plausible BFD detect multiplier
# (real ACI UI range is 1-50).
if self.isn.bfd_min_rx <= 0 or self.isn.bfd_min_tx <= 0:
raise ValueError(
f"isn.bfd_min_rx ({self.isn.bfd_min_rx}) and isn.bfd_min_tx "
f"({self.isn.bfd_min_tx}) must both be positive integers (milliseconds)."
)
if not (1 <= self.isn.bfd_multiplier <= 50):
raise ValueError(
f"isn.bfd_multiplier ({self.isn.bfd_multiplier}) must be in the "
f"range 1-50 (real ACI BFD detect-multiplier range)."
)
# BGP keepalive/hold (per l3out.csw_peer): must be positive, and
# (matching real ACI's own BGP timer sanity check) hold must be at
# least 2x keepalive's typical relationship — real ACI enforces
# hold >= 3 or hold==0; we enforce the simpler, always-correct
# invariant that hold exceeds keepalive (a hold at or below the
# keepalive interval can never survive a single missed keepalive).
for tenant in self.tenants:
for l3out in tenant.l3outs:
if l3out.csw_peer is None:
continue
cp = l3out.csw_peer
if cp.keepalive <= 0 or cp.hold <= 0:
raise ValueError(
f"Tenant '{tenant.name}', L3Out '{l3out.name}': csw_peer "
f"keepalive ({cp.keepalive}) and hold ({cp.hold}) must both "
f"be positive integers (seconds)."
)
if cp.hold <= cp.keepalive:
raise ValueError(
f"Tenant '{tenant.name}', L3Out '{l3out.name}': csw_peer "
f"hold ({cp.hold}) must be greater than keepalive "
f"({cp.keepalive}) — a hold timer at or below the keepalive "
f"interval can never survive a single missed keepalive."
)
# BD MAC / fabric.default_bd_mac: must look like a real MAC address
# (real ACI's fvBD.mac accepts colon-separated hex octets, e.g.
# "00:22:BD:F8:19:FF"). Reject anything else at load time rather
# than silently writing a malformed fvBD.mac attribute.
_mac_re = re.compile(r"^[0-9A-Fa-f]{2}(:[0-9A-Fa-f]{2}){5}$")
if not _mac_re.match(self.fabric.default_bd_mac):
raise ValueError(
f"fabric.default_bd_mac {self.fabric.default_bd_mac!r} is not a "
f"valid MAC address (expected colon-separated hex octets, e.g. "
f"'00:22:BD:F8:19:FF')."
)
for tenant in self.tenants:
for bd in tenant.bds:
if bd.mac is not None and not _mac_re.match(bd.mac):
raise ValueError(
f"Tenant '{tenant.name}', BD '{bd.name}': bd.mac {bd.mac!r} "
f"is not a valid MAC address (expected colon-separated hex "
f"octets, e.g. '00:22:BD:F8:19:FF')."
)
# oob_subnet / inb_subnet: pure well-formedness + range checks (both
# are store+validate-only — see Fabric docstring above). oob_subnet
# must fall within 192.168.0.0/16 (same family oob_pool is
# validated against); inb_subnet must be a private RFC1918 range
# (ACI's in-band mgmt EPG is conventionally carved out of RFC1918
# space, same as OOB) and must be a valid CIDR.
if self.fabric.oob_subnet is not None:
try:
oob_subnet_net = ipaddress.ip_network(self.fabric.oob_subnet, strict=False)
except ValueError as exc:
raise ValueError(
f"fabric.oob_subnet {self.fabric.oob_subnet!r} is not a valid "
f"CIDR: {exc}"
) from exc
if not oob_subnet_net.overlaps(ipaddress.ip_network("192.168.0.0/16")):
raise ValueError(
f"fabric.oob_subnet {self.fabric.oob_subnet!r} must fall "
f"within 192.168.0.0/16, matching fabric.oob_pool's own "
f"address family."
)
if self.fabric.inb_subnet is not None:
try:
inb_net = ipaddress.ip_network(self.fabric.inb_subnet, strict=False)
except ValueError as exc:
raise ValueError(
f"fabric.inb_subnet {self.fabric.inb_subnet!r} is not a valid "
f"CIDR: {exc}"
) from exc
_rfc1918 = (
ipaddress.ip_network("10.0.0.0/8"),
ipaddress.ip_network("172.16.0.0/12"),
ipaddress.ip_network("192.168.0.0/16"),
)
if not any(inb_net.overlaps(net) for net in _rfc1918):
raise ValueError(
f"fabric.inb_subnet {self.fabric.inb_subnet!r} must fall "
f"within a private RFC1918 range (10.0.0.0/8, "
f"172.16.0.0/12, or 192.168.0.0/16)."
)
# Tier-2 VMM domain params (PR-19): a VMM domain's `vlan_pool` (if
# set) must reference a real access.vlan_pools[] entry, same
# cross-reference discipline as tenant.bds[].vrf / epg.bd above.
vlan_pool_names = {p.name for p in self.access.vlan_pools}
for vmm in self.access.vmm_domains:
if vmm.vlan_pool and vmm.vlan_pool not in vlan_pool_names:
raise ValueError(
f"access.vmm_domains '{vmm.name}' references unknown vlan_pool "
f"'{vmm.vlan_pool}'. Known vlan pools: {sorted(vlan_pool_names)}"
)
# CSW peering params are duplicated between site.border_leaves[].csw and
# tenant.l3outs[].csw_peer (#28) — the border-leaf's `csw` is authoritative
# (it carries local_ip/vlan too, which l3out.csw_peer lacks; l3out.py's
# per-path encap/local_ip/l3extMember addressing comes only from
# border_leaf.csw). Any l3out.csw_peer covering the same border leaves
# must agree with the border-leaf's csw on name/asn/peer_ip, or the
# rendered BGP session (from csw_peer) and the physical sub-interface
# (from border_leaf.csw) would silently describe two different CSWs.
bl_csw_by_id = {
bl.id: bl.csw
for site in self.sites
for bl in site.border_leaves
}
for tenant in self.tenants:
for l3out in tenant.l3outs:
if l3out.csw_peer is None:
continue
for bl_id in l3out.border_leaves:
bl_csw = bl_csw_by_id.get(bl_id)
if bl_csw is None:
continue # already reported by the border-leaf existence check above
mismatch = (
bl_csw.name != l3out.csw_peer.name
or bl_csw.asn != l3out.csw_peer.asn
or bl_csw.peer_ip != l3out.csw_peer.peer_ip
)
if mismatch:
raise ValueError(
f"Tenant '{tenant.name}', L3Out '{l3out.name}': csw_peer "
f"{{name={l3out.csw_peer.name!r}, asn={l3out.csw_peer.asn}, "
f"peer_ip={l3out.csw_peer.peer_ip!r}}} does not match border-leaf "
f"{bl_id}'s csw {{name={bl_csw.name!r}, asn={bl_csw.asn}, "
f"peer_ip={bl_csw.peer_ip!r}}}. These must agree — the border-leaf's "
f"csw is authoritative (see docs/DESIGN.md)."
)
return self
# ------------------------------------------------------------------ #
# Topology-level helpers #
# ------------------------------------------------------------------ #
def site_by_name(self, name: str) -> Site:
"""Look up a site by name; raises KeyError if not found."""
for site in self.sites:
if site.name == name:
return site
raise KeyError(f"Site '{name}' not found in topology")
def other_site(self, name: str) -> Site:
"""Return the first site that is NOT `name`.
Useful for ISN remote-ASN lookup in a 2-site topology.
Raises KeyError if no other site exists.
"""
others = [s for s in self.sites if s.name != name]
if not others:
raise KeyError(f"No site other than '{name}' in topology")
return others[0]