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:
2026-07-05 22:51:55 +10:00
co-authored by Claude Fable 5
parent 7e9a175ce6
commit 1d29a2f289
9 changed files with 480 additions and 10 deletions
+32
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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.
+123 -6
View File
@@ -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`.
![aci-sim topology](docs/images/topology.png)
*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):
![aci-sim topology](docs/images/topology.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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+97
View File
@@ -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)