diff --git a/CHANGELOG.md b/CHANGELOG.md index fe922d0..0690829 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 6a227da..5bbf161 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 75ed9a7..025a69e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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_.py`); follow that convention for new ones. diff --git a/README.md b/README.md index c078626..3202a0c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/aci_sim/cli.py b/aci_sim/cli.py index 5163333..d0c61d3 100644 --- a/aci_sim/cli.py +++ b/aci_sim/cli.py @@ -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)." ) diff --git a/docs/images/topology.png b/docs/images/topology.png new file mode 100644 index 0000000..8261958 Binary files /dev/null and b/docs/images/topology.png differ diff --git a/pyproject.toml b/pyproject.toml index a3abaa7..e6c4765 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/scripts/run.sh b/scripts/run.sh index 35b2750..6842d74 100755 --- a/scripts/run.sh +++ b/scripts/run.sh @@ -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 diff --git a/tests/test_cli.py b/tests/test_cli.py index b55590c..9c9b94e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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)