mirror of
https://github.com/dtzp555-max/aci-sim.git
synced 2026-07-19 09:46:33 +00:00
feat: aci-sim query helper + onboarding fixes (v0.17.0)
Clean-room README test found a newcomer hits failures in the first 5 min — all doc/script, not sim bugs. Fixes: - scripts/run.sh: use ./.venv/bin/python (was bare `python` → ModuleNotFoundError: uvicorn unless the venv was activated; now matches sandbox-up.sh). - NEW `aci-sim query CLASS [--dn/--host/--json]`: stdlib aaaLogin→cookie→query helper so newcomers don't hand-roll auth cookies (the old README smoke test `curl class/fabricNode.json` had no auth → returned an APIC error envelope). - README: 60-second quickstart + embedded topology screenshot (docs/images/ topology.png), port-mode leads with `aci-sim run`, smoke test uses `aci-sim query`, install notes git+https is package-only. 900 tests pass (+12 for aci-sim query). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,38 @@ 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)
|
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).
|
(pre-1.0: minor bumps may include breaking changes to the sim's behavior).
|
||||||
|
|
||||||
|
## [0.17.0] - 2026-07-05
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **`aci-sim query CLASS`** — a zero-friction, stdlib-only authenticated MIT
|
||||||
|
query helper (`aci_sim/cli.py`'s `cmd_query`), closing a clean-room
|
||||||
|
onboarding gap: the README's smoke test (`curl .../class/fabricNode.json`)
|
||||||
|
returned an APIC 403 error envelope, not the fabric, because — like real
|
||||||
|
APIC — the sim requires `aaaLogin` before any query. `aci-sim query`
|
||||||
|
does the `aaaLogin` -> `APIC-cookie` -> `GET /api/class|mo/...` dance for
|
||||||
|
you (via `urllib.request`/`ssl`/`http.cookies`, no new dependency — httpx
|
||||||
|
stays dev-only) and prints a compact table (`--json` for the raw
|
||||||
|
envelope). Supports `--dn DN` for a single-MO query, `--host`/`--user`/
|
||||||
|
`--password` overrides, and a friendly hint ("is the sim running? start
|
||||||
|
it with `aci-sim run`") on a connection error instead of a traceback.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **`scripts/run.sh` failed with `ModuleNotFoundError: uvicorn` outside an
|
||||||
|
activated venv.** It invoked bare `python` (whatever that resolves to on
|
||||||
|
`$PATH`), unlike `scripts/sandbox-up.sh` which already resolves
|
||||||
|
`./.venv/bin/python`. Now `scripts/run.sh` uses the same
|
||||||
|
`PY="./.venv/bin/python"` (falling back to `python3`) pattern, so
|
||||||
|
`bash scripts/run.sh` works from a fresh, non-activated shell right after
|
||||||
|
`pip install -e '.[dev]'` — no `source .venv/bin/activate` required.
|
||||||
|
- **README quickstart + smoke test now copy-paste-correct.** Added a
|
||||||
|
60-second quickstart (`pip install` -> `aci-sim run` -> `aci-sim query
|
||||||
|
fabricNode`) verified end-to-end in a clean-room test; the old smoke test
|
||||||
|
(`curl` with no login) is replaced with `aci-sim query fabricNode` (or the
|
||||||
|
full authenticated curl, shown as an alternative). Also embeds a rendered
|
||||||
|
`aci-sim graph` topology screenshot (`docs/images/topology.png`) near the
|
||||||
|
top and in the `aci-sim graph` CLI section, and documents `aci-sim query`
|
||||||
|
in §5 with a live sample transcript.
|
||||||
|
|
||||||
## [0.16.0] - 2026-07-05
|
## [0.16.0] - 2026-07-05
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ per device on `:443`, needed for NDO auto-discovery clients that hardcode
|
|||||||
port 443). Primary public use case: running Ansible `cisco.aci` playbooks
|
port 443). Primary public use case: running Ansible `cisco.aci` playbooks
|
||||||
against it directly — see `examples/ansible/`.
|
against it directly — see `examples/ansible/`.
|
||||||
|
|
||||||
Test suite: `.venv/bin/python -m pytest -q` (888 tests, must stay green).
|
Test suite: `.venv/bin/python -m pytest -q` (900 tests, must stay green).
|
||||||
Lint: `ruff check .` (non-blocking in CI — pre-existing baseline, see
|
Lint: `ruff check .` (non-blocking in CI — pre-existing baseline, see
|
||||||
`.github/workflows/ci.yml`).
|
`.github/workflows/ci.yml`).
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@ pip install -e '.[dev]' # installs aci_sim + fastapi/uvicorn/pydantic/p
|
|||||||
.venv/bin/python -m pytest -q
|
.venv/bin/python -m pytest -q
|
||||||
```
|
```
|
||||||
|
|
||||||
The suite currently has **888 tests** and must stay green. If you add a
|
The suite currently has **900 tests** and must stay green. If you add a
|
||||||
feature or fix a bug, add or extend tests under `tests/` in the same PR —
|
feature or fix a bug, add or extend tests under `tests/` in the same PR —
|
||||||
most existing tests are organized by PR/feature (`tests/test_pr*.py`,
|
most existing tests are organized by PR/feature (`tests/test_pr*.py`,
|
||||||
`tests/test_<feature>.py`); follow that convention for new ones.
|
`tests/test_<feature>.py`); follow that convention for new ones.
|
||||||
|
|||||||
@@ -20,6 +20,25 @@ entire fabric — nodes, tenants, VRFs/BDs/EPGs, contracts, L3Outs, faults,
|
|||||||
endpoints, and the whole NDO schema tree — is generated deterministically
|
endpoints, and the whole NDO schema tree — is generated deterministically
|
||||||
from a single `topology.yaml`.
|
from a single `topology.yaml`.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
*A 2-site, 15-node fabric rendered by `aci-sim graph` — spines (blue),
|
||||||
|
leaves/border-leaves (green), controllers (amber), and the inter-site ISN
|
||||||
|
cloud (slate). See §5's `aci-sim graph` for how to generate your own.*
|
||||||
|
|
||||||
|
### 60-second quickstart
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install git+https://github.com/dtzp555-max/aci-sim.git
|
||||||
|
aci-sim run &
|
||||||
|
aci-sim query fabricNode # shows the 7-node LAB1 fabric
|
||||||
|
```
|
||||||
|
|
||||||
|
That's it — no cert generation, no manual `aaaLogin`/cookie handling, no
|
||||||
|
clone required. See §4 for the full install/run options (including the
|
||||||
|
from-source/clone path needed for `scripts/`, `examples/`, and Ansible) and
|
||||||
|
§5 for the full `aci-sim` CLI reference.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Purpose
|
## 1. Purpose
|
||||||
@@ -225,16 +244,25 @@ pip install -e '.[dev]' # installs aci_sim + fastapi/uvicorn/pydantic/p
|
|||||||
`pip install aci-sim` will work once the project is published to PyPI (not
|
`pip install aci-sim` will work once the project is published to PyPI (not
|
||||||
yet published — use one of the two forms above until then).
|
yet published — use one of the two forms above until then).
|
||||||
|
|
||||||
|
> **Note:** `pip install git+https://...` installs the **package only** —
|
||||||
|
> `aci_sim` (the library + the `aci-sim` CLI). It does NOT check out
|
||||||
|
> `scripts/`, `examples/`, `deploy/`, or `topology.yaml` (those live in the
|
||||||
|
> git repo, not the installed package). That's fine for everyday use —
|
||||||
|
> `aci-sim run` / `aci-sim query` / `aci-sim new` / `aci-sim graph` work
|
||||||
|
> standalone and scaffold their own `topology.yaml` — but if you want
|
||||||
|
> `scripts/sandbox-up.sh`, the Ansible examples, or the systemd/launchd
|
||||||
|
> deploy sketches, use the "from source" clone below instead.
|
||||||
|
|
||||||
### Port mode (default)
|
### Port mode (default)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash scripts/run.sh
|
|
||||||
# or, equivalently, via the aci-sim CLI (see §5):
|
|
||||||
aci-sim run
|
aci-sim run
|
||||||
|
# or, equivalently, from a clone (see scripts/run.sh):
|
||||||
|
bash scripts/run.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Generates a self-signed cert on first run (`scripts/gen_certs.sh`) and
|
Generates a self-signed cert on first run (`scripts/gen_certs.sh`'s logic,
|
||||||
starts:
|
either via `aci-sim run` or `scripts/run.sh`) and starts:
|
||||||
|
|
||||||
```
|
```
|
||||||
[sim] APIC LAB1 (asn 65001) → https://127.0.0.1:8443
|
[sim] APIC LAB1 (asn 65001) → https://127.0.0.1:8443
|
||||||
@@ -242,10 +270,33 @@ starts:
|
|||||||
[sim] NDO → https://127.0.0.1:8445
|
[sim] NDO → https://127.0.0.1:8445
|
||||||
```
|
```
|
||||||
|
|
||||||
Smoke test:
|
Smoke test — like real APIC, every query requires `aaaLogin` first (a bare
|
||||||
|
`curl .../class/fabricNode.json` returns a 403 APIC error envelope, not the
|
||||||
|
fabric); `aci-sim query` does the login+cookie dance for you:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -sk https://127.0.0.1:8443/api/class/fabricNode.json | python -m json.tool
|
aci-sim query fabricNode
|
||||||
|
```
|
||||||
|
|
||||||
|
```console
|
||||||
|
CLASS NAME/KEY EXTRA DN
|
||||||
|
fabricNode LAB1-ACI-SP01 spine topology/pod-1/node-201
|
||||||
|
fabricNode LAB1-ACI-SP02 spine topology/pod-1/node-202
|
||||||
|
fabricNode LAB1-ACI-LF101 leaf topology/pod-1/node-101
|
||||||
|
fabricNode LAB1-ACI-LF102 leaf topology/pod-1/node-102
|
||||||
|
fabricNode LAB1-ACI-LF103 leaf topology/pod-1/node-103
|
||||||
|
fabricNode LAB1-ACI-LF104 leaf topology/pod-1/node-104
|
||||||
|
fabricNode LAB1-ACI-APIC01 controller topology/pod-1/node-1
|
||||||
|
```
|
||||||
|
|
||||||
|
Equivalently, the full authenticated curl (if you don't have `aci-sim` on
|
||||||
|
`PATH`, e.g. scripting against a remote sim):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TOKEN=$(curl -sk https://127.0.0.1:8443/api/aaaLogin.json \
|
||||||
|
-X POST -d '{"aaaUser":{"attributes":{"name":"admin","pwd":"cisco"}}}' \
|
||||||
|
| python3 -c 'import json,sys; print(json.load(sys.stdin)["imdata"][0]["aaaLogin"]["attributes"]["token"])')
|
||||||
|
curl -sk https://127.0.0.1:8443/api/class/fabricNode.json -H "Cookie: APIC-cookie=$TOKEN" | python3 -m json.tool
|
||||||
```
|
```
|
||||||
|
|
||||||
### Sandbox mode (real per-device IPs on :443)
|
### Sandbox mode (real per-device IPs on :443)
|
||||||
@@ -417,10 +468,76 @@ $ aci-sim graph lab3.topology.yaml -o lab3.svg
|
|||||||
[aci-sim] wrote topology diagram: lab3.svg
|
[aci-sim] wrote topology diagram: lab3.svg
|
||||||
```
|
```
|
||||||
|
|
||||||
|
What the diagram looks like (this repo's default `topology.yaml`, exported to
|
||||||
|
PNG):
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
*2 sites, 15 nodes total — spines (blue), leaves/border-leaves (green),
|
||||||
|
controllers (amber), ISN cloud (slate).*
|
||||||
|
|
||||||
Flags: `TOPOLOGY` positional (default `./topology.yaml`), `-o/--output`
|
Flags: `TOPOLOGY` positional (default `./topology.yaml`), `-o/--output`
|
||||||
(default `topology.html`; `.html`/`.htm` writes an HTML wrapper, `.svg`
|
(default `topology.html`; `.html`/`.htm` writes an HTML wrapper, `.svg`
|
||||||
writes raw SVG).
|
writes raw SVG).
|
||||||
|
|
||||||
|
### `aci-sim query [CLASS]`
|
||||||
|
|
||||||
|
A zero-friction, **authenticated** MIT query helper — the ACI/NDO REST API
|
||||||
|
requires `aaaLogin` before any query (§10's error-handling section, real-APIC
|
||||||
|
behavior), so a bare `curl .../class/fabricNode.json` returns a 403 APIC
|
||||||
|
error envelope instead of data. `aci-sim query` does the
|
||||||
|
`aaaLogin` -> `APIC-cookie` -> `GET /api/class|mo/...` dance for you, with no
|
||||||
|
new dependency (stdlib `urllib.request`/`ssl`/`http.cookies` only — `httpx`
|
||||||
|
stays a dev/test-only extra, see §3).
|
||||||
|
|
||||||
|
`CLASS` (e.g. `fabricNode`, `fvTenant`, `topSystem`) runs a class query;
|
||||||
|
`--dn DN` runs a single-MO query instead. Default output is a compact table
|
||||||
|
(one row per MO); `--json` prints the raw APIC envelope.
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ aci-sim query fabricNode
|
||||||
|
CLASS NAME/KEY EXTRA DN
|
||||||
|
fabricNode LAB1-ACI-SP01 spine topology/pod-1/node-201
|
||||||
|
fabricNode LAB1-ACI-SP02 spine topology/pod-1/node-202
|
||||||
|
fabricNode LAB1-ACI-LF101 leaf topology/pod-1/node-101
|
||||||
|
fabricNode LAB1-ACI-LF102 leaf topology/pod-1/node-102
|
||||||
|
fabricNode LAB1-ACI-LF103 leaf topology/pod-1/node-103
|
||||||
|
fabricNode LAB1-ACI-LF104 leaf topology/pod-1/node-104
|
||||||
|
fabricNode LAB1-ACI-APIC01 controller topology/pod-1/node-1
|
||||||
|
|
||||||
|
$ aci-sim query fvTenant
|
||||||
|
CLASS NAME/KEY EXTRA DN
|
||||||
|
fvTenant mgmt - uni/tn-mgmt
|
||||||
|
fvTenant MS-TN1 - uni/tn-MS-TN1
|
||||||
|
fvTenant SF-TN1-LAB1 - uni/tn-SF-TN1-LAB1
|
||||||
|
|
||||||
|
$ aci-sim query --dn uni/tn-mgmt --json
|
||||||
|
{
|
||||||
|
"imdata": [
|
||||||
|
{
|
||||||
|
"fvTenant": {
|
||||||
|
"attributes": {
|
||||||
|
"dn": "uni/tn-mgmt",
|
||||||
|
"name": "mgmt"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"totalCount": "1"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Flags: `CLASS` positional (omit if using `--dn`), `--dn DN` (mo-query a
|
||||||
|
single DN instead of a class query), `--host` (default `127.0.0.1:8443`,
|
||||||
|
port-mode LAB1), `--user`/`--password` (default `admin`/`cisco`), `--json`
|
||||||
|
(raw APIC envelope instead of the table). On a connection error (sim not
|
||||||
|
running), prints a friendly hint instead of a traceback:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ aci-sim query fabricNode
|
||||||
|
ERROR: could not connect to 127.0.0.1:8443 — is the sim running? start it with `aci-sim run`
|
||||||
|
```
|
||||||
|
|
||||||
### `aci-sim run [TOPOLOGY]`
|
### `aci-sim run [TOPOLOGY]`
|
||||||
|
|
||||||
A thin wrapper around `python -m aci_sim.runtime.supervisor` — it
|
A thin wrapper around `python -m aci_sim.runtime.supervisor` — it
|
||||||
|
|||||||
+222
@@ -30,6 +30,7 @@ import ipaddress
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import ssl
|
||||||
import sys
|
import sys
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -442,6 +443,215 @@ def cmd_lldp(args: argparse.Namespace) -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# query — zero-friction authenticated MIT query (aaaLogin -> cookie -> GET),
|
||||||
|
# so newcomers never have to hand-roll the login/cookie dance themselves.
|
||||||
|
# stdlib-only (urllib.request + ssl + http.cookies) — httpx is dev-only (see
|
||||||
|
# pyproject.toml's [project.optional-dependencies] dev extra), and `aci-sim
|
||||||
|
# query` must work from a bare `pip install aci-sim` with no dev extras.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
DEFAULT_QUERY_HOST = "127.0.0.1:8443"
|
||||||
|
DEFAULT_QUERY_USER = "admin"
|
||||||
|
DEFAULT_QUERY_PASSWORD = "cisco"
|
||||||
|
|
||||||
|
|
||||||
|
class QueryConnectionError(Exception):
|
||||||
|
"""Raised when the sim cannot be reached at all (connection refused/timeout)."""
|
||||||
|
|
||||||
|
|
||||||
|
def _unverified_ssl_context() -> ssl.SSLContext:
|
||||||
|
"""A permissive SSL context that accepts the sim's self-signed cert.
|
||||||
|
|
||||||
|
Mirrors `curl -k` / `httpx.Client(verify=False)` used elsewhere in this
|
||||||
|
repo (examples/ci/assert_mit.py, scripts/e2e_*.py) — the sim's cert is
|
||||||
|
self-signed by scripts/gen_certs.sh, so hostname/CA verification is
|
||||||
|
deliberately skipped for this local dev tool.
|
||||||
|
"""
|
||||||
|
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||||
|
ctx.check_hostname = False
|
||||||
|
ctx.verify_mode = ssl.CERT_NONE
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
def _aaa_login(host: str, user: str, password: str, timeout: float = 5.0) -> str:
|
||||||
|
"""POST /api/aaaLogin.json; return the APIC-cookie token string.
|
||||||
|
|
||||||
|
Raises QueryConnectionError on a connection-level failure (sim not
|
||||||
|
running), or ValueError if the sim answered but login failed (bad
|
||||||
|
credentials / malformed envelope).
|
||||||
|
"""
|
||||||
|
import json as _json
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
url = f"https://{host}/api/aaaLogin.json"
|
||||||
|
body = _json.dumps({"aaaUser": {"attributes": {"name": user, "pwd": password}}}).encode("utf-8")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url, data=body, method="POST", headers={"Content-Type": "application/json"}
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout, context=_unverified_ssl_context()) as resp:
|
||||||
|
resp_body = resp.read().decode("utf-8")
|
||||||
|
set_cookie = resp.headers.get_all("Set-Cookie") or []
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
resp_body = exc.read().decode("utf-8")
|
||||||
|
text = _error_text_from_envelope(resp_body) or f"HTTP {exc.code}"
|
||||||
|
raise ValueError(f"aaaLogin failed: {text}") from exc
|
||||||
|
except (urllib.error.URLError, OSError, TimeoutError) as exc:
|
||||||
|
raise QueryConnectionError(str(exc)) from exc
|
||||||
|
|
||||||
|
token = _extract_cookie(set_cookie, "APIC-cookie")
|
||||||
|
if token is not None:
|
||||||
|
return token
|
||||||
|
|
||||||
|
# Fall back to the token embedded in the aaaLogin response body itself
|
||||||
|
# (docs/CONTRACT.md: imdata[0].aaaLogin.attributes.token) in case
|
||||||
|
# Set-Cookie parsing ever misses it.
|
||||||
|
try:
|
||||||
|
data = _json.loads(resp_body)
|
||||||
|
return data["imdata"][0]["aaaLogin"]["attributes"]["token"]
|
||||||
|
except (KeyError, IndexError, ValueError) as exc:
|
||||||
|
raise ValueError(f"aaaLogin succeeded but no token found in response: {resp_body[:200]}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_cookie(set_cookie_headers: list[str], name: str) -> str | None:
|
||||||
|
"""Pull cookie *name*'s value out of one or more raw Set-Cookie header strings."""
|
||||||
|
from http.cookies import SimpleCookie
|
||||||
|
|
||||||
|
for header in set_cookie_headers:
|
||||||
|
jar: SimpleCookie = SimpleCookie()
|
||||||
|
jar.load(header)
|
||||||
|
if name in jar:
|
||||||
|
return jar[name].value
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _error_text_from_envelope_dict(data: Any) -> str | None:
|
||||||
|
"""If *data* (already-parsed) is an APIC error envelope, return its human-readable text."""
|
||||||
|
imdata = data.get("imdata") if isinstance(data, dict) else None
|
||||||
|
if not imdata or not isinstance(imdata, list):
|
||||||
|
return None
|
||||||
|
first = imdata[0]
|
||||||
|
if isinstance(first, dict) and "error" in first:
|
||||||
|
attrs = first["error"].get("attributes", {})
|
||||||
|
code = attrs.get("code", "")
|
||||||
|
text = attrs.get("text", "")
|
||||||
|
return f"{text} (code {code})" if code else text
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _error_text_from_envelope(raw_body: str) -> str | None:
|
||||||
|
"""If *raw_body* is an APIC error envelope, return its human-readable text."""
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = _json.loads(raw_body)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return _error_text_from_envelope_dict(data)
|
||||||
|
|
||||||
|
|
||||||
|
def _query_mit(
|
||||||
|
host: str, token: str, *, cls: str | None, dn: str | None, timeout: float = 5.0
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""GET /api/class/{cls}.json or /api/mo/{dn}.json with the APIC-cookie; return parsed JSON."""
|
||||||
|
import json as _json
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
if dn is not None:
|
||||||
|
url = f"https://{host}/api/mo/{dn}.json"
|
||||||
|
else:
|
||||||
|
url = f"https://{host}/api/class/{cls}.json"
|
||||||
|
|
||||||
|
req = urllib.request.Request(url, method="GET", headers={"Cookie": f"APIC-cookie={token}"})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout, context=_unverified_ssl_context()) as resp:
|
||||||
|
raw = resp.read().decode("utf-8")
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
raw = exc.read().decode("utf-8")
|
||||||
|
except (urllib.error.URLError, OSError, TimeoutError) as exc:
|
||||||
|
raise QueryConnectionError(str(exc)) from exc
|
||||||
|
|
||||||
|
return _json.loads(raw)
|
||||||
|
|
||||||
|
|
||||||
|
# A handful of well-known attributes worth surfacing as extra table columns,
|
||||||
|
# tried in this order — the first one present on the MO wins (keeps the
|
||||||
|
# table useful across very different classes without a per-class mapping).
|
||||||
|
_QUERY_KEY_ATTR_CANDIDATES = ("name", "dn", "id")
|
||||||
|
_QUERY_EXTRA_ATTR_CANDIDATES = ("role", "state", "status", "adSt", "operSt", "descr")
|
||||||
|
|
||||||
|
|
||||||
|
def _format_query_text(cls_label: str, imdata: list[dict[str, Any]]) -> str:
|
||||||
|
"""Render a compact one-row-per-MO table, in `aci-sim lldp`'s tabular style."""
|
||||||
|
if not imdata:
|
||||||
|
return f"No {cls_label} objects found."
|
||||||
|
|
||||||
|
rows: list[dict[str, str]] = []
|
||||||
|
for entry in imdata:
|
||||||
|
if not isinstance(entry, dict) or not entry:
|
||||||
|
continue
|
||||||
|
class_name, body = next(iter(entry.items()))
|
||||||
|
attrs = body.get("attributes", {}) if isinstance(body, dict) else {}
|
||||||
|
key = next((attrs[a] for a in _QUERY_KEY_ATTR_CANDIDATES if attrs.get(a)), "-")
|
||||||
|
extra = next((attrs[a] for a in _QUERY_EXTRA_ATTR_CANDIDATES if attrs.get(a)), "-")
|
||||||
|
rows.append({"class": class_name, "key": key, "extra": extra, "dn": attrs.get("dn", "-")})
|
||||||
|
|
||||||
|
lines = [f"{'CLASS':<16} {'NAME/KEY':<28} {'EXTRA':<12} DN"]
|
||||||
|
for r in rows:
|
||||||
|
lines.append(f"{r['class']:<16} {r['key']:<28} {r['extra']:<12} {r['dn']}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_query(args: argparse.Namespace) -> int:
|
||||||
|
host = args.host
|
||||||
|
user = args.user
|
||||||
|
password = args.password
|
||||||
|
|
||||||
|
if not args.dn and not args.cls:
|
||||||
|
print("ERROR: provide a CLASS (e.g. `aci-sim query fabricNode`) or --dn DN", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
token = _aaa_login(host, user, password)
|
||||||
|
except QueryConnectionError:
|
||||||
|
print(
|
||||||
|
f"ERROR: could not connect to {host} — is the sim running? "
|
||||||
|
f"start it with `aci-sim run`",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
except ValueError as exc:
|
||||||
|
print(f"ERROR: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = _query_mit(host, token, cls=args.cls if not args.dn else None, dn=args.dn)
|
||||||
|
except QueryConnectionError:
|
||||||
|
print(
|
||||||
|
f"ERROR: could not connect to {host} — is the sim running? "
|
||||||
|
f"start it with `aci-sim run`",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
imdata = data.get("imdata", []) if isinstance(data, dict) else []
|
||||||
|
error_text = _error_text_from_envelope_dict(data)
|
||||||
|
if error_text is not None:
|
||||||
|
print(f"ERROR: {error_text}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(data, indent=2))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
label = args.dn if args.dn else args.cls
|
||||||
|
print(_format_query_text(label, imdata))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# run
|
# run
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -1668,6 +1878,18 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
p_lldp.add_argument("--json", action="store_true", help="Emit machine-readable JSON instead of text.")
|
p_lldp.add_argument("--json", action="store_true", help="Emit machine-readable JSON instead of text.")
|
||||||
p_lldp.set_defaults(func=cmd_lldp)
|
p_lldp.set_defaults(func=cmd_lldp)
|
||||||
|
|
||||||
|
p_query = sub.add_parser(
|
||||||
|
"query",
|
||||||
|
help="Authenticated MIT query (aaaLogin -> cookie -> GET) against a running sim — no manual cookie handling.",
|
||||||
|
)
|
||||||
|
p_query.add_argument("cls", metavar="CLASS", nargs="?", default=None, help="MO class to query, e.g. fabricNode, fvTenant, topSystem")
|
||||||
|
p_query.add_argument("--dn", default=None, help="Query a single MO by DN instead of a class query")
|
||||||
|
p_query.add_argument("--host", default=DEFAULT_QUERY_HOST, help=f"APIC host:port (default: {DEFAULT_QUERY_HOST}, i.e. port-mode LAB1)")
|
||||||
|
p_query.add_argument("--user", default=DEFAULT_QUERY_USER, help=f"Admin username (default: {DEFAULT_QUERY_USER})")
|
||||||
|
p_query.add_argument("--password", default=DEFAULT_QUERY_PASSWORD, help=f"Admin password (default: {DEFAULT_QUERY_PASSWORD})")
|
||||||
|
p_query.add_argument("--json", action="store_true", help="Emit the raw APIC JSON envelope instead of a compact table.")
|
||||||
|
p_query.set_defaults(func=cmd_query)
|
||||||
|
|
||||||
p_graph = sub.add_parser(
|
p_graph = sub.add_parser(
|
||||||
"graph", help="Render a self-contained SVG/HTML topology diagram (no server, no CDN)."
|
"graph", help="Render a self-contained SVG/HTML topology diagram (no server, no CDN)."
|
||||||
)
|
)
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 102 KiB |
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "aci-sim"
|
name = "aci-sim"
|
||||||
version = "0.16.0"
|
version = "0.17.0"
|
||||||
description = "Faithful REST simulator of a 2-site Cisco ACI fabric (per-site APIC + ND/NDO)"
|
description = "Faithful REST simulator of a 2-site Cisco ACI fabric (per-site APIC + ND/NDO)"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
+3
-1
@@ -4,7 +4,9 @@
|
|||||||
# unchanged.
|
# unchanged.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
|
PY="./.venv/bin/python"
|
||||||
|
[ -x "$PY" ] || PY="python3"
|
||||||
if [ ! -f certs/sim.crt ] || [ ! -f certs/sim.key ]; then
|
if [ ! -f certs/sim.crt ] || [ ! -f certs/sim.key ]; then
|
||||||
bash scripts/gen_certs.sh
|
bash scripts/gen_certs.sh
|
||||||
fi
|
fi
|
||||||
python -m aci_sim.runtime.supervisor
|
"$PY" -m aci_sim.runtime.supervisor
|
||||||
|
|||||||
@@ -16,9 +16,14 @@ from pathlib import Path
|
|||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from aci_sim.cli import (
|
from aci_sim.cli import (
|
||||||
|
QueryConnectionError,
|
||||||
_ensure_certs,
|
_ensure_certs,
|
||||||
|
_error_text_from_envelope,
|
||||||
|
_error_text_from_envelope_dict,
|
||||||
|
_format_query_text,
|
||||||
build_parser,
|
build_parser,
|
||||||
build_run_env_argv,
|
build_run_env_argv,
|
||||||
|
cmd_query,
|
||||||
generate_topology,
|
generate_topology,
|
||||||
main,
|
main,
|
||||||
)
|
)
|
||||||
@@ -459,3 +464,95 @@ class TestRunEnvArgv:
|
|||||||
|
|
||||||
assert cert.read_text(encoding="utf-8") == "existing-cert"
|
assert cert.read_text(encoding="utf-8") == "existing-cert"
|
||||||
assert key.read_text(encoding="utf-8") == "existing-key"
|
assert key.read_text(encoding="utf-8") == "existing-key"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# query — pure-logic helpers only (no live socket binding, same philosophy
|
||||||
|
# as TestRunEnvArgv above); the actual aaaLogin->cookie->GET round trip
|
||||||
|
# against a live sim is exercised manually (see README §4 quickstart) and by
|
||||||
|
# examples/ci/assert_mit.py-style E2E scripts, not by this unit suite.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestQuery:
|
||||||
|
def test_format_query_text_class_query(self) -> None:
|
||||||
|
imdata = [
|
||||||
|
{"fabricNode": {"attributes": {"dn": "topology/pod-1/node-201", "name": "LAB1-SP01", "role": "spine"}}},
|
||||||
|
{"fabricNode": {"attributes": {"dn": "topology/pod-1/node-101", "name": "LAB1-LF101", "role": "leaf"}}},
|
||||||
|
]
|
||||||
|
text = _format_query_text("fabricNode", imdata)
|
||||||
|
assert "LAB1-SP01" in text
|
||||||
|
assert "spine" in text
|
||||||
|
assert "topology/pod-1/node-201" in text
|
||||||
|
# Header row present
|
||||||
|
assert "CLASS" in text and "DN" in text
|
||||||
|
|
||||||
|
def test_format_query_text_empty(self) -> None:
|
||||||
|
text = _format_query_text("fvTenant", [])
|
||||||
|
assert "No fvTenant objects found." == text
|
||||||
|
|
||||||
|
def test_format_query_text_falls_back_to_dn_when_no_name(self) -> None:
|
||||||
|
imdata = [{"fvCtx": {"attributes": {"dn": "uni/tn-X/ctx-vrf1"}}}]
|
||||||
|
text = _format_query_text("fvCtx", imdata)
|
||||||
|
assert "uni/tn-X/ctx-vrf1" in text
|
||||||
|
|
||||||
|
def test_error_text_from_envelope_dict_detects_error(self) -> None:
|
||||||
|
envelope = {"imdata": [{"error": {"attributes": {"text": "Token was invalid", "code": "403"}}}], "totalCount": "1"}
|
||||||
|
assert _error_text_from_envelope_dict(envelope) == "Token was invalid (code 403)"
|
||||||
|
|
||||||
|
def test_error_text_from_envelope_dict_none_for_ok_envelope(self) -> None:
|
||||||
|
envelope = {"imdata": [{"fabricNode": {"attributes": {"dn": "topology/pod-1/node-201"}}}], "totalCount": "1"}
|
||||||
|
assert _error_text_from_envelope_dict(envelope) is None
|
||||||
|
|
||||||
|
def test_error_text_from_envelope_string_wrapper(self) -> None:
|
||||||
|
raw = json.dumps({"imdata": [{"error": {"attributes": {"text": "bad creds", "code": "401"}}}]})
|
||||||
|
assert _error_text_from_envelope(raw) == "bad creds (code 401)"
|
||||||
|
|
||||||
|
def test_error_text_from_envelope_malformed_json_is_none(self) -> None:
|
||||||
|
assert _error_text_from_envelope("not json") is None
|
||||||
|
|
||||||
|
def test_cmd_query_requires_class_or_dn(self, capsys) -> None:
|
||||||
|
rc = main(["query", "--host", "127.0.0.1:1"])
|
||||||
|
err = capsys.readouterr().err
|
||||||
|
assert rc == 1
|
||||||
|
assert "CLASS" in err or "--dn" in err
|
||||||
|
|
||||||
|
def test_cmd_query_connection_error_is_friendly(self, capsys) -> None:
|
||||||
|
# Port 1 should refuse/timeout immediately on any dev machine — no
|
||||||
|
# live sim required for this test, matching TestRunEnvArgv's
|
||||||
|
# no-live-socket philosophy.
|
||||||
|
rc = main(["query", "fabricNode", "--host", "127.0.0.1:1"])
|
||||||
|
out_err = capsys.readouterr().err
|
||||||
|
assert rc == 1
|
||||||
|
assert "is the sim running" in out_err
|
||||||
|
assert "aci-sim run" in out_err
|
||||||
|
|
||||||
|
def test_query_parser_wiring_defaults(self) -> None:
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args(["query", "fabricNode"])
|
||||||
|
assert args.cls == "fabricNode"
|
||||||
|
assert args.dn is None
|
||||||
|
assert args.host == "127.0.0.1:8443"
|
||||||
|
assert args.user == "admin"
|
||||||
|
assert args.password == "cisco"
|
||||||
|
assert args.json is False
|
||||||
|
assert args.func is cmd_query
|
||||||
|
|
||||||
|
def test_query_parser_wiring_dn_and_overrides(self) -> None:
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args(
|
||||||
|
["query", "--dn", "uni/tn-mgmt", "--host", "10.0.0.1:9999", "--user", "bob", "--password", "hunter2", "--json"]
|
||||||
|
)
|
||||||
|
assert args.cls is None
|
||||||
|
assert args.dn == "uni/tn-mgmt"
|
||||||
|
assert args.host == "10.0.0.1:9999"
|
||||||
|
assert args.user == "bob"
|
||||||
|
assert args.password == "hunter2"
|
||||||
|
assert args.json is True
|
||||||
|
|
||||||
|
def test_query_connection_error_is_a_distinct_exception_type(self) -> None:
|
||||||
|
# Sanity: QueryConnectionError must not be a bare Exception subclass
|
||||||
|
# confusable with ValueError (used for auth-failure vs connection
|
||||||
|
# failure branching in cmd_query).
|
||||||
|
assert issubclass(QueryConnectionError, Exception)
|
||||||
|
assert not issubclass(QueryConnectionError, ValueError)
|
||||||
|
|||||||
Reference in New Issue
Block a user