mirror of
https://github.com/dtzp555-max/aci-sim.git
synced 2026-07-19 09:46:33 +00:00
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:
@@ -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`).
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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())
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user