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>
This commit is contained in:
2026-07-05 20:06:36 +10:00
co-authored by Claude Fable 5
commit 7e9a175ce6
117 changed files with 31769 additions and 0 deletions
+186
View File
@@ -0,0 +1,186 @@
"""Mutation-killers for filters.py numeric-comparison semantics (finding #21).
A prior audit replaced the numeric branch of gt/ge/lt/le/bw with pure-lexical
string comparison and the full suite stayed green — the numeric-vs-lexical
fork in ``_cmp_pred`` / ``_bw_pred`` was effectively untested. Every case
below is chosen so lexical and numeric ordering DISAGREE: if the numeric
branch were deleted (or the ``rn is not None and vn is not None`` guard were
forced to always fail), these assertions flip and the test fails.
Covers both the parse_filter unit level (this file) and the HTTP
class-query path (tests/test_rest_aci.py::TestNumericSemanticsHttp-style
cases live alongside the existing REST fault/filter tests — see the
`test_bw_wide_numeric_range_matches_non_controller_nodes` /
`test_gt_numeric_filter_matches_all_non_controller_nodes` companions in
tests/test_rest_aci.py, exercised here again via the FastAPI TestClient
for the same fixture used by fabricNode ids 101-104).
"""
from __future__ import annotations
import copy
import pytest
from fastapi.testclient import TestClient
from aci_sim.build.orchestrator import build_site
from aci_sim.mit.mo import MO
from aci_sim.mit.store import MITStore
from aci_sim.query.filters import parse_filter
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
def _make_store() -> MITStore:
"""Fabric nodes with three-digit ids 101-104 — same fixture shape as
tests/test_query.py::_make_store, so lexical "150" < "99" and
"101" < "99" traps are exercised identically."""
s = MITStore()
for node_id, role in [("101", "spine"), ("102", "spine"), ("103", "leaf"), ("104", "leaf")]:
s.add(MO(
"fabricNode",
dn=f"topology/pod-1/node-{node_id}",
id=node_id,
role=role,
name=f"node-{node_id}",
))
return s
# ---------------------------------------------------------------------------
# parse_filter unit level
# ---------------------------------------------------------------------------
class TestNumericSemanticsUnit:
def setup_method(self):
self.s = _make_store()
def _ids(self, expr: str) -> set[str]:
pred = parse_filter(expr)
return {mo.attrs["id"] for mo in self.s.by_class("fabricNode") if pred(mo)}
# -- bw: lexically "150" < "99" empties the range; numerically it must
# cover every 3-digit node id 101-104. --
def test_bw_lexically_inverted_bounds_matches_all_nodes(self):
assert self._ids('bw(fabricNode.id,"99","150")') == {"101", "102", "103", "104"}
def test_bw_lexically_inverted_bounds_excludes_below_low(self):
# Numeric lower bound 99 excludes nothing here (all ids >= 101), but
# pin a case where the lexical trap would ALSO wrongly exclude if the
# numeric branch were dropped: bw with a tight numeric window that a
# lexical comparison would evaluate completely differently.
assert self._ids('bw(fabricNode.id,"101","102")') == {"101", "102"}
# -- gt: "99" is lexically greater than "101".."104" (since "9" > "1"),
# so a lexical-only gt(id,"99") would match NOTHING; numerically it must
# match every node. --
def test_gt_low_numeric_bound_matches_all_nodes(self):
assert self._ids('gt(fabricNode.id,"99")') == {"101", "102", "103", "104"}
def test_gt_three_digit_ids_numeric(self):
# "103" > "102" holds both lexically and numerically for same-width
# strings, so pair it with the mixed-width case below to force the
# numeric branch specifically.
assert self._ids('gt(fabricNode.id,"102")') == {"103", "104"}
# -- ge: mirrors gt with mixed-width low bound. --
def test_ge_low_numeric_bound_matches_all_nodes(self):
assert self._ids('ge(fabricNode.id,"99")') == {"101", "102", "103", "104"}
# -- lt / le: "9" is lexically greater than "101".."104", so a
# lexical-only lt(id,"9") would match every node (each id < "9" lexically
# is False since "1..." > "9" is False... actually "101" < "9" is True
# lexically because '1' < '9'). Use a case where lexical and numeric
# DISAGREE in direction: lt(id, "20") — lexically "101" < "20" is True
# (since '1' < '2'), but numerically 101 < 20 is False. --
def test_lt_mixed_width_bound_matches_nothing_numerically(self):
# Lexical comparison would wrongly match all nodes ("101" < "20",
# "102" < "20", ... all True as strings); numeric comparison must
# correctly match none (101-104 are not < 20).
assert self._ids('lt(fabricNode.id,"20")') == set()
def test_le_mixed_width_bound_matches_nothing_numerically(self):
assert self._ids('le(fabricNode.id,"20")') == set()
def test_lt_numeric_within_range(self):
assert self._ids('lt(fabricNode.id,"103")') == {"101", "102"}
def test_le_numeric_within_range(self):
assert self._ids('le(fabricNode.id,"102")') == {"101", "102"}
# ---------------------------------------------------------------------------
# HTTP class-query path
# ---------------------------------------------------------------------------
@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)
login = c.post(
"/api/aaaLogin.json",
json={"aaaUser": {"attributes": {"name": "admin", "pwd": "cisco"}}},
)
assert login.status_code == 200
return c
class TestNumericSemanticsHttp:
def test_bw_lexically_inverted_bounds_matches_all_non_controller_nodes(self, client):
# "150" < "99" lexically would empty the range entirely; numerically
# 99 <= id <= 150 must match all four LAB1 leaf/border-leaf nodes.
resp = client.get(
'/api/class/fabricNode.json?query-target-filter=bw(fabricNode.id,"99","150")'
)
assert resp.status_code == 200
data = resp.json()
assert int(data["totalCount"]) == 4
ids = {i["fabricNode"]["attributes"]["id"] for i in data["imdata"]}
assert ids == {"101", "102", "103", "104"}
def test_gt_low_numeric_bound_matches_all_non_controller_nodes(self, client):
# Lexically "99" > "101".."104" (since '9' > '1'), so a lexical-only
# gt would match nothing; numerically every 3-digit node id passes.
resp = client.get(
'/api/class/fabricNode.json?query-target-filter=gt(fabricNode.id,"99")'
)
assert resp.status_code == 200
data = resp.json()
assert int(data["totalCount"]) >= 4
roles = [i["fabricNode"]["attributes"]["role"] for i in data["imdata"]]
assert "controller" not in roles
def test_lt_mixed_width_bound_excludes_leaves_and_spines(self, client):
# Lexically "101".."104"/"201"/"202" are all < "50" (since '1'/'2' <
# '5'), so a lexical-only lt would wrongly match every leaf/spine;
# numerically none of 101-104/201/202 is < 50, so only the
# single-digit controllers (id 1-3) may pass.
resp = client.get(
'/api/class/fabricNode.json?query-target-filter=lt(fabricNode.id,"50")'
)
assert resp.status_code == 200
data = resp.json()
roles = {i["fabricNode"]["attributes"]["role"] for i in data["imdata"]}
ids = {i["fabricNode"]["attributes"]["id"] for i in data["imdata"]}
assert roles <= {"controller"}
assert "leaf" not in roles
assert "spine" not in roles
assert ids <= {"1", "2", "3"}