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)
(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
### 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
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
`.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
```
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 —
most existing tests are organized by PR/feature (`tests/test_pr*.py`,
`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
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
@@ -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
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)
```bash
bash scripts/run.sh
# or, equivalently, via the aci-sim CLI (see §5):
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
starts:
Generates a self-signed cert on first run (`scripts/gen_certs.sh`'s logic,
either via `aci-sim run` or `scripts/run.sh`) and starts:
```
[sim] APIC LAB1 (asn 65001) → https://127.0.0.1:8443
@@ -242,10 +270,33 @@ starts:
[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
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)
@@ -417,10 +468,76 @@ $ aci-sim graph lab3.topology.yaml -o 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`
(default `topology.html`; `.html`/`.htm` writes an HTML wrapper, `.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]`
A thin wrapper around `python -m aci_sim.runtime.supervisor` — it
+222
View File
@@ -30,6 +30,7 @@ import ipaddress
import json
import os
import re
import ssl
import sys
from collections.abc import Callable
from pathlib import Path
@@ -442,6 +443,215 @@ def cmd_lldp(args: argparse.Namespace) -> int:
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
# ---------------------------------------------------------------------------
@@ -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.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(
"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]
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)"
readme = "README.md"
license = "MIT"
+3 -1
View File
@@ -4,7 +4,9 @@
# unchanged.
set -euo pipefail
cd "$(dirname "$0")/.."
PY="./.venv/bin/python"
[ -x "$PY" ] || PY="python3"
if [ ! -f certs/sim.crt ] || [ ! -f certs/sim.key ]; then
bash scripts/gen_certs.sh
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
from aci_sim.cli import (
QueryConnectionError,
_ensure_certs,
_error_text_from_envelope,
_error_text_from_envelope_dict,
_format_query_text,
build_parser,
build_run_env_argv,
cmd_query,
generate_topology,
main,
)
@@ -459,3 +464,95 @@ class TestRunEnvArgv:
assert cert.read_text(encoding="utf-8") == "existing-cert"
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)