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
+52
View File
@@ -0,0 +1,52 @@
# CI gate example — fail a PR before it reaches real hardware
Pattern: run your network automation (Ansible / Terraform / Python / aci-py)
against a running `aci-sim`, then **assert the MIT reached the expected
end state**. A failed assertion fails the build. No hardware, deterministic,
resettable.
## Files
- **`assert_mit.py`** — the gate. Logs into the sim (or a real APIC) and checks
that the objects your change was supposed to create/modify are present.
Exit code = number of failed checks, so CI can gate on it.
- **`github-actions-aci-gate.yml`** — a copy-pasteable GitHub Actions workflow
wiring the three steps (start sim → run automation → assert).
## `assert_mit.py` check syntax
Each `--expect` (repeatable):
| Check | Meaning |
|---|---|
| `class:<cls>[>=N]` | the class query returns ≥ N objects (default N=1) |
| `mo:<dn>` | `GET /api/mo/<dn>.json` returns the object (exists) |
| `absent:<dn>` | the MO does NOT exist (e.g. after a `state=absent` / delete) |
| `attr:<dn>:<key>=<val>` | the MO at `<dn>` has attribute `<key>` == `<val>` |
## Try it locally
```bash
# 1. start the sim (ships MS-TN1 / SF-TN1-* tenants from topology.yaml)
aci-sim run &
# 2. assert the seeded end state
python examples/ci/assert_mit.py --host 127.0.0.1:8443 \
--expect "class:fvTenant>=1" \
--expect "mo:uni/tn-MS-TN1" \
--expect "attr:uni/tn-MS-TN1:name=MS-TN1" \
--expect "absent:uni/tn-DoesNotExist"
# → 4/4 checks passed, exit 0
```
Because `assert_mit.py` speaks the plain APIC REST API, you can point it at a
**real APIC** too (`--host <apic-ip> --user ... --password ...`) to diff
sim-vs-real, or to validate the same expectations against staging gear.
## Real-world shape
In practice step 2 is your existing playbook (see `../ansible/smoke.yml` for a
full cisco.aci create→idempotency example), and step 3's `--expect` list is the
objects that playbook is contracted to produce. When someone edits the playbook
in a way that stops creating those objects, the PR goes red — before anything
touches production.
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""assert_mit.py — a tiny CI gate: assert the simulator's MIT reached an
expected end state after your automation ran.
This is the "gate" half of the CI pattern: run your playbook / Terraform /
Python against a running aci-sim, then run this to fail the build if
the objects your change was supposed to create/modify are not present.
It talks the plain APIC REST API (aaaLogin + class/mo queries), so it works
against a real APIC too — point it at real gear to diff sim-vs-real.
Usage
-----
python assert_mit.py --host 127.0.0.1:8443 --user admin --password cisco \
--expect "class:fvTenant>=1" \
--expect "mo:uni/tn-MS-TN1" \
--expect "attr:uni/tn-MS-TN1:name=MS-TN1"
Check syntax (each --expect, repeatable):
class:<cls>[>=N] the class query returns at least N objects (default N=1)
mo:<dn> GET /api/mo/<dn>.json returns the object (exists)
absent:<dn> GET /api/mo/<dn>.json returns empty (does NOT exist)
attr:<dn>:<key>=<val> the MO at <dn> has attribute <key> equal to <val>
Exit code = number of failed checks (0 = all passed), so CI can gate on it.
"""
from __future__ import annotations
import argparse
import sys
import httpx
def _login(client: httpx.Client, user: str, password: str) -> None:
r = client.post(
"/api/aaaLogin.json",
json={"aaaUser": {"attributes": {"name": user, "pwd": password}}},
)
if r.status_code != 200:
print(f" FATAL aaaLogin failed: HTTP {r.status_code} {r.text[:120]}")
sys.exit(99)
tok = r.json()["imdata"][0]["aaaLogin"]["attributes"]["token"]
client.cookies.set("APIC-cookie", tok)
def _mo(client: httpx.Client, dn: str) -> dict | None:
r = client.get(f"/api/mo/{dn}.json")
if r.status_code == 200 and int(r.json().get("totalCount", 0)) > 0:
return list(r.json()["imdata"][0].values())[0]["attributes"]
return None
def _check(client: httpx.Client, expect: str) -> bool:
"""Run one --expect check; return True on pass, print the result."""
if expect.startswith("class:"):
body = expect[len("class:"):]
minimum = 1
cls = body
if ">=" in body:
cls, n = body.split(">=", 1)
minimum = int(n)
r = client.get(f"/api/class/{cls}.json")
total = int(r.json().get("totalCount", 0)) if r.status_code == 200 else -1
ok = total >= minimum
print(f" {'PASS' if ok else 'FAIL'} class {cls} count={total} (need >={minimum})")
return ok
if expect.startswith("mo:"):
dn = expect[len("mo:"):]
attrs = _mo(client, dn)
ok = attrs is not None
print(f" {'PASS' if ok else 'FAIL'} mo {dn} {'exists' if ok else 'MISSING'}")
return ok
if expect.startswith("absent:"):
dn = expect[len("absent:"):]
attrs = _mo(client, dn)
ok = attrs is None
print(f" {'PASS' if ok else 'FAIL'} absent {dn} {'gone' if ok else 'STILL PRESENT'}")
return ok
if expect.startswith("attr:"):
rest = expect[len("attr:"):]
dn, kv = rest.rsplit(":", 1)
key, val = kv.split("=", 1)
attrs = _mo(client, dn) or {}
got = attrs.get(key)
ok = got == val
print(f" {'PASS' if ok else 'FAIL'} attr {dn} {key}={got!r} (want {val!r})")
return ok
print(f" FAIL unrecognized check: {expect!r}")
return False
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(description="Assert the aci-sim MIT end state (CI gate).")
p.add_argument("--host", required=True, help="APIC host:port, e.g. 127.0.0.1:8443")
p.add_argument("--user", default="admin")
p.add_argument("--password", default="cisco")
p.add_argument("--scheme", default="https", choices=["https", "http"])
p.add_argument("--expect", action="append", default=[], help="a check (repeatable) — see module docstring")
a = p.parse_args(argv)
if not a.expect:
p.error("at least one --expect is required")
base = f"{a.scheme}://{a.host}"
with httpx.Client(base_url=base, verify=False, timeout=30.0) as client:
_login(client, a.user, a.password)
failed = sum(0 if _check(client, e) else 1 for e in a.expect)
print(f"\n=== {len(a.expect) - failed}/{len(a.expect)} checks passed ===")
return failed
if __name__ == "__main__":
raise SystemExit(main())
+65
View File
@@ -0,0 +1,65 @@
# Example GitHub Actions workflow: gate a network-automation change against
# aci-sim before it can reach real hardware.
#
# The pattern is three steps:
# 1. Start the simulator (in the background, port mode).
# 2. Run YOUR automation against it (Ansible / Terraform / Python / aci-py).
# 3. Assert the MIT reached the expected end state (examples/ci/assert_mit.py).
# A non-zero exit fails the build.
#
# Copy this into .github/workflows/ in the repo that holds your automation,
# adjust the "Run your automation" and "Assert" steps to your playbook + the
# objects it is supposed to create, and you have a hardware-free CI gate.
name: ACI automation gate
on:
pull_request:
push:
branches: [main]
jobs:
aci-gate:
runs-on: ubuntu-latest
steps:
- name: Check out your automation repo
uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
# ---- 1. Bring up the simulator -------------------------------------
- name: Install and start aci-sim
run: |
pip install "aci-sim @ git+https://github.com/dtzp555-max/aci-sim@v0.8.0"
# Or: git clone the repo and `pip install -e .`
# Port mode: APIC LAB1 on :8443, LAB2 :8444, NDO :8445 (127.0.0.1).
aci-sim run & # backgrounds the supervisor
# wait for the APIC REST endpoint to answer
for i in $(seq 1 30); do
curl -sk https://127.0.0.1:8443/api/class/fvTenant.json >/dev/null && break
sleep 1
done
# ---- 2. Run YOUR automation against the sim ------------------------
# Replace this with your real playbook / terraform / script.
# It targets the sim exactly like a real APIC (admin/cisco by default).
- name: "Run automation (example: cisco.aci playbook)"
run: |
pip install ansible-core
ansible-galaxy collection install cisco.aci
ansible-playbook -i inventory.ini create_tenant.yml \
-e apic_host=127.0.0.1:8443 -e apic_username=admin -e apic_password=cisco \
-e apic_validate_certs=false
# ---- 3. Gate: assert the MIT end state -----------------------------
- name: Assert MIT end state
run: |
# assert_mit.py ships in the aci-sim repo under examples/ci/.
# Vendor it, or curl it, or add the sim repo as a submodule.
python assert_mit.py --host 127.0.0.1:8443 \
--expect "class:fvTenant>=1" \
--expect "mo:uni/tn-YOUR-TENANT" \
--expect "attr:uni/tn-YOUR-TENANT:name=YOUR-TENANT"
# non-zero exit here fails the PR — your change did not produce the
# objects it was supposed to. No hardware was touched.