mirror of
https://github.com/dtzp555-max/aci-sim.git
synced 2026-07-21 21:15:12 +00:00
feat: materialize L3Out eBGP operational sessions for runtime-pushed tenants (v0.18.0)
A tenant L3Out POSTed live via /api/mo (cisco.aci / aci-py) only landed the
bgpPeerP config MO; the operational bgpPeer/bgpPeerEntry/bgpPeerAfEntry that
build/l3out.py emits for topology-defined L3Outs never existed for it, so
per-tenant L3Out BGP session views stayed empty. writes.py now reacts to
runtime bgpPeerP writes: dom-{tenant}:{vrf} (VRF from l3extRsEctx, ASN from
the nested bgpAsP, node from the path DN), operSt=established. Skips when no
VRF is bound or on status=deleted. 3 new regression tests; suite: 903 passed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
63a6cb06e2
commit
6ecd89d550
@@ -6,6 +6,23 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
|
||||
(pre-1.0: minor bumps may include breaking changes to the sim's behavior).
|
||||
|
||||
## [0.18.0] - 2026-07-06
|
||||
|
||||
### Added
|
||||
- **Runtime tenant-L3Out eBGP session materialization** — a tenant L3Out
|
||||
pushed live via `POST /api/mo` (cisco.aci playbooks / aci-py) lands
|
||||
`bgpPeerP` (config) through the generic write path, which never produced
|
||||
the operational session MOs. `writes.py` now reacts to every runtime
|
||||
`bgpPeerP` by materializing `bgpPeer`/`bgpPeerEntry`(`operSt=established`)/
|
||||
`bgpPeerAfEntry` under a `dom-{tenant}:{vrf}` BGP domain on the L3Out's own
|
||||
node — the peer ASN derived from the nested `bgpAsP`, the VRF from the
|
||||
L3Out's `l3extRsEctx.tnFvCtxName` (skips cleanly when no VRF is bound, or
|
||||
on `status=deleted`). This mirrors what `build/l3out.py` already does for
|
||||
topology-defined L3Outs, and makes per-tenant L3Out BGP session views in
|
||||
API clients (e.g. autoACI's "L3Out BGP" topology table) populate for
|
||||
runtime-pushed tenants. New regression tests in
|
||||
`tests/test_l3out_bgp_session.py` (903 tests total).
|
||||
|
||||
## [0.17.0] - 2026-07-05
|
||||
|
||||
### Added
|
||||
|
||||
@@ -7,6 +7,7 @@ import re
|
||||
from aci_sim.build.fabric import loopback_ip, oob_ip
|
||||
from aci_sim.build.interfaces import _HOST_PORTS, _UPLINK_START, _add_port
|
||||
from aci_sim.build.neighbors import add_switch_adjacency
|
||||
from aci_sim.mit.dn import pod_from_dn
|
||||
from aci_sim.mit.mo import MO
|
||||
from aci_sim.mit.store import MITStore
|
||||
from aci_sim.topology.schema import Node
|
||||
@@ -419,6 +420,102 @@ def materialize_node_registration(
|
||||
)
|
||||
|
||||
|
||||
_BGP_PEERP_RE = re.compile(
|
||||
r"uni/tn-(?P<tenant>[^/]+)/out-(?P<l3out>[^/]+)/.*"
|
||||
r"paths-(?P<node>\d+)/pathep-\[[^\]]+\]\]/peerP-\[(?P<peer>[^\]]+)\]$"
|
||||
)
|
||||
|
||||
|
||||
def _materialize_l3out_bgp_session(
|
||||
store: MITStore, peer_dn: str, addr: str, remote_asn: str
|
||||
) -> None:
|
||||
"""Reaction: a tenant L3Out ``bgpPeerP`` (config) was POSTed — materialize
|
||||
the matching operational session MOs (``bgpPeer``/``bgpPeerEntry``/
|
||||
``bgpPeerAfEntry``) so autoACI's ``_compute_session_tables`` (routers/
|
||||
topology.py) L3Out BGP table finds it, exactly like the boot-time
|
||||
topology-driven path already does for its own L3Outs (build/l3out.py's
|
||||
``_upsert_ebgp_sessions``) — this covers the OTHER path, a tenant config
|
||||
pushed live via POST /api/mo (cisco.aci ansible playbook / hidden_push.py),
|
||||
which lands generic MOs here in writes.py instead of going through the
|
||||
topology builders.
|
||||
|
||||
DN/attribute derivation (verified against routers/topology.py's
|
||||
``_compute_session_tables``, ``_dn_dom`` and ``_dn_node`` helpers):
|
||||
- node: parsed from the bgpPeerP's own DN (".../paths-{N}/pathep-...")
|
||||
— this IS the L3Out border-leaf, same node the peer is deployed on.
|
||||
- dom: MUST be ``dom-{tenant}:{vrf}`` (colon-separated) — topology.py's
|
||||
``_dn_dom`` extracts the dom segment verbatim and then does
|
||||
``if ":" not in dom: continue`` to skip fabric/overlay (``dom-
|
||||
overlay-1``) peers, then ``tenant, _, vrf = dom.partition(":")``.
|
||||
vrf is read from the L3Out's own ``l3extRsEctx.tnFvCtxName`` (already
|
||||
in the store from the earlier ``aci_l3out`` POST) rather than
|
||||
hardcoded, so it always matches whatever VRF the tenant push used.
|
||||
- remoteAsn: topology.py reads it off the sibling ``bgpPeer.asn``
|
||||
attribute (matched via the entry's parent dn) — sourced from the
|
||||
bgpPeerP's own nested ``bgpAsP.asn`` child, i.e. the L3Out's actual
|
||||
configured peer ASN, never hardcoded.
|
||||
"""
|
||||
m = _BGP_PEERP_RE.match(peer_dn)
|
||||
if not m:
|
||||
return
|
||||
tenant = m.group("tenant")
|
||||
l3out_name = m.group("l3out")
|
||||
node_id = m.group("node")
|
||||
|
||||
l3out_dn = f"uni/tn-{tenant}/out-{l3out_name}"
|
||||
vrf = ""
|
||||
for ectx in store.children(l3out_dn, {"l3extRsEctx"}):
|
||||
vrf = ectx.attrs.get("tnFvCtxName", "")
|
||||
if vrf:
|
||||
break
|
||||
if not vrf:
|
||||
# Can't place this peer under a tenant-VRF dom without a VRF name —
|
||||
# nothing sane to derive it from, so skip rather than fudge a dom
|
||||
# that would silently misclassify as fabric/overlay (no ":").
|
||||
return
|
||||
|
||||
pod = pod_from_dn(peer_dn)
|
||||
inst_dn = f"topology/pod-{pod}/node-{node_id}/sys/bgp/inst"
|
||||
inst_mo = MO("bgpInst", dn=inst_dn)
|
||||
|
||||
dom_name = f"{tenant}:{vrf}"
|
||||
dom_dn = f"{inst_dn}/dom-{dom_name}"
|
||||
dom_mo = MO("bgpDom", dn=dom_dn, name=dom_name)
|
||||
|
||||
session_peer_dn = f"{dom_dn}/peer-[{addr}]"
|
||||
peer_mo = MO(
|
||||
"bgpPeer",
|
||||
dn=session_peer_dn,
|
||||
addr=addr,
|
||||
asn=remote_asn,
|
||||
type="ebgp",
|
||||
peerRole="ce",
|
||||
)
|
||||
entry_dn = f"{session_peer_dn}/ent-[{addr}]"
|
||||
peer_mo.add_child(MO(
|
||||
"bgpPeerEntry",
|
||||
dn=entry_dn,
|
||||
addr=addr,
|
||||
operSt="established",
|
||||
connEst="1",
|
||||
connDrop="0",
|
||||
type="ebgp",
|
||||
))
|
||||
af_dn = f"{entry_dn}/af-[ipv4-ucast]"
|
||||
peer_mo.add_child(MO(
|
||||
"bgpPeerAfEntry",
|
||||
dn=af_dn,
|
||||
type="ipv4-ucast",
|
||||
afId="ipv4-ucast",
|
||||
acceptedPaths="3",
|
||||
pfxSent="3",
|
||||
tblVer="1",
|
||||
))
|
||||
dom_mo.add_child(peer_mo)
|
||||
inst_mo.add_child(dom_mo)
|
||||
store.upsert(inst_mo)
|
||||
|
||||
|
||||
def apply(store: MITStore, dn: str, body: dict, *, topo=None, site=None) -> tuple[list[dict], int]:
|
||||
"""Apply a write (upsert or delete) from a POST /api/mo/{dn}.json body.
|
||||
|
||||
@@ -464,6 +561,29 @@ def apply(store: MITStore, dn: str, body: dict, *, topo=None, site=None) -> tupl
|
||||
store, topo=topo, site=site, node_id=node_id, name=name, role=role,
|
||||
)
|
||||
|
||||
# Tenant L3Out eBGP peer (bgpPeerP) reaction: materialize the operational
|
||||
# bgpPeer/bgpPeerEntry/bgpPeerAfEntry session MOs so autoACI's L3Out BGP
|
||||
# table (routers/topology.py) finds it — see _materialize_l3out_bgp_session
|
||||
# docstring. bgpAsP (the peer ASN) is POSTed as bgpPeerP's own nested
|
||||
# child in the SAME body (cisco.aci's tenant.yml task), so it is already
|
||||
# in this same `planned` list — read it from there instead of a second
|
||||
# store lookup.
|
||||
for mo_cls, mo_attrs in planned:
|
||||
if mo_cls != "bgpPeerP":
|
||||
continue
|
||||
if mo_attrs.get("status") == "deleted":
|
||||
continue
|
||||
peer_dn = mo_attrs.get("dn", "")
|
||||
addr = mo_attrs.get("addr", "")
|
||||
if not peer_dn or not addr:
|
||||
continue
|
||||
remote_asn = ""
|
||||
for as_cls, as_attrs in planned:
|
||||
if as_cls == "bgpAsP" and as_attrs.get("dn", "").startswith(f"{peer_dn}/"):
|
||||
remote_asn = as_attrs.get("asn", "")
|
||||
break
|
||||
_materialize_l3out_bgp_session(store, peer_dn, addr, remote_asn)
|
||||
|
||||
status = "deleted" if attrs.get("status") == "deleted" else "created"
|
||||
result = [{cls: {"attributes": {"dn": effective_dn, "status": status}}}]
|
||||
return result, 1
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "aci-sim"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
description = "Faithful REST simulator of a 2-site Cisco ACI fabric (per-site APIC + ND/NDO)"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Runtime tenant-L3Out eBGP session materialization (writes.py reaction).
|
||||
|
||||
A tenant L3Out pushed live via POST /api/mo (cisco.aci playbook / aci-py
|
||||
hidden_push) lands ``bgpPeerP`` (config) in writes.py — NOT via the topology
|
||||
builders, so build/l3out.py's boot-time ``bgpPeer``/``bgpPeerEntry`` emission
|
||||
never runs for it. The ``_materialize_l3out_bgp_session`` reaction closes that
|
||||
gap: every runtime ``bgpPeerP`` also materializes the operational session MOs
|
||||
(``bgpPeer``/``bgpPeerEntry``/``bgpPeerAfEntry``) under a ``dom-{tenant}:{vrf}``
|
||||
BGP domain, which is exactly what autoACI's L3Out-BGP session table parses
|
||||
(``_dn_dom`` → ``if ":" not in dom: skip`` → ``tenant, _, vrf = partition(":")``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from aci_sim.build.orchestrator import build_site
|
||||
from aci_sim.rest_aci.app import ApicSiteState, make_apic_app
|
||||
from aci_sim.topology.loader import load_topology
|
||||
|
||||
TOPO_PATH = "topology.yaml" # run from project root
|
||||
|
||||
TENANT = "BGPSESS-T1"
|
||||
L3OUT = "l3o-bgp-test"
|
||||
VRF = "vrf-L3_TEST"
|
||||
PEER_IP = "10.9.9.1"
|
||||
PEER_ASN = "65100"
|
||||
PEERP_DN = (
|
||||
f"uni/tn-{TENANT}/out-{L3OUT}/lnodep-lnp-Core/lifp-lip-Core/"
|
||||
f"rspathL3OutAtt-[topology/pod-1/paths-101/pathep-[eth1/9]]/peerP-[{PEER_IP}]"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def client():
|
||||
topo = load_topology(TOPO_PATH)
|
||||
site = topo.sites[0]
|
||||
store = build_site(topo, site)
|
||||
state = ApicSiteState(
|
||||
name=site.name,
|
||||
site=site,
|
||||
topo=topo,
|
||||
store=store,
|
||||
baseline=copy.deepcopy(store),
|
||||
)
|
||||
app = make_apic_app(state)
|
||||
c = TestClient(app)
|
||||
resp = c.post(
|
||||
"/api/aaaLogin.json",
|
||||
json={"aaaUser": {"attributes": {"name": "admin", "pwd": "cisco"}}},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
return c
|
||||
|
||||
|
||||
def _post_mo(client, dn: str, body: dict):
|
||||
resp = client.post(f"/api/mo/{dn}.json", json=body)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp
|
||||
|
||||
|
||||
def _class_dns(client, cls: str) -> list[str]:
|
||||
data = client.get(f"/api/class/{cls}.json").json()
|
||||
return [next(iter(i.values()))["attributes"]["dn"] for i in data["imdata"]]
|
||||
|
||||
|
||||
def test_runtime_bgp_peerp_materializes_operational_session(client):
|
||||
# 1. Tenant, then the L3Out at its EXPLICIT DN with the VRF binding
|
||||
# (l3extRsEctx) nested — mirroring how cisco.aci / aci-py actually
|
||||
# post (per-MO, dn in attributes; nested l3extOut would get an
|
||||
# auto-numbered RN since l3extOut has no _RN_PREFIXES entry).
|
||||
_post_mo(client, f"uni/tn-{TENANT}", {
|
||||
"fvTenant": {"attributes": {"name": TENANT}},
|
||||
})
|
||||
l3out_dn = f"uni/tn-{TENANT}/out-{L3OUT}"
|
||||
_post_mo(client, l3out_dn, {
|
||||
"l3extOut": {"attributes": {"name": L3OUT, "dn": l3out_dn}, "children": [
|
||||
{"l3extRsEctx": {"attributes": {"tnFvCtxName": VRF}}},
|
||||
]},
|
||||
})
|
||||
# 2. The eBGP peer config, nested bgpAsP carrying the remote (CSW) ASN —
|
||||
# same single-POST shape the cisco.aci tenant task produces.
|
||||
_post_mo(client, PEERP_DN, {
|
||||
"bgpPeerP": {"attributes": {"addr": PEER_IP, "dn": PEERP_DN}, "children": [
|
||||
{"bgpAsP": {"attributes": {"asn": PEER_ASN}}},
|
||||
]},
|
||||
})
|
||||
|
||||
dom = f"dom-{TENANT}:{VRF}"
|
||||
exp_peer = f"topology/pod-1/node-101/sys/bgp/inst/{dom}/peer-[{PEER_IP}]"
|
||||
|
||||
peer_dns = [d for d in _class_dns(client, "bgpPeer") if dom in d]
|
||||
assert peer_dns == [exp_peer]
|
||||
|
||||
# bgpPeer carries the derived remote ASN (from the nested bgpAsP).
|
||||
data = client.get("/api/class/bgpPeer.json").json()
|
||||
peer_attrs = next(
|
||||
i["bgpPeer"]["attributes"] for i in data["imdata"]
|
||||
if i["bgpPeer"]["attributes"]["dn"] == exp_peer
|
||||
)
|
||||
assert peer_attrs["asn"] == PEER_ASN
|
||||
assert peer_attrs["type"] == "ebgp"
|
||||
|
||||
# bgpPeerEntry (operSt) + bgpPeerAfEntry (prefix counters) exist under it.
|
||||
entry_dns = [d for d in _class_dns(client, "bgpPeerEntry") if dom in d]
|
||||
assert entry_dns == [f"{exp_peer}/ent-[{PEER_IP}]"]
|
||||
entry_attrs = next(
|
||||
i["bgpPeerEntry"]["attributes"]
|
||||
for i in client.get("/api/class/bgpPeerEntry.json").json()["imdata"]
|
||||
if dom in i["bgpPeerEntry"]["attributes"]["dn"]
|
||||
)
|
||||
assert entry_attrs["operSt"] == "established"
|
||||
|
||||
af_dns = [d for d in _class_dns(client, "bgpPeerAfEntry") if dom in d]
|
||||
assert af_dns == [f"{exp_peer}/ent-[{PEER_IP}]/af-[ipv4-ucast]"]
|
||||
|
||||
|
||||
def test_bgp_peerp_without_vrf_binding_is_skipped(client):
|
||||
"""No l3extRsEctx on the L3Out → no tenant:vrf dom to place the session
|
||||
under; the reaction must skip rather than fudge a dom that autoACI would
|
||||
misclassify (no ':' → treated as fabric/overlay)."""
|
||||
tenant2 = "BGPSESS-T2"
|
||||
peerp2 = (
|
||||
f"uni/tn-{tenant2}/out-l3o-novrf/lnodep-lnp/lifp-lip/"
|
||||
f"rspathL3OutAtt-[topology/pod-1/paths-101/pathep-[eth1/10]]/peerP-[10.9.9.2]"
|
||||
)
|
||||
_post_mo(client, f"uni/tn-{tenant2}", {
|
||||
"fvTenant": {"attributes": {"name": tenant2}, "children": [
|
||||
{"l3extOut": {"attributes": {"name": "l3o-novrf"}}},
|
||||
]},
|
||||
})
|
||||
_post_mo(client, peerp2, {
|
||||
"bgpPeerP": {"attributes": {"addr": "10.9.9.2", "dn": peerp2}, "children": [
|
||||
{"bgpAsP": {"attributes": {"asn": "65100"}}},
|
||||
]},
|
||||
})
|
||||
assert not [d for d in _class_dns(client, "bgpPeer") if tenant2 in d]
|
||||
|
||||
|
||||
def test_deleted_bgp_peerp_does_not_materialize(client):
|
||||
"""status=deleted bgpPeerP must not (re)create session MOs."""
|
||||
tenant3 = "BGPSESS-T3"
|
||||
peerp3 = (
|
||||
f"uni/tn-{tenant3}/out-{L3OUT}/lnodep-lnp/lifp-lip/"
|
||||
f"rspathL3OutAtt-[topology/pod-1/paths-101/pathep-[eth1/11]]/peerP-[10.9.9.3]"
|
||||
)
|
||||
_post_mo(client, f"uni/tn-{tenant3}", {
|
||||
"fvTenant": {"attributes": {"name": tenant3}},
|
||||
})
|
||||
# L3Out at its explicit DN WITH a VRF binding — so the only thing
|
||||
# stopping materialization below is the deleted status, not a missing VRF.
|
||||
l3out3_dn = f"uni/tn-{tenant3}/out-{L3OUT}"
|
||||
_post_mo(client, l3out3_dn, {
|
||||
"l3extOut": {"attributes": {"name": L3OUT, "dn": l3out3_dn}, "children": [
|
||||
{"l3extRsEctx": {"attributes": {"tnFvCtxName": VRF}}},
|
||||
]},
|
||||
})
|
||||
_post_mo(client, peerp3, {
|
||||
"bgpPeerP": {"attributes": {"addr": "10.9.9.3", "dn": peerp3, "status": "deleted"}},
|
||||
})
|
||||
assert not [d for d in _class_dns(client, "bgpPeer") if tenant3 in d]
|
||||
Reference in New Issue
Block a user