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

314 lines
11 KiB
Python

"""build/endpoints.py — fvCEp, fvIp, epmMacEp, epmIpEp, fvIfConn.
Port allocation avoids the APIC-controller-reserved port range: fabric.py wires
each APIC-<cid> (cid 1..site.controllers) to eth1/<cid> on the first two leaves
(site.leaf_nodes()[:2]) — the same leaves endpoints are placed on. Endpoint
host ports therefore start right AFTER that reserved range so no port ever
hosts both a controller lldpAdjEp and an endpoint fabricPathDn (see
interfaces.py's _HOST_PORTS=8 host-port inventory for the ceiling).
Multi-site (stretched-tenant) endpoint semantics (see docs/DESIGN.md):
A stretched tenant (tenant.stretch=true, sites: [A, B]) is built independently
per site by this same build() function, once per site's MITStore. Real ACI
never shows the identical endpoint as locally-learned-on-a-front-panel-port at
BOTH sites simultaneously: exactly one site has it physically attached (HOME,
lcC contains "learned", fabricPathDn = a real front-panel pathep); the other
site only knows about it via the multi-site overlay/ISN tunnel (REMOTE,
lcC="learned,vxlan", fabricPathDn = a tunnelIf path toward the home site's
spine-proxy TEP). The HOME site is chosen deterministically per endpoint
(alternating over tenant.sites by the endpoint's position in the per-tenant
sequence) so both sites' independent builds agree on which one is home
without needing to share state.
"""
from __future__ import annotations
import ipaddress
import zlib
from aci_sim.mit.mo import MO
from aci_sim.mit.store import MITStore
from aci_sim.topology.schema import BD, Site, Tenant, Topology
from aci_sim.build.fabric import loopback_ip
from aci_sim.build.interfaces import _HOST_PORTS
def _is_deployed(tenant: Tenant, site: Site) -> bool:
return site.name in tenant.sites
def _vnid(name: str, base: int) -> int:
return base + (zlib.crc32(name.encode()) & 0xFFFF)
def _mac(counter: int) -> str:
aa = (counter >> 16) & 0xFF
bb = (counter >> 8) & 0xFF
cc = counter & 0xFF
return f"00:50:56:{aa:02X}:{bb:02X}:{cc:02X}"
def _host_ip(bd: BD, index: int) -> str:
"""Return host at position 10+index within the first BD subnet."""
if not bd.subnets:
return "0.0.0.0"
net = ipaddress.ip_network(bd.subnets[0], strict=False)
hosts = list(net.hosts())
pos = 10 + index
return str(hosts[pos % len(hosts)])
def _emit_epm_mos(
store: MITStore,
mac: str,
ip: str,
epg_vlan: int,
pod: int,
leaf_id: int,
port_ifid: str,
epg_ref: str,
vrf_vnid: int,
bd_vnid: int,
flags: str = "local",
) -> None:
"""Emit epmMacEp, epmIpEp, and (for real front-panel ports) fvIfConn."""
ctx_bd = f"ctx-[vxlan-{vrf_vnid}]/bd-[vxlan-{bd_vnid}]"
node_sys = f"topology/pod-{pod}/node-{leaf_id}/sys"
store.add(MO(
"epmMacEp",
dn=f"{node_sys}/{ctx_bd}/db-ep/mac-{mac}",
addr=mac,
ifId=port_ifid,
flags=flags,
createTs="2026-01-01T00:00:00.000+00:00",
encap=f"vlan-{epg_vlan}",
))
store.add(MO(
"epmIpEp",
dn=f"{node_sys}/{ctx_bd}/db-ep/ip-[{ip}]",
addr=ip,
ifId=port_ifid,
flags=flags,
createTs="2026-01-01T00:00:00.000+00:00",
))
if flags == "local":
store.add(MO(
"fvIfConn",
dn=(
f"{node_sys}/phys-[{port_ifid}]"
f"/epgconn-[{epg_ref}]"
),
encap=f"vlan-{epg_vlan}",
))
def _emit_endpoint(
store: MITStore,
cep_dn: str,
mac: str,
ip: str,
epg_vlan: int,
pod: int,
leaf_id: int,
port: int,
epg_ref: str,
vrf_vnid: int,
bd_vnid: int,
) -> None:
"""Emit fvCEp+fvIp+epmMacEp+epmIpEp+fvIfConn for one LOCAL (home-site)
endpoint — physically attached to a real front-panel leaf port."""
cep_mo = MO(
"fvCEp",
dn=cep_dn,
mac=mac,
ip="0.0.0.0",
encap=f"vlan-{epg_vlan}",
lcC="learned",
fabricPathDn=(
f"topology/pod-{pod}/paths-{leaf_id}/pathep-[eth1/{port}]"
),
)
cep_mo.add_child(MO(
"fvIp",
dn=f"{cep_dn}/ip-[{ip}]",
addr=ip,
))
store.add(cep_mo)
_emit_epm_mos(
store, mac, ip, epg_vlan, pod, leaf_id, f"eth1/{port}",
epg_ref, vrf_vnid, bd_vnid, flags="local",
)
def _ensure_tunnel_if(
store: MITStore,
pod: int,
spine_id: int,
remote_pod: int,
remote_spine_id: int,
seen: set[tuple[int, int]],
) -> str:
"""Ensure a tunnelIf MO exists on *spine_id* toward the remote site's
spine-proxy TEP (represented by remote_spine_id's loopback); return its
interface id (e.g. "tunnel401"), used to build the fvCEp fabricPathDn.
Real ACI multi-site: remote-learned endpoints are reachable via a VXLAN
tunnel from the local spine-proxy to the remote site's spine-proxy anycast
TEP. We model one tunnelIf per (local spine, remote spine) pair, numbered
deterministically by the remote spine's id so the DN is stable and
idempotent across multiple endpoints sharing the same tunnel.
"""
key = (spine_id, remote_spine_id)
tunnel_id = f"tunnel{remote_spine_id}"
if key not in seen:
seen.add(key)
dest_tep = loopback_ip(remote_pod, remote_spine_id)
store.add(MO(
"tunnelIf",
dn=f"topology/pod-{pod}/node-{spine_id}/sys/tunnel-[{tunnel_id}]",
id=tunnel_id,
dest=dest_tep,
src=loopback_ip(pod, spine_id),
operSt="up",
type="logical-l3-multicast",
layer="l3-multicast",
))
return tunnel_id
def _emit_remote_endpoint(
store: MITStore,
cep_dn: str,
mac: str,
ip: str,
epg_vlan: int,
pod: int,
tunnel_spine_id: int,
tunnel_id: str,
epg_ref: str,
vrf_vnid: int,
bd_vnid: int,
) -> None:
"""Emit fvCEp+fvIp+epmMacEp+epmIpEp for one REMOTE (peer-site) endpoint —
learned via the multi-site overlay tunnel, not a real front-panel port."""
fabric_path_dn = (
f"topology/pod-{pod}/paths-{tunnel_spine_id}/pathep-[{tunnel_id}]"
)
cep_mo = MO(
"fvCEp",
dn=cep_dn,
mac=mac,
ip="0.0.0.0",
encap=f"vlan-{epg_vlan}",
lcC="learned,vxlan",
fabricPathDn=fabric_path_dn,
)
cep_mo.add_child(MO(
"fvIp",
dn=f"{cep_dn}/ip-[{ip}]",
addr=ip,
))
store.add(cep_mo)
_emit_epm_mos(
store, mac, ip, epg_vlan, pod, tunnel_spine_id, tunnel_id,
epg_ref, vrf_vnid, bd_vnid, flags="learned,vxlan",
)
def build(topo: Topology, site: Site, store: MITStore) -> None:
"""Emit endpoint + EPM MOs for every EPG deployed at *site*.
MAC/VLAN sequences are per-store (per site/APIC) and assume the intended
one-MITStore-per-APIC model; merging two site stores would collide DNs/MACs.
"""
pod = site.pod
leaves = site.leaf_nodes()
per_epg = topo.endpoints.per_epg
first_vlan = topo.access.vlan_pools[0].from_vlan if topo.access.vlan_pools else 100
# APIC-{cid} controllers (cid 1..site.controllers) are wired to eth1/{cid}
# on the first two leaves (fabric.py: apic_leaves = site.leaf_nodes()[:2]).
# Reserve that port range on those leaves; endpoints start right after it.
apic_reserved_leaf_ids = {n.id for n in site.leaf_nodes()[:2]}
apic_port_start = 1 + site.controllers
free_host_ports = max(_HOST_PORTS - site.controllers, 1)
global_epg_index = 0
ep_counter = 0
tunnels_seen: set[tuple[int, int]] = set()
for tenant in topo.tenants:
if not _is_deployed(tenant, site):
continue
t = tenant.name
bd_by_name = {bd.name: bd for bd in tenant.bds}
# A stretched tenant's endpoints are HOME on exactly one of its sites
# (see module docstring). Non-stretched (site-local) tenants have no
# remote counterpart — every endpoint is simply local, as before.
is_stretched = tenant.stretch and len(tenant.sites) > 1
remote_site = None
if is_stretched:
try:
remote_site = topo.other_site(site.name) if site.name in tenant.sites else None
except KeyError:
remote_site = None
for ap in tenant.aps:
for epg in ap.epgs:
bd = bd_by_name.get(epg.bd)
epg_vlan = first_vlan + global_epg_index
global_epg_index += 1
if bd is None or not bd.subnets:
continue
vrf_vnid = _vnid(f"{t}:{bd.vrf}", 2900000)
bd_vnid = _vnid(f"{t}:{bd.name}", 15000000)
epg_ref = f"uni/tn-{t}/ap-{ap.name}/epg-{epg.name}"
for i in range(per_epg):
# Deterministic HOME-site assignment: alternates over
# tenant.sites by this endpoint's position in the
# per-tenant sequence, so LAB1's and LAB2's independent
# build() calls agree on which site is home without
# sharing state.
home_site_name = (
tenant.sites[ep_counter % len(tenant.sites)]
if is_stretched else site.name
)
mac = _mac(ep_counter)
ep_counter += 1
ip = _host_ip(bd, i)
cep_dn = f"uni/tn-{t}/ap-{ap.name}/epg-{epg.name}/cep-{mac}"
if not is_stretched or home_site_name == site.name:
leaf = leaves[i % len(leaves)]
leaf_id = leaf.id
if leaf_id in apic_reserved_leaf_ids:
# Skip past the APIC-reserved eth1/1..eth1/{controllers}
# range so no endpoint double-books a controller port.
port = apic_port_start + (i % free_host_ports)
else:
port = (i % _HOST_PORTS) + 1
_emit_endpoint(
store, cep_dn, mac, ip, epg_vlan, pod,
leaf_id, port, epg_ref, vrf_vnid, bd_vnid,
)
elif remote_site is not None:
# This site is the PEER for this endpoint: learned via
# the multi-site overlay tunnel, not a front-panel port.
local_spines = site.spine_nodes()
home_spines = remote_site.spine_nodes()
if not local_spines or not home_spines:
continue
tunnel_spine = local_spines[i % len(local_spines)]
home_spine = home_spines[i % len(home_spines)]
tunnel_id = _ensure_tunnel_if(
store, pod, tunnel_spine.id,
remote_site.pod, home_spine.id, tunnels_seen,
)
_emit_remote_endpoint(
store, cep_dn, mac, ip, epg_vlan, pod,
tunnel_spine.id, tunnel_id, epg_ref, vrf_vnid, bd_vnid,
)