From 6ecd89d5506888da6992e925fa79d8bc5b333d03 Mon Sep 17 00:00:00 2001 From: dtzp555-max Date: Mon, 6 Jul 2026 21:44:00 +1000 Subject: [PATCH] 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 --- CHANGELOG.md | 17 ++++ aci_sim/rest_aci/writes.py | 120 +++++++++++++++++++++++ pyproject.toml | 2 +- tests/test_l3out_bgp_session.py | 164 ++++++++++++++++++++++++++++++++ 4 files changed, 302 insertions(+), 1 deletion(-) create mode 100644 tests/test_l3out_bgp_session.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0690829..6f1de51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/aci_sim/rest_aci/writes.py b/aci_sim/rest_aci/writes.py index 88f662b..a007d7f 100644 --- a/aci_sim/rest_aci/writes.py +++ b/aci_sim/rest_aci/writes.py @@ -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[^/]+)/out-(?P[^/]+)/.*" + r"paths-(?P\d+)/pathep-\[[^\]]+\]\]/peerP-\[(?P[^\]]+)\]$" +) + + +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 diff --git a/pyproject.toml b/pyproject.toml index e6c4765..acf4533 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tests/test_l3out_bgp_session.py b/tests/test_l3out_bgp_session.py new file mode 100644 index 0000000..f1dd07a --- /dev/null +++ b/tests/test_l3out_bgp_session.py @@ -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]