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
+96
View File
@@ -0,0 +1,96 @@
# Ansible `cisco.aci` examples
This directory contains a small, validated `cisco.aci` playbook set that
exercises aci-sim the same way you'd exercise a real APIC: create a
tenant/VRF/BD/AP/EPG, confirm idempotency (re-apply reports no change), then
tear it all down.
## Prerequisites
```bash
pip install ansible
ansible-galaxy collection install cisco.aci
```
The sim must be running first — see the main `README.md` §4 Usage/Quickstart.
## Files
| File | Purpose |
| --- | --- |
| `inventory.ini` | Example inventory — one host (`sim-apic-a`) pointed at the sim, with connection vars set as group vars. |
| `smoke.yml` | Creates `SMOKE-TN1` (tenant) → `SMOKE-VRF1` (VRF) → `SMOKE-BD1` (BD) → `SMOKE-AP1` (AP) → `SMOKE-EPG1` (EPG), then re-applies every task and asserts `changed=false` on the second pass. |
| `teardown.yml` | Removes everything `smoke.yml` created, in reverse dependency order, then asserts that deleting an already-absent tenant is idempotent (`changed=false`). |
## Running against port mode (default)
Port mode is what `bash scripts/run.sh` starts: one shared bind address
(`127.0.0.1` by default), APIC site A on `:8443`.
```bash
# from the repo root, in one terminal:
bash scripts/run.sh
# in another terminal:
cd examples/ansible
ansible-playbook -i inventory.ini smoke.yml
ansible-playbook -i inventory.ini teardown.yml
```
`inventory.ini` already points `sim-apic-a` at `127.0.0.1:8443` with
`admin`/`cisco` and `validate_certs=false` (the sim's TLS cert is
self-signed — see main `README.md` §5 Configuration). If you changed
`APIC_A_PORT` or `SIM_USERNAME`/`SIM_PASSWORD`, override the matching
`aci_port`/`aci_username`/`aci_password` host/group vars.
## Running against sandbox mode
Sandbox mode (`sudo bash scripts/sandbox-up.sh`) gives each APIC a real IP
on the standard port `443` — no port number, like real gear. Point the
inventory at that IP instead and drop the port override (or set it to 443
explicitly):
```bash
sudo bash scripts/sandbox-up.sh # from the repo root
```
Edit `inventory.ini` (or pass `-e` overrides on the command line) so
`ansible_host`/`aci_hostname` is the site's `mgmt_ip` from `topology.yaml`
(e.g. `10.192.0.11` for LAB1's default topology) and `aci_port` is `443`:
```bash
cd examples/ansible
ansible-playbook -i inventory.ini smoke.yml \
-e aci_hostname=10.192.0.11 -e aci_port=443
ansible-playbook -i inventory.ini teardown.yml \
-e aci_hostname=10.192.0.11 -e aci_port=443
```
Tear the sandbox down when done:
```bash
sudo bash scripts/sandbox-down.sh
```
## What "idempotent" means here
`cisco.aci` modules are idempotent at the **client** level: they GET the
existing object, diff it against the desired state, and only issue a
POST/DELETE if something actually differs. `smoke.yml`'s second pass and
`teardown.yml`'s re-delete both assert `changed: false`, proving the round
trip (create → GET-existing → diff → no-op) behaves the same against
aci-sim as it would against a real APIC — this is exactly the
GET-before-write contract documented in `docs/CONTRACT.md` §11
(`api_call()` treats any non-200 GET as fatal, so a not-yet-created
object's GET must be `200 {"imdata":[],"totalCount":"0"}`, never 404).
## Troubleshooting
- **Connection refused** — the sim isn't running, or you're pointed at the
wrong host/port for the run mode you started (port mode vs. sandbox mode).
- **`fatal: APIC Error 103: Unable to find the object specified`** on the
very first task — this was a real bug class this sim's PR-9 fixed (GET on
a nonexistent DN used to 404); if you see it, you're likely running an
older checkout — update to the latest `main`.
- **SSL errors** — make sure `validate_certs: false` (the sim's certificate
is self-signed; see `scripts/gen_certs.sh`).
+21
View File
@@ -0,0 +1,21 @@
; Example inventory for running the cisco.aci playbooks in this directory
; against aci-sim.
;
; Port mode (default `bash scripts/run.sh`):
; host = 127.0.0.1
; port = 8443 (APIC site A) — see README.md §Configuration for
; APIC_A_PORT/APIC_B_PORT if you changed the defaults.
;
; Sandbox mode (`sudo bash scripts/sandbox-up.sh`):
; host = the site's mgmt_ip from topology.yaml (e.g. 10.192.0.11)
; port = 443 (the default; cisco.aci's aci_port omitted or set to 443)
[aci_sim]
sim-apic-a ansible_host=127.0.0.1 aci_port=8443
[aci_sim:vars]
aci_hostname={{ ansible_host }}
aci_username=admin
aci_password=cisco
aci_validate_certs=false
aci_use_ssl=true
+145
View File
@@ -0,0 +1,145 @@
---
# smoke.yml — create tenant/VRF/BD/AP/EPG against aci-sim and verify
# idempotency (run twice: second run must report changed=false for every task).
#
# ansible-playbook -i inventory.ini smoke.yml
#
# Uses the cisco.aci collection (https://github.com/CiscoDevNet/ansible-aci).
# Install it first if needed:
# ansible-galaxy collection install cisco.aci
#
- name: aci-sim smoke test — tenant/VRF/BD/AP/EPG create + idempotency
hosts: aci_sim
connection: local
gather_facts: false
vars:
aci_connection: &aci_connection
host: "{{ aci_hostname }}"
port: "{{ aci_port | default(8443) }}"
username: "{{ aci_username | default('admin') }}"
password: "{{ aci_password | default('cisco') }}"
validate_certs: "{{ aci_validate_certs | default(false) }}"
use_ssl: "{{ aci_use_ssl | default(true) }}"
smoke_tenant: SMOKE-TN1
smoke_vrf: SMOKE-VRF1
smoke_bd: SMOKE-BD1
smoke_ap: SMOKE-AP1
smoke_epg: SMOKE-EPG1
tasks:
- name: Ensure tenant exists
cisco.aci.aci_tenant:
<<: *aci_connection
tenant: "{{ smoke_tenant }}"
description: Created by aci-sim examples/ansible/smoke.yml
state: present
register: tenant_result
- name: Ensure VRF exists
cisco.aci.aci_vrf:
<<: *aci_connection
tenant: "{{ smoke_tenant }}"
vrf: "{{ smoke_vrf }}"
state: present
register: vrf_result
- name: Ensure bridge domain exists
cisco.aci.aci_bd:
<<: *aci_connection
tenant: "{{ smoke_tenant }}"
bd: "{{ smoke_bd }}"
vrf: "{{ smoke_vrf }}"
state: present
register: bd_result
- name: Ensure application profile exists
cisco.aci.aci_ap:
<<: *aci_connection
tenant: "{{ smoke_tenant }}"
ap: "{{ smoke_ap }}"
state: present
register: ap_result
- name: Ensure EPG exists
cisco.aci.aci_epg:
<<: *aci_connection
tenant: "{{ smoke_tenant }}"
ap: "{{ smoke_ap }}"
epg: "{{ smoke_epg }}"
bd: "{{ smoke_bd }}"
state: present
register: epg_result
- name: Report first-run results (expect changed=true for a fresh sim)
ansible.builtin.debug:
msg: >-
tenant changed={{ tenant_result.changed }},
vrf changed={{ vrf_result.changed }},
bd changed={{ bd_result.changed }},
ap changed={{ ap_result.changed }},
epg changed={{ epg_result.changed }}
# --- Idempotency re-check -------------------------------------------------
# Re-running every task above against the same state must report
# changed=false. This block re-issues each module and fails the play if
# any of them still reports a change, catching non-idempotent behavior.
- name: Re-apply tenant (idempotency check)
cisco.aci.aci_tenant:
<<: *aci_connection
tenant: "{{ smoke_tenant }}"
description: Created by aci-sim examples/ansible/smoke.yml
state: present
register: tenant_recheck
- name: Re-apply VRF (idempotency check)
cisco.aci.aci_vrf:
<<: *aci_connection
tenant: "{{ smoke_tenant }}"
vrf: "{{ smoke_vrf }}"
state: present
register: vrf_recheck
- name: Re-apply bridge domain (idempotency check)
cisco.aci.aci_bd:
<<: *aci_connection
tenant: "{{ smoke_tenant }}"
bd: "{{ smoke_bd }}"
vrf: "{{ smoke_vrf }}"
state: present
register: bd_recheck
- name: Re-apply application profile (idempotency check)
cisco.aci.aci_ap:
<<: *aci_connection
tenant: "{{ smoke_tenant }}"
ap: "{{ smoke_ap }}"
state: present
register: ap_recheck
- name: Re-apply EPG (idempotency check)
cisco.aci.aci_epg:
<<: *aci_connection
tenant: "{{ smoke_tenant }}"
ap: "{{ smoke_ap }}"
epg: "{{ smoke_epg }}"
bd: "{{ smoke_bd }}"
state: present
register: epg_recheck
- name: Assert idempotency (all re-applies must be changed=false)
ansible.builtin.assert:
that:
- not tenant_recheck.changed
- not vrf_recheck.changed
- not bd_recheck.changed
- not ap_recheck.changed
- not epg_recheck.changed
fail_msg: >-
Idempotency check failed — at least one module reported a change on
a re-apply against unchanged state. This usually means the sim (or
the playbook's parameters) disagree between GET-existing and the
proposed diff.
success_msg: Idempotency check passed — no module reported a change on re-apply.
+83
View File
@@ -0,0 +1,83 @@
---
# teardown.yml — remove everything smoke.yml created (state=absent), in
# reverse dependency order. Deleting the tenant cascades its whole subtree
# in aci-sim (see docs/CONTRACT.md §11, MITStore.delete), so the
# later tasks are mostly redundant once the tenant is gone — they're kept
# here for clarity and so this playbook also works if you only want to
# tear down a single child object.
#
# ansible-playbook -i inventory.ini teardown.yml
#
- name: aci-sim teardown — remove smoke.yml objects
hosts: aci_sim
connection: local
gather_facts: false
vars:
aci_connection: &aci_connection
host: "{{ aci_hostname }}"
port: "{{ aci_port | default(8443) }}"
username: "{{ aci_username | default('admin') }}"
password: "{{ aci_password | default('cisco') }}"
validate_certs: "{{ aci_validate_certs | default(false) }}"
use_ssl: "{{ aci_use_ssl | default(true) }}"
smoke_tenant: SMOKE-TN1
smoke_vrf: SMOKE-VRF1
smoke_bd: SMOKE-BD1
smoke_ap: SMOKE-AP1
smoke_epg: SMOKE-EPG1
tasks:
- name: Remove EPG
cisco.aci.aci_epg:
<<: *aci_connection
tenant: "{{ smoke_tenant }}"
ap: "{{ smoke_ap }}"
epg: "{{ smoke_epg }}"
bd: "{{ smoke_bd }}"
state: absent
- name: Remove application profile
cisco.aci.aci_ap:
<<: *aci_connection
tenant: "{{ smoke_tenant }}"
ap: "{{ smoke_ap }}"
state: absent
- name: Remove bridge domain
cisco.aci.aci_bd:
<<: *aci_connection
tenant: "{{ smoke_tenant }}"
bd: "{{ smoke_bd }}"
vrf: "{{ smoke_vrf }}"
state: absent
- name: Remove VRF
cisco.aci.aci_vrf:
<<: *aci_connection
tenant: "{{ smoke_tenant }}"
vrf: "{{ smoke_vrf }}"
state: absent
- name: Remove tenant (cascades any remaining children)
cisco.aci.aci_tenant:
<<: *aci_connection
tenant: "{{ smoke_tenant }}"
state: absent
- name: Re-run tenant delete (idempotency check — must be changed=false)
cisco.aci.aci_tenant:
<<: *aci_connection
tenant: "{{ smoke_tenant }}"
state: absent
register: tenant_redelete
- name: Assert delete idempotency
ansible.builtin.assert:
that:
- not tenant_redelete.changed
fail_msg: >-
Deleting an already-absent tenant reported changed=true — DELETE
on a missing DN should be idempotent (see docs/CONTRACT.md §11).
success_msg: Delete idempotency check passed.