From 3c052ae5210550c8efaa2224fad3dad877865a0d Mon Sep 17 00:00:00 2001 From: Adam Rickards Date: Fri, 28 Aug 2026 10:47:49 +0000 Subject: [PATCH 001/143] Count YAML in GitHub language stats. Linguist hides data languages; schema and wire YAML are the product. Closes #18. --- .gitattributes | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..c7dd48c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# YAML is the product (schema + wire). Linguist hides data languages by default. +*.yaml linguist-detectable=true +*.yml linguist-detectable=true From 5e79ec4c5c9657717558b08393fef8667eea5260 Mon Sep 17 00:00:00 2001 From: Adam Rickards Date: Sat, 29 Aug 2026 00:58:03 +0000 Subject: [PATCH 002/143] Add read-only sidecar as HTTP over release_matrix --inspect. POST /v1/run {name} maps get_dns.read to the existing inspect harness. Writes stay 400. Closes #20. --- sidecar/README.md | 50 +++++++ sidecar/__init__.py | 3 + sidecar/__main__.py | 37 +++++ sidecar/app.py | 239 +++++++++++++++++++++++++++++++++ sidecar/openapi.yaml | 15 ++- tests/test_sidecar_readonly.py | 134 ++++++++++++++++++ 6 files changed, 474 insertions(+), 4 deletions(-) create mode 100644 sidecar/README.md create mode 100644 sidecar/__init__.py create mode 100644 sidecar/__main__.py create mode 100644 sidecar/app.py create mode 100755 tests/test_sidecar_readonly.py diff --git a/sidecar/README.md b/sidecar/README.md new file mode 100644 index 0000000..c267899 --- /dev/null +++ b/sidecar/README.md @@ -0,0 +1,50 @@ +# Sidecar (read-only named tests) + +Thin HTTP over the existing harness. Not a second test suite. + + POST /v1/run {"name": "get_dns.read"} + +maps to + + python3 tests/release_matrix.py --inspect --method get_dns --device + +Same Python function: `tests/release_matrix.py::run_inspect`. +Documented in `tests/README_TESTS.md` (use `--inspect`, never throwaway +`get_*` scripts). `--inspect` is read-only by design. + +## Name → CLI + +| catalog name | call | +| `get_dns.read` | `--inspect --method get_dns --kind read` | +| `set_dns.roundtrip` | HTTP 400 `bad_name` (not called) | +| `dns.lifecycle.mops` | HTTP 400 `bad_name` (not called) | +| `save_config.execute` | HTTP 400 `bad_name` (not called) | +| unknown well-formed | HTTP 404 `unknown_name` | + +Device IP comes from the sidecar machine's gitignored +`tests/device_pool.yaml`. Never from the request body. Never from git. +Example pool host is TEST-NET `192.0.2.10` in +`tests/device_pool.yaml.example`. Passwords from the environment +(`CRUDE_DEVICE_PASSWORD`). + +Sync: iterate tests on a PC, merge to GitHub, sidecar machine git pull. + +## Run + +From the crude-engine root: + + python3 -m sidecar --help + python3 -m sidecar --host 127.0.0.1 --port 8765 + +Default `CRUDE_SIDECAR_MODE=read-only`. Default transport is `fake` +(mocked inspect, no switch). Operator LAN: set `CRUDE_SIDECAR_TRANSPORT` +to `live` and keep the pool file local. + +Bind loopback. Bot VM must not WireGuard and must not SSH switches. + +Put `SIDECAR_URL` in a gitignored `.env` on the operator machine. +`servers.url` in `openapi.yaml` stays `/`. + +## Offline proofs + + python3 tests/test_sidecar_readonly.py diff --git a/sidecar/__init__.py b/sidecar/__init__.py new file mode 100644 index 0000000..c43a55f --- /dev/null +++ b/sidecar/__init__.py @@ -0,0 +1,3 @@ +"""Thin HTTP over tests/release_matrix.py --inspect (issue 20).""" + +__version__ = "0.1.0" diff --git a/sidecar/__main__.py b/sidecar/__main__.py new file mode 100644 index 0000000..4ccfd92 --- /dev/null +++ b/sidecar/__main__.py @@ -0,0 +1,37 @@ +"""python -m sidecar — thin HTTP over tests/release_matrix.py --inspect.""" +from __future__ import annotations + +import argparse +import os +import sys + + +def main(argv=None): + p = argparse.ArgumentParser( + prog="python -m sidecar", + description=( + "POST /v1/run {name} maps catalog *.read to " + "tests/release_matrix.py --inspect --method . " + "Default mode is read-only." + ), + ) + p.add_argument("--host", default=os.environ.get("CRUDE_SIDECAR_HOST", "127.0.0.1")) + p.add_argument("--port", type=int, default=int(os.environ.get("CRUDE_SIDECAR_PORT", "8765"))) + args = p.parse_args(argv) + from sidecar.app import make_server, mode + + print( + f"sidecar mode={mode()!r} POST /v1/run on {args.host}:{args.port} " + f"inspect=tests/release_matrix.py::run_inspect", + file=sys.stderr, + ) + httpd = make_server(args.host, args.port) + try: + httpd.serve_forever() + except KeyboardInterrupt: + return 0 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/sidecar/app.py b/sidecar/app.py new file mode 100644 index 0000000..d3d9e67 --- /dev/null +++ b/sidecar/app.py @@ -0,0 +1,239 @@ +"""Thin HTTP over tests/release_matrix.py --inspect. + +POST /v1/run {name: get_dns.read} maps to + python3 tests/release_matrix.py --inspect --method get_dns --device + +Device IP comes from local gitignored tests/device_pool.yaml, never from +the request body, never from this package. Read-only mode refuses +non-*.read *before* calling inspect. This module does not gather, dump, +or assert; it calls release_matrix.run_inspect and shapes the OpenAPI body. +""" +from __future__ import annotations + +import json +import os +import re +import sys +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import urlparse + +ROOT = Path(__file__).resolve().parents[1] +CATALOG_PATH = ROOT / "tests" / "catalog.yaml" +POOL_PATH = ROOT / "tests" / "device_pool.yaml" +NAME_RE = re.compile(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$") +DEFAULT_MODE = "read-only" + +try: + import yaml +except ImportError: # pragma: no cover + yaml = None + + +class RunLock: + def __init__(self): + self._lock = threading.Lock() + + def acquire(self): + return self._lock.acquire(blocking=False) + + def release(self): + self._lock.release() + + +LOCK = RunLock() + + +def mode(): + return (os.environ.get("CRUDE_SIDECAR_MODE") or DEFAULT_MODE).strip().lower() + + +def transport(): + return (os.environ.get("CRUDE_SIDECAR_TRANSPORT") or "fake").strip().lower() + + +def well_formed(name): + return bool(name) and isinstance(name, str) and NAME_RE.match(name) + + +def is_read(entry): + if not entry: + return False + name = entry.get("name") or "" + return entry.get("access") == "R" or str(name).endswith(".read") + + +def load_catalog(): + if yaml is None: + raise FileNotFoundError("pyyaml required") + if not CATALOG_PATH.is_file(): + raise FileNotFoundError(str(CATALOG_PATH)) + data = yaml.safe_load(CATALOG_PATH.read_text()) or {} + entries = {} + for item in data.get("entries") or []: + n = item.get("name") + if n: + entries[str(n)] = item + return entries + + +def name_to_inspect(name, entry): + """Catalog get_dns.read → --inspect --method get_dns --kind read.""" + method = entry.get("method") + if not method and name.endswith(".read"): + method = name[: -len(".read")] + return method + + +def pick_device_ip(): + """First read-safe device in the local gitignored pool. None if absent.""" + if yaml is None or not POOL_PATH.is_file(): + return None + data = yaml.safe_load(POOL_PATH.read_text()) or {} + for dev in data.get("devices") or []: + safe = dev.get("safe_for") or [] + ip = dev.get("ip") + if ip and (not safe or "read" in safe): + return str(ip) + return None + + +def shape_inspect(name, rc): + passed = rc == 0 + comms = "ok" if passed else "lost" + expected = {"comms": "ok", "rollback": "not_armed"} + actual = {"comms": comms, "rollback": "not_armed"} + return { + "result": { + "name": name, + "passed": passed, + "commands_sent": True, + "comms": comms, + "rollback": "not_armed", + "expected": expected, + "actual": actual, + }, + "audit": {"diff": {"buckets": []}}, + "timings": { + "encode_dispatch_ms": 0, + "gather_decode_ms": 0, + "time_to_confirm_ms": None, + "time_to_rollback_visible_ms": None, + "audit_lag_ms": None, + "device_timer_ms": 0, + }, + } + + +def call_inspect(method, device, protocol=None): + """Call tests/release_matrix.py::run_inspect. Fake transport does not.""" + if transport() in ("fake", "offline"): + return 0 + tests_dir = str(ROOT / "tests") + if tests_dir not in sys.path: + sys.path.insert(0, tests_dir) + import release_matrix as rm + + user = os.environ.get("CRUDE_DEVICE_USERNAME") or "admin" + password = os.environ.get("CRUDE_DEVICE_PASSWORD") or "" + t0 = time.perf_counter() + rc = rm.run_inspect( + method, + device, + protocol, + username=user, + password=password, + ) + _ = t0 + return rc + + +def handle_run(payload): + """Return (status_code, body). Refuses non-reads before inspect.""" + if not isinstance(payload, dict) or set(payload.keys()) - {"name"}: + return 400, {"error": "bad_name", "message": 'body must be {"name": ...}'} + name = payload.get("name") + if not well_formed(name): + return 400, {"error": "bad_name", "message": "not a catalog test name", "name": name} + + try: + catalog = load_catalog() + except FileNotFoundError as exc: + return 503, {"error": "not_ready", "message": str(exc), "name": name} + + entry = catalog.get(name) + if entry is None: + return 404, {"error": "unknown_name", "message": "not in catalog", "name": name} + + if mode() in ("read-only", "readonly", "read") and not is_read(entry): + return 400, { + "error": "bad_name", + "message": "read-only mode allows catalog access R / *.read only", + "name": name, + } + + method = name_to_inspect(name, entry) + if not method: + return 400, {"error": "bad_name", "message": "name does not map to --method", "name": name} + + device = pick_device_ip() + if transport() not in ("fake", "offline") and not device: + return 503, { + "error": "not_ready", + "message": "no local tests/device_pool.yaml (gitignored)", + "name": name, + } + + protocol = os.environ.get("CRUDE_SIDECAR_PROTOCOL") or None + + if not LOCK.acquire(): + return 409, {"error": "lock_held", "message": "lab lock is held", "name": name} + try: + rc = call_inspect(method, device, protocol) + if rc not in (0, None) and rc != 0: + if rc == 2: + return 503, { + "error": "not_ready", + "message": "release_matrix --inspect returned 2", + "name": name, + } + return 200, shape_inspect(name, 0 if rc in (0, None) else rc) + finally: + LOCK.release() + + +class Handler(BaseHTTPRequestHandler): + def log_message(self, fmt, *args): + sys.stderr.write("%s - %s\n" % (self.address_string(), fmt % args)) + + def _send(self, code, body): + raw = json.dumps(body).encode("utf-8") + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(raw))) + self.end_headers() + self.wfile.write(raw) + + def do_POST(self): + path = urlparse(self.path).path + if path != "/v1/run": + self._send(404, {"error": "unknown_name", "message": "not POST /v1/run"}) + return + length = int(self.headers.get("Content-Length") or 0) + raw = self.rfile.read(length) if length else b"" + try: + payload = json.loads(raw.decode("utf-8") or "null") + except (ValueError, UnicodeDecodeError): + self._send(400, {"error": "bad_name", "message": "body is not JSON"}) + return + code, body = handle_run(payload) + self._send(code, body) + + def do_GET(self): + self._send(400, {"error": "bad_name", "message": "POST /v1/run only"}) + + +def make_server(host, port): + return ThreadingHTTPServer((host, port), Handler) diff --git a/sidecar/openapi.yaml b/sidecar/openapi.yaml index c0f8eb6..e5c1f21 100644 --- a/sidecar/openapi.yaml +++ b/sidecar/openapi.yaml @@ -3,10 +3,17 @@ info: title: CRUDE lab sidecar version: 0.1.0 description: | - Contract only. This file is the Cycle 0 sidecar API. The VPS is not - implemented here. Named catalog entries are generated later from schema - `type:` (C/R/U/D) plus protocol `execute_methods` (E). Do not invent - CRUDE methods in this document. + Cycle 0 sidecar API. Named catalog entries come from schema `type:` + (C/R/U/D) plus protocol `execute_methods` (E). Do not invent CRUDE + methods in this document. + + **Read-only process (issue 20).** Default `CRUDE_SIDECAR_MODE=read-only`. + `python -m sidecar` serves POST /v1/run for catalog `access: R` / + `*.read` only. Rollback is `not_armed`. This process does not arm + HiOS rollback and does not implement Keep/Revert. Other catalog + names (`*.roundtrip`, `*.execute`, `*.lifecycle`) return 400 + `bad_name`. Homelab VPS write path is not this process. + `servers.url` stays `/`. **Where it runs.** The sidecar lives on the existing VPS already on the homelab VPN. The Bot VM must not WireGuard the homelab, must not hold diff --git a/tests/test_sidecar_readonly.py b/tests/test_sidecar_readonly.py new file mode 100755 index 0000000..7d9ca92 --- /dev/null +++ b/tests/test_sidecar_readonly.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +"""Offline proofs for issue 20. Mocked inspect. No device. No WireGuard.""" +from __future__ import annotations + +import json +import os +import subprocess +import sys +import threading +from http.client import HTTPConnection +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +os.environ["CRUDE_SIDECAR_MODE"] = "read-only" +os.environ["CRUDE_SIDECAR_TRANSPORT"] = "fake" +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from sidecar.app import ( # noqa: E402 + call_inspect, + handle_run, + make_server, + name_to_inspect, +) + + +def fail(msg): + print(f"FAIL {msg}") + return 1 + + +def ok(msg): + print(f"PASS {msg}") + return 0 + + +def post_http(port, name): + conn = HTTPConnection("127.0.0.1", port, timeout=5) + body = json.dumps({"name": name}).encode("utf-8") + conn.request("POST", "/v1/run", body=body, headers={"Content-Type": "application/json"}) + resp = conn.getresponse() + raw = resp.read() + conn.close() + return resp.status, json.loads(raw.decode("utf-8")) + + +def main(): + errors = 0 + + mapping = name_to_inspect("get_dns.read", {"method": "get_dns", "name": "get_dns.read"}) + if mapping != "get_dns": + errors += fail(f"name map {mapping!r}") + else: + errors += ok("get_dns.read → --inspect --method get_dns") + + if call_inspect("get_dns", None) != 0: + errors += fail("fake inspect should return 0 without a device") + else: + errors += ok("fake/mocked inspect (no switch)") + + code, body = handle_run({"name": "get_dns.read"}) + if code != 200 or body.get("result", {}).get("rollback") != "not_armed": + errors += fail(f"get_dns.read → {code} {body}") + else: + errors += ok("POST get_dns.read → 200 rollback=not_armed") + + code, body = handle_run({"name": "set_dns.roundtrip"}) + if code != 400 or body.get("error") != "bad_name": + errors += fail(f"set_dns.roundtrip → {code} {body}") + else: + errors += ok("POST set_dns.roundtrip → 400 bad_name (inspect not called)") + + code, body = handle_run({"name": "get_no_such_method.read"}) + if code != 404 or body.get("error") != "unknown_name": + errors += fail(f"unknown → {code} {body}") + else: + errors += ok("unknown well-formed name → 404 unknown_name") + + httpd = make_server("127.0.0.1", 0) + port = httpd.server_address[1] + thread = threading.Thread(target=httpd.serve_forever, daemon=True) + thread.start() + try: + st, body = post_http(port, "get_dns.read") + if st != 200 or body.get("result", {}).get("rollback") != "not_armed": + errors += fail(f"HTTP get_dns.read → {st} {body}") + else: + errors += ok("HTTP POST /v1/run get_dns.read → 200") + st, body = post_http(port, "dns.lifecycle.mops") + if st != 400 or body.get("error") != "bad_name": + errors += fail(f"HTTP lifecycle → {st} {body}") + else: + errors += ok("HTTP POST dns.lifecycle.mops → 400 bad_name") + finally: + httpd.shutdown() + + hits = [] + for path in (ROOT / "sidecar").rglob("*"): + if path.suffix.lower() not in {".yaml", ".yml", ".md", ".py"}: + continue + if "__pycache__" in path.parts: + continue + text = path.read_text(errors="replace") + for i, line in enumerate(text.splitlines(), 1): + if "https://" in line.lower(): + hits.append(f"{path.relative_to(ROOT)}:{i}") + if hits: + errors += fail("https URL in sidecar/: " + ", ".join(hits)) + else: + errors += ok("no https URL in sidecar/") + + help_proc = subprocess.run( + [sys.executable, "-m", "sidecar", "--help"], + cwd=str(ROOT), + capture_output=True, + text=True, + env={**os.environ, "PYTHONPATH": str(ROOT)}, + ) + text = (help_proc.stdout + help_proc.stderr).lower() + if help_proc.returncode != 0 or "usage:" not in text: + errors += fail(f"python -m sidecar --help exit {help_proc.returncode}: {help_proc.stderr}") + else: + errors += ok("python -m sidecar --help") + + print() + if errors: + print(f"{errors} sidecar proof(s) failed") + return 1 + print("sidecar read-only proofs passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From a15ec3ee8779938c2dd8a2081dc9bb8292347df9 Mon Sep 17 00:00:00 2001 From: Adam Rickards Date: Sat, 29 Aug 2026 07:40:03 +0000 Subject: [PATCH 003/143] fix(tests): cache wire YAML loads in audit_common load_all_method_metadata re-parsed the same wire files once per attribute (~50s cold --inspect). Memoize the two loaders. Closes #22. --- tests/audit_common.py | 3 ++ tests/test_audit_common_wire_cache.py | 68 +++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100755 tests/test_audit_common_wire_cache.py diff --git a/tests/audit_common.py b/tests/audit_common.py index 1288f61..2b8ea00 100644 --- a/tests/audit_common.py +++ b/tests/audit_common.py @@ -313,6 +313,7 @@ def gather_one_ip(ip: str, username: str = "admin", password: str = "private", # # Returns a flat dict of method_name → metadata. +import functools import os as _os import yaml as _yaml @@ -350,6 +351,7 @@ def _load_wire_exemptions() -> dict: return out +@functools.lru_cache(maxsize=None) def _load_one_wire(wire_file: str) -> dict: """Load and return base wire YAML for `wire_file` (no .yaml extension).""" p = _os.path.join(_WIRE_DIR, f"{wire_file}.yaml") @@ -359,6 +361,7 @@ def _load_one_wire(wire_file: str) -> dict: return _yaml.safe_load(f) or {} +@functools.lru_cache(maxsize=None) def _load_one_wire_overlay(wire_file: str, protocol: str) -> dict: """Load wire overlay for a protocol (e.g., wire/ssh/.yaml).""" p = _os.path.join(_WIRE_DIR, protocol, f"{wire_file}.yaml") diff --git a/tests/test_audit_common_wire_cache.py b/tests/test_audit_common_wire_cache.py new file mode 100755 index 0000000..44c8507 --- /dev/null +++ b/tests/test_audit_common_wire_cache.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Offline proof for issue 22. Wire YAML loaders must not re-parse per attribute.""" +from __future__ import annotations + +import sys +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT / "tests") not in sys.path: + sys.path.insert(0, str(ROOT / "tests")) + +from audit_common import ( # noqa: E402 + _load_one_wire, + _load_one_wire_overlay, + load_all_method_metadata, +) + + +def fail(msg): + print(f"FAIL {msg}") + return 1 + + +def ok(msg): + print(f"PASS {msg}") + return 0 + + +def main() -> int: + rc = 0 + _load_one_wire.cache_clear() + _load_one_wire_overlay.cache_clear() + + t0 = time.monotonic() + first = load_all_method_metadata() + elapsed = time.monotonic() - t0 + n = len(first) + if n < 1: + rc |= fail("load_all_method_metadata returned no methods") + else: + rc |= ok(f"metadata methods={n} in {elapsed:.3f}s") + + if elapsed >= 5.0: + rc |= fail(f"cold load took {elapsed:.1f}s; expected well under 5s after cache") + else: + rc |= ok(f"cold load {elapsed:.3f}s < 5s") + + base_info = _load_one_wire.cache_info() + overlay_info = _load_one_wire_overlay.cache_info() + if base_info.hits < 1: + rc |= fail(f"_load_one_wire had no cache hits: {base_info}") + else: + rc |= ok(f"_load_one_wire hits={base_info.hits} misses={base_info.misses}") + # overlays can miss-only if few ssh overlays; hits are expected once files repeat + rc |= ok(f"_load_one_wire_overlay hits={overlay_info.hits} misses={overlay_info.misses}") + + second = load_all_method_metadata() + if set(first) != set(second): + rc |= fail("second load_all_method_metadata() method set differed") + else: + rc |= ok("second load method set matches") + + return rc + + +if __name__ == "__main__": + sys.exit(main()) From 1278e41f563e9380d8210a3518c9c97f9328fce5 Mon Sep 17 00:00:00 2001 From: Adam Rickards Date: Sat, 29 Aug 2026 09:17:57 +0000 Subject: [PATCH 004/143] fix(sidecar): return structured inspect results with YAML timeouts run_inspect returns per-protocol status, raw, and parity_diffs. Timeouts come from tests/inspect.yaml. passed means at least one protocol returned data; diffs stay for issue proof. Closes #24. --- sidecar/app.py | 48 +++++-- sidecar/openapi.yaml | 35 ++++- tests/inspect.yaml | 19 +++ tests/release_matrix.py | 232 +++++++++++++++++++++++---------- tests/test_inspect_result.py | 98 ++++++++++++++ tests/test_sidecar_readonly.py | 16 ++- 6 files changed, 357 insertions(+), 91 deletions(-) create mode 100644 tests/inspect.yaml create mode 100755 tests/test_inspect_result.py diff --git a/sidecar/app.py b/sidecar/app.py index d3d9e67..2647c15 100644 --- a/sidecar/app.py +++ b/sidecar/app.py @@ -100,8 +100,22 @@ def pick_device_ip(): return None -def shape_inspect(name, rc): - passed = rc == 0 +def shape_inspect(name, inspect_out): + """HTTP body for a read inspect. passed = at least one protocol ok (or fake). + + parity_diffs are first-class: callers file GitHub issues from them. + Disagreement does not flip passed to false. + """ + if not isinstance(inspect_out, dict): + inspect_out = {"exit": 0 if inspect_out in (0, None) else inspect_out, + "protocols": {}, "parity_diffs": []} + fake = bool(inspect_out.get("fake")) + protocols = inspect_out.get("protocols") or {} + diffs = inspect_out.get("parity_diffs") or [] + if not isinstance(diffs, list): + diffs = [diffs] + any_ok = any((p or {}).get("status") == "ok" for p in protocols.values()) + passed = True if fake else any_ok comms = "ok" if passed else "lost" expected = {"comms": "ok", "rollback": "not_armed"} actual = {"comms": comms, "rollback": "not_armed"} @@ -114,6 +128,8 @@ def shape_inspect(name, rc): "rollback": "not_armed", "expected": expected, "actual": actual, + "protocols": protocols, + "parity_diffs": diffs, }, "audit": {"diff": {"buckets": []}}, "timings": { @@ -130,7 +146,14 @@ def shape_inspect(name, rc): def call_inspect(method, device, protocol=None): """Call tests/release_matrix.py::run_inspect. Fake transport does not.""" if transport() in ("fake", "offline"): - return 0 + return { + "exit": 0, + "fake": True, + "method": method, + "device": device, + "protocols": {}, + "parity_diffs": [], + } tests_dir = str(ROOT / "tests") if tests_dir not in sys.path: sys.path.insert(0, tests_dir) @@ -138,16 +161,13 @@ def call_inspect(method, device, protocol=None): user = os.environ.get("CRUDE_DEVICE_USERNAME") or "admin" password = os.environ.get("CRUDE_DEVICE_PASSWORD") or "" - t0 = time.perf_counter() - rc = rm.run_inspect( + return rm.run_inspect( method, device, protocol, username=user, password=password, ) - _ = t0 - return rc def handle_run(payload): @@ -191,15 +211,21 @@ def handle_run(payload): if not LOCK.acquire(): return 409, {"error": "lock_held", "message": "lab lock is held", "name": name} try: - rc = call_inspect(method, device, protocol) - if rc not in (0, None) and rc != 0: - if rc == 2: + out = call_inspect(method, device, protocol) + if isinstance(out, dict) and out.get("exit") == 2: + return 503, { + "error": "not_ready", + "message": out.get("error") or "release_matrix --inspect usage error", + "name": name, + } + if not isinstance(out, dict) and out not in (0, None): + if out == 2: return 503, { "error": "not_ready", "message": "release_matrix --inspect returned 2", "name": name, } - return 200, shape_inspect(name, 0 if rc in (0, None) else rc) + return 200, shape_inspect(name, out) finally: LOCK.release() diff --git a/sidecar/openapi.yaml b/sidecar/openapi.yaml index e5c1f21..3e22577 100644 --- a/sidecar/openapi.yaml +++ b/sidecar/openapi.yaml @@ -205,6 +205,8 @@ components: - rollback - expected - actual + - protocols + - parity_diffs properties: name: type: string @@ -212,11 +214,10 @@ components: passed: type: boolean description: | - true only when expected matches actual on first-class - outcomes, audit.diff.buckets is empty, and timings have not - regressed (a timing regression fails like `wrong_encoding`). - Pass/fail is not sufficient by itself; callers must also - read commands_sent, comms, and rollback. + For read-only inspect: true when at least one protocol + returned data (or fake/offline transport). Protocol + disagreement does not flip this to false. Callers file + GitHub issues from `parity_diffs`, not from `passed`. commands_sent: type: boolean description: Whether the sidecar dispatched commands toward the device. @@ -228,6 +229,30 @@ components: $ref: '#/components/schemas/Outcomes' actual: $ref: '#/components/schemas/Outcomes' + protocols: + type: object + additionalProperties: + type: object + properties: + status: + type: string + enum: [ok, connect_failed, dispatch_error, timeout] + elapsed_ms: + type: integer + minimum: 0 + raw: {} + error: + type: string + description: | + Per-protocol inspect outcome. Timeouts come from + tests/inspect.yaml (YAML declares, Python interprets). + parity_diffs: + type: array + description: | + Cross-protocol diffs from the harness parity check. + Empty if fewer than two protocols returned data, or they + matched. Non-empty is issue-proof, not a failed GET. + items: {} Outcomes: type: object additionalProperties: false diff --git a/tests/inspect.yaml b/tests/inspect.yaml new file mode 100644 index 0000000..4c82857 --- /dev/null +++ b/tests/inspect.yaml @@ -0,0 +1,19 @@ +# inspect.yaml — harness inspect budgets. TEST infrastructure, not engine +# schemas. crude_engine does not read this file. tests/release_matrix.py +# ::run_inspect and the sidecar (which calls it) do. +# +# YAML declares per-protocol timeout_s. Python only interprets. Do not +# hardcode seconds in release_matrix.py. One flat timeout is wrong: +# MOPS is usually one call; SNMP is multi-turn when a method has a +# sub_table (scalar get_cmd plus bulk_cmd walk) plus per-OID fallback; +# SSH/CLI is the noisiest (prompt, pagination). +# +# These numbers are starting budgets, not a second floor. Tune here. + +protocols: + mops: + timeout_s: 2 + snmp: + timeout_s: 5 + ssh: + timeout_s: 8 diff --git a/tests/release_matrix.py b/tests/release_matrix.py index 547cbb8..1c187e1 100644 --- a/tests/release_matrix.py +++ b/tests/release_matrix.py @@ -33,6 +33,8 @@ from __future__ import annotations import argparse +import functools +import concurrent.futures import errno import fcntl import json @@ -57,6 +59,7 @@ TAG_MAP_PATH = os.path.join(HERE, "tag_map.yaml") METHOD_EXEMPTIONS_PATH = os.path.join(HERE, "method_exemptions.yaml") WIRE_EXEMPTIONS_PATH = os.path.join(HERE, "wire_exemptions.yaml") +INSPECT_YAML_PATH = os.path.join(HERE, "inspect.yaml") MATRIX_PATH = os.path.join(HERE, "release_matrix.json") PLAN_PATH = os.path.join(HERE, "release_test_plan.json") @@ -343,6 +346,7 @@ def run_gather(device_ip: str | None = None, state.setdefault("devices", {}) state["gathered_at"] = _now_iso() + from napalm import get_network_driver from napalm import get_network_driver driver = get_network_driver("hios") @@ -2216,6 +2220,50 @@ def run_render() -> None: # ============================================================================= +@functools.lru_cache(maxsize=1) +def _load_inspect_yaml() -> dict: + """Read tests/inspect.yaml. YAML declares; this only loads.""" + import yaml + if not os.path.isfile(INSPECT_YAML_PATH): + raise FileNotFoundError(INSPECT_YAML_PATH) + with open(INSPECT_YAML_PATH) as f: + return yaml.safe_load(f) or {} + + +def _inspect_timeout_s(protocol: str) -> float: + """timeout_s for one protocol from tests/inspect.yaml. No Python defaults.""" + data = _load_inspect_yaml() + entry = ((data.get("protocols") or {}).get(protocol) or {}) + if "timeout_s" not in entry: + raise KeyError( + f"tests/inspect.yaml protocols.{protocol}.timeout_s is not declared" + ) + return float(entry["timeout_s"]) + + +def _call_with_timeout(seconds: float, fn, *args, **kwargs): + """Run fn, raise TimeoutError after seconds. + + Do not wait for the worker on timeout: a hung device.open() must not + hold the sidecar RunLock. The worker thread may outlive the caller. + """ + pool = concurrent.futures.ThreadPoolExecutor(max_workers=1) + fut = pool.submit(fn, *args, **kwargs) + try: + return fut.result(timeout=seconds) + except concurrent.futures.TimeoutError as exc: + raise TimeoutError(f"exceeded {seconds}s") from exc + finally: + pool.shutdown(wait=False) + + +def _jsonable(obj): + try: + return json.loads(json.dumps(obj, default=str)) + except TypeError: + return repr(obj)[:500] + + def run_inspect(method_name: str | None, device_filter: str | None, protocol_filter: str | None, @@ -2223,48 +2271,56 @@ def run_inspect(method_name: str | None, trace: bool = False, no_validate: bool = False, username: str = "admin", - password: str = "private") -> int: + password: str = "private"): """Investigation mode for fault finding. Runs the named read method on the named device across every supported - protocol (or just the named one). Dumps raw return data side-by-side, - runs the parity check, prints the result. Does NOT write to the matrix - DB or modify any persisted state. - - Engine flag passthrough: - trace=True — capture device.last_trace, dump pipeline steps - no_validate=True — pass validate=False (skip gate rejection) - napalm_compat=False is always set (we want raw engine output) + protocol (or just the named one). Prints for interactive use AND returns + a dict the sidecar can put on the wire: + + { + "exit": 0|2, + "method": ..., + "device": ..., + "protocols": {proto: {status, elapsed_ms, raw|error}}, + "parity_diffs": [...], + } - Use this instead of writing throwaway Python scripts when you need to - see what a getter actually returns. The harness has the wiring; use it. + `exit` 2 is usage (missing method/device). `exit` 0 means the inspect + ran; it is not "all protocols agreed." Parity diffs stay in + parity_diffs so callers can file issues. Does NOT write the matrix DB. """ - from audit_common import load_all_method_metadata - from napalm import get_network_driver + def usage(msg: str) -> dict: + print(msg) + return { + "exit": 2, + "error": msg, + "method": method_name, + "device": device_filter, + "protocols": {}, + "parity_diffs": [], + } if not method_name: - print("--inspect requires --method") - return 2 + return usage("--inspect requires --method") if not device_filter: - print("--inspect requires --device") - return 2 + return usage("--inspect requires --device") + from audit_common import load_all_method_metadata schemas_meta = load_all_method_metadata() if method_name not in schemas_meta: - print(f"unknown method: {method_name}") - return 2 + return usage(f"unknown method: {method_name}") meta = schemas_meta[method_name] if meta["kind"] != "read": - print(f"--inspect only supports read methods (got {meta['kind']})") - return 2 + return usage(f"--inspect only supports read methods (got {meta['kind']})") declared_protocols = sorted(meta["protocols"]) if protocol_filter: if protocol_filter not in declared_protocols: - print(f"protocol {protocol_filter!r} has no wire source for {method_name}") - print(f"declared protocols for this method: {declared_protocols}") - return 2 + return usage( + f"protocol {protocol_filter!r} has no wire source for {method_name}" + ) protocols = [protocol_filter] else: protocols = declared_protocols @@ -2288,61 +2344,81 @@ def run_inspect(method_name: str | None, driver = get_network_driver("hios") raw_results: dict[str, object] = {} + protocols_out: dict[str, dict] = {} - # Build the kwargs we'll pass to the method — these flow through the - # adapter to the engine via _call(). napalm_compat=False is the inspect - # default so we get the raw schema-shaped output, not NAPALM reshape. call_kwargs: dict = {"napalm_compat": False} if trace: call_kwargs["trace"] = True if no_validate: call_kwargs["validate"] = False - for proto in protocols: - print(f"--- {proto} ---") - try: - device = driver(device_filter, username, password, - optional_args={"protocol": proto}) - device.open() - except Exception as e: - print(f" CONNECT FAILED: {str(e)[:200]}") - print() - continue - - import time as _time - t0 = _time.monotonic() - raw = None - err = None + def _one_protocol(proto: str): + device = driver(device_filter, username, password, + optional_args={"protocol": proto}) + device.open() + last_trace = None try: fn = getattr(device, method_name) try: raw = fn(**call_kwargs) except TypeError: - # Some methods may not accept all kwargs — retry minimal raw = fn() - except Exception as e: - err = str(e)[:300] - elapsed_ms = round((_time.monotonic() - t0) * 1000) - - # Capture last_trace if trace was requested - last_trace = None - if trace: - last_trace = getattr(device, "last_trace", None) + if trace: + last_trace = getattr(device, "last_trace", None) + if isinstance(raw, tuple): + raw = raw[0] + return raw, last_trace + finally: + try: + device.close() + except Exception: + pass + for proto in protocols: try: - device.close() - except Exception: - pass - - if err: - print(f" DISPATCH ERROR: {err}") + budget = _inspect_timeout_s(proto) + except (KeyError, FileNotFoundError, ValueError, TypeError) as e: + print(f"--- {proto} ---") + print(f" UNDECLARED TIMEOUT: {e}") + print() + protocols_out[proto] = { + "status": "dispatch_error", + "elapsed_ms": 0, + "error": str(e), + } + continue + print(f"--- {proto} (timeout {budget}s) ---") + t0 = time.monotonic() + try: + raw, last_trace = _call_with_timeout(budget, _one_protocol, proto) + except TimeoutError as e: + elapsed_ms = round((time.monotonic() - t0) * 1000) + print(f" TIMEOUT: {e}") print() + protocols_out[proto] = { + "status": "timeout", + "elapsed_ms": elapsed_ms, + "error": str(e), + } + continue + except Exception as e: + elapsed_ms = round((time.monotonic() - t0) * 1000) + msg = str(e)[:300] + # open() failures vs dispatch: both are per-protocol signal + status = "connect_failed" if "CONNECT" in msg.upper() or elapsed_ms < 50 else "dispatch_error" + # Prefer connect_failed when open() raised before a getter exists + if "open" in msg.lower() or elapsed_ms == 0: + status = "connect_failed" + print(f" {status.upper()}: {msg[:200]}") + print() + protocols_out[proto] = { + "status": status, + "elapsed_ms": elapsed_ms, + "error": msg, + } continue - # Strip tuple wrapping (some methods return (result, trace) tuples) - if isinstance(raw, tuple): - raw = raw[0] - + elapsed_ms = round((time.monotonic() - t0) * 1000) print(f" time_ms={elapsed_ms}") if isinstance(raw, dict): @@ -2367,7 +2443,6 @@ def run_inspect(method_name: str | None, if raw is not None: raw_results[proto] = raw - # Trace dump if trace and last_trace: print(f" trace ({len(last_trace)} entries):") for entry in last_trace: @@ -2387,11 +2462,16 @@ def run_inspect(method_name: str | None, print(f" trace: (empty — device.last_trace was None)") print() + protocols_out[proto] = { + "status": "ok", + "elapsed_ms": elapsed_ms, + "raw": _jsonable(raw), + } - # Cross-protocol parity check (uses the same _compute_parity as the gate) + diffs: list = [] if len(raw_results) >= 2: print("=== parity ===") - diffs = _compute_parity(method_name, meta, raw_results) + diffs = list(_compute_parity(method_name, meta, raw_results) or []) if not diffs: print(" PARITY OK — all protocols return matching values for " "non-timing fields") @@ -2403,7 +2483,14 @@ def run_inspect(method_name: str | None, print("=== parity ===") print(" (need ≥ 2 protocols with results to compare)") - return 0 + return { + "exit": 0, + "method": method_name, + "device": device_filter, + "protocols": protocols_out, + "parity_diffs": _jsonable(diffs), + } + # ============================================================================= @@ -2482,12 +2569,15 @@ def main(): return 0 if args.inspect: - return run_inspect(method_name=args.method, - device_filter=args.device, - protocol_filter=args.protocol, - schema_filter=args.schema, - trace=args.trace, - no_validate=args.no_validate) + inspect_out = run_inspect(method_name=args.method, + device_filter=args.device, + protocol_filter=args.protocol, + schema_filter=args.schema, + trace=args.trace, + no_validate=args.no_validate) + if isinstance(inspect_out, dict): + return int(inspect_out.get("exit", 0)) + return inspect_out did_something = False diff --git a/tests/test_inspect_result.py b/tests/test_inspect_result.py new file mode 100755 index 0000000..47110a6 --- /dev/null +++ b/tests/test_inspect_result.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""Offline proofs for issue 24. No device. YAML declares inspect timeouts.""" +from __future__ import annotations + +import sys +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "tests")) +sys.path.insert(0, str(ROOT)) + +from release_matrix import ( # noqa: E402 + _call_with_timeout, + _inspect_timeout_s, + _load_inspect_yaml, +) +from sidecar.app import shape_inspect # noqa: E402 + + +def fail(msg): + print(f"FAIL {msg}") + return 1 + + +def ok(msg): + print(f"PASS {msg}") + return 0 + + +def main() -> int: + rc = 0 + data = _load_inspect_yaml() + protos = data.get("protocols") or {} + for name in ("mops", "snmp", "ssh"): + if "timeout_s" not in (protos.get(name) or {}): + rc |= fail(f"inspect.yaml missing protocols.{name}.timeout_s") + else: + rc |= ok(f"inspect.yaml protocols.{name}.timeout_s={_inspect_timeout_s(name)}") + + mops, snmp, ssh = (_inspect_timeout_s(p) for p in ("mops", "snmp", "ssh")) + if not (mops < snmp <= ssh): + rc |= fail(f"budgets should be mops < snmp <= ssh, got {mops} {snmp} {ssh}") + else: + rc |= ok("MOPS tighter than SNMP, SSH slackest") + + t0 = time.monotonic() + try: + _call_with_timeout(0.2, time.sleep, 5) + rc |= fail("timeout helper did not raise") + except TimeoutError: + elapsed = time.monotonic() - t0 + if elapsed > 2.0: + rc |= fail(f"timeout helper hung {elapsed:.1f}s") + else: + rc |= ok(f"timeout helper raised in {elapsed:.2f}s") + + disagreed = { + "exit": 0, + "protocols": { + "mops": {"status": "ok", "elapsed_ms": 10, "raw": {"servers": {"1": {}}}}, + "ssh": {"status": "ok", "elapsed_ms": 20, "raw": {"servers": {"0": {}}}}, + }, + "parity_diffs": ["servers.mops-only rows: ['1']"], + } + body = shape_inspect("get_dns.read", disagreed) + result = body["result"] + if result.get("passed") is not True: + rc |= fail("parity diffs must not flip passed to false") + elif result.get("parity_diffs") != disagreed["parity_diffs"]: + rc |= fail(f"parity_diffs not passed through: {result.get('parity_diffs')}") + else: + rc |= ok("passed true with non-empty parity_diffs (issue-proof)") + + dead = { + "exit": 0, + "protocols": { + "snmp": {"status": "timeout", "elapsed_ms": 5000, "error": "exceeded 5.0s"}, + }, + "parity_diffs": [], + } + body = shape_inspect("get_dns.read", dead) + if body["result"].get("passed") is not False: + rc |= fail("no protocol ok should be passed false") + else: + rc |= ok("all timeout/connect_failed → passed false") + + fake = {"exit": 0, "fake": True, "protocols": {}, "parity_diffs": []} + if shape_inspect("get_dns.read", fake)["result"].get("passed") is not True: + rc |= fail("fake transport should pass") + else: + rc |= ok("fake transport passed true") + + return rc + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_sidecar_readonly.py b/tests/test_sidecar_readonly.py index 7d9ca92..a92c1fa 100755 --- a/tests/test_sidecar_readonly.py +++ b/tests/test_sidecar_readonly.py @@ -53,16 +53,24 @@ def main(): else: errors += ok("get_dns.read → --inspect --method get_dns") - if call_inspect("get_dns", None) != 0: - errors += fail("fake inspect should return 0 without a device") + fake = call_inspect("get_dns", None) + if not isinstance(fake, dict) or fake.get("exit") != 0 or not fake.get("fake"): + errors += fail(f"fake inspect should return dict exit=0 fake=True, got {fake!r}") else: errors += ok("fake/mocked inspect (no switch)") code, body = handle_run({"name": "get_dns.read"}) - if code != 200 or body.get("result", {}).get("rollback") != "not_armed": + result = body.get("result") or {} + if ( + code != 200 + or result.get("rollback") != "not_armed" + or "parity_diffs" not in result + or "protocols" not in result + or result.get("passed") is not True + ): errors += fail(f"get_dns.read → {code} {body}") else: - errors += ok("POST get_dns.read → 200 rollback=not_armed") + errors += ok("POST get_dns.read → 200 rollback=not_armed + inspect map") code, body = handle_run({"name": "set_dns.roundtrip"}) if code != 400 or body.get("error") != "bad_name": From ac5219a98e6c9cef417da0ce9754519d6c7e553d Mon Sep 17 00:00:00 2001 From: Adam Rickards Date: Sat, 29 Aug 2026 09:49:10 +0000 Subject: [PATCH 005/143] fix(tests): hoist napalm get_network_driver for live inspect run_inspect crashed NameError on every live POST /v1/run after #25. Import once at module scope (None if napalm missing). Offline test calls run_inspect directly, not through sidecar fake. Closes #26. --- sidecar/app.py | 12 ++++- tests/release_matrix.py | 10 ++-- tests/test_inspect_reaches_driver.py | 81 ++++++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 4 deletions(-) create mode 100755 tests/test_inspect_reaches_driver.py diff --git a/sidecar/app.py b/sidecar/app.py index 2647c15..c27fd9b 100644 --- a/sidecar/app.py +++ b/sidecar/app.py @@ -254,7 +254,17 @@ def do_POST(self): except (ValueError, UnicodeDecodeError): self._send(400, {"error": "bad_name", "message": "body is not JSON"}) return - code, body = handle_run(payload) + try: + code, body = handle_run(payload) + except Exception as exc: + self._send( + 500, + { + "error": "not_ready", + "message": f"{type(exc).__name__}: {exc}", + }, + ) + return self._send(code, body) def do_GET(self): diff --git a/tests/release_matrix.py b/tests/release_matrix.py index 1c187e1..61bce91 100644 --- a/tests/release_matrix.py +++ b/tests/release_matrix.py @@ -46,6 +46,11 @@ from datetime import datetime, timezone from typing import Any +try: + from napalm import get_network_driver +except ImportError: + get_network_driver = None + # ----------------------------------------------------------------------------- # Paths # ----------------------------------------------------------------------------- @@ -346,8 +351,6 @@ def run_gather(device_ip: str | None = None, state.setdefault("devices", {}) state["gathered_at"] = _now_iso() - from napalm import get_network_driver - from napalm import get_network_driver driver = get_network_driver("hios") for dev in devices: @@ -1226,7 +1229,6 @@ def run_worker(device_ip: str, jobs: list[dict], matrix_db: MatrixDB, Returns a summary dict for orchestrator logging. """ - from napalm import get_network_driver # Group jobs by protocol — we can only have one open device per # protocol at a time. Open per-protocol within the same worker. @@ -2342,6 +2344,8 @@ def usage(msg: str) -> dict: print(f" engine flags: {', '.join(flags)}") print() + if get_network_driver is None: + raise ImportError("napalm is required for live inspect") driver = get_network_driver("hios") raw_results: dict[str, object] = {} protocols_out: dict[str, dict] = {} diff --git a/tests/test_inspect_reaches_driver.py b/tests/test_inspect_reaches_driver.py new file mode 100755 index 0000000..50a0d0f --- /dev/null +++ b/tests/test_inspect_reaches_driver.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""Issue 24 hole: run_inspect must reach get_network_driver without sidecar fake. + +Does not use CRUDE_SIDECAR_TRANSPORT=fake. Does not need a live switch. +Patches release_matrix.get_network_driver so the protocol loop runs. +""" +from __future__ import annotations + +import os +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "tests")) +sys.path.insert(0, str(ROOT)) +os.environ.pop("CRUDE_SIDECAR_TRANSPORT", None) + +import release_matrix as rm # noqa: E402 + + +def fail(msg): + print(f"FAIL {msg}") + return 1 + + +def ok(msg): + print(f"PASS {msg}") + return 0 + + +class _Boom: + def __init__(self, *args, **kwargs): + pass + + def open(self): + raise OSError("TEST-NET unreachable") + + def close(self): + pass + + +def _driver(_name): + return _Boom + + +def main() -> int: + rc = 0 + if rm.get_network_driver is None: + # Module imported without napalm. Bind a stub so run_inspect can call it. + rm.get_network_driver = _driver + rc |= ok("bound stub get_network_driver (napalm not installed)") + else: + rm.get_network_driver = _driver + rc |= ok("patched get_network_driver") + + out = rm.run_inspect("get_dns", "192.0.2.10", None) + if not isinstance(out, dict): + return fail(f"run_inspect returned {type(out)}") + if out.get("exit") not in (0, None): + rc |= fail(f"exit {out.get('exit')} error={out.get('error')}") + else: + rc |= ok(f"run_inspect exit={out.get('exit')}") + + protos = out.get("protocols") or {} + if not protos: + rc |= fail("no per-protocol entries") + else: + statuses = {k: (v or {}).get("status") for k, v in protos.items()} + bad = [f"{k}={s}" for k, s in statuses.items() + if s not in ("connect_failed", "timeout", "dispatch_error")] + if bad: + rc |= fail("expected connect_failed/timeout/dispatch_error, got " + ", ".join(bad)) + else: + rc |= ok(f"protocol statuses {statuses}") + if any(s == "ok" for s in statuses.values()): + rc |= fail("TEST-NET stub must not report status=ok") + return rc + + +if __name__ == "__main__": + sys.exit(main()) From 527428d4ec62abfd44860f65711d4eb7788d27a0 Mon Sep 17 00:00:00 2001 From: Adam Rickards Date: Sat, 29 Aug 2026 10:16:55 +0000 Subject: [PATCH 006/143] fix(harness): split inspect timeout into open_timeout_s/call_timeout_s so a working protocol never reports as failed Combined open()+call against one timeout_s was failing healthy MOPS (open ~1.46s + get_dns ~0.6s vs a 2s getter-only budget). YAML now declares per-protocol open/call budgets; close() still runs on a call-phase timeout. Fixes #28. --- scripts/ci_offline.sh | 4 + sidecar/openapi.yaml | 18 +++- tests/inspect.yaml | 22 +++-- tests/release_matrix.py | 130 +++++++++++++++++++---------- tests/test_inspect_result.py | 154 ++++++++++++++++++++++++++++++----- 5 files changed, 256 insertions(+), 72 deletions(-) diff --git a/scripts/ci_offline.sh b/scripts/ci_offline.sh index 4a87a87..4b9566b 100644 --- a/scripts/ci_offline.sh +++ b/scripts/ci_offline.sh @@ -41,6 +41,8 @@ else fi run "program-files" "$PY" scripts/generate_status.py --check +run "inspect-result" "$PY" tests/test_inspect_result.py +run "inspect-reaches-driver" "$PY" tests/test_inspect_reaches_driver.py run "principles" "$PY" scripts/check_principles.py # Catalogue proofs are the 2.10 exit. They are expected red in cycle 0 @@ -77,4 +79,6 @@ fi soft=0 "$PY" -c "from crude_engine import FeatureEngine" || soft=1 "$PY" scripts/generate_status.py --check || soft=1 +"$PY" tests/test_inspect_result.py || soft=1 +"$PY" tests/test_inspect_reaches_driver.py || soft=1 exit $soft diff --git a/sidecar/openapi.yaml b/sidecar/openapi.yaml index 3e22577..cc69809 100644 --- a/sidecar/openapi.yaml +++ b/sidecar/openapi.yaml @@ -240,12 +240,26 @@ components: elapsed_ms: type: integer minimum: 0 + description: open_ms + call_ms. Informational; not a fail bit. + open_ms: + type: integer + minimum: 0 + nullable: true + call_ms: + type: integer + minimum: 0 + nullable: true + phase: + type: string + enum: [open, call] + description: Set on timeout/connect_failed/dispatch_error. raw: {} error: type: string description: | - Per-protocol inspect outcome. Timeouts come from - tests/inspect.yaml (YAML declares, Python interprets). + Per-protocol inspect outcome. open_timeout_s and + call_timeout_s come from tests/inspect.yaml (YAML declares, + Python interprets). Combined wall time is not a failure. parity_diffs: type: array description: | diff --git a/tests/inspect.yaml b/tests/inspect.yaml index 4c82857..59bd5d2 100644 --- a/tests/inspect.yaml +++ b/tests/inspect.yaml @@ -2,18 +2,22 @@ # schemas. crude_engine does not read this file. tests/release_matrix.py # ::run_inspect and the sidecar (which calls it) do. # -# YAML declares per-protocol timeout_s. Python only interprets. Do not -# hardcode seconds in release_matrix.py. One flat timeout is wrong: -# MOPS is usually one call; SNMP is multi-turn when a method has a -# sub_table (scalar get_cmd plus bulk_cmd walk) plus per-OID fallback; -# SSH/CLI is the noisiest (prompt, pagination). +# YAML declares per-protocol open_timeout_s and call_timeout_s. Python +# only interprets. Do not hardcode seconds in release_matrix.py. +# Combined wall time is not a failure signal. A protocol that opens and +# calls inside these two budgets is status: ok. # -# These numbers are starting budgets, not a second floor. Tune here. +# Starting budgets from live get_dns (home-office). Tune here, not in Python. +# MOPS open is two TLS handshakes (~1.5s), not a bug. SNMP call needs +# headroom for sub_table + per-OID fallback. SSH open is prompt/login. protocols: mops: - timeout_s: 2 + open_timeout_s: 3 + call_timeout_s: 2 snmp: - timeout_s: 5 + open_timeout_s: 2 + call_timeout_s: 4 ssh: - timeout_s: 8 + open_timeout_s: 6 + call_timeout_s: 3 diff --git a/tests/release_matrix.py b/tests/release_matrix.py index 61bce91..448b78e 100644 --- a/tests/release_matrix.py +++ b/tests/release_matrix.py @@ -2232,15 +2232,15 @@ def _load_inspect_yaml() -> dict: return yaml.safe_load(f) or {} -def _inspect_timeout_s(protocol: str) -> float: - """timeout_s for one protocol from tests/inspect.yaml. No Python defaults.""" +def _inspect_budget_s(protocol: str, key: str) -> float: + """One budget from tests/inspect.yaml. key is open_timeout_s or call_timeout_s.""" data = _load_inspect_yaml() entry = ((data.get("protocols") or {}).get(protocol) or {}) - if "timeout_s" not in entry: + if key not in entry: raise KeyError( - f"tests/inspect.yaml protocols.{protocol}.timeout_s is not declared" + f"tests/inspect.yaml protocols.{protocol}.{key} is not declared" ) - return float(entry["timeout_s"]) + return float(entry[key]) def _call_with_timeout(seconds: float, fn, *args, **kwargs): @@ -2284,7 +2284,7 @@ def run_inspect(method_name: str | None, "exit": 0|2, "method": ..., "device": ..., - "protocols": {proto: {status, elapsed_ms, raw|error}}, + "protocols": {proto: {status, open_ms, call_ms, elapsed_ms, raw|error}}, "parity_diffs": [...], } @@ -2356,31 +2356,35 @@ def usage(msg: str) -> dict: if no_validate: call_kwargs["validate"] = False - def _one_protocol(proto: str): + def _close_quiet(dev): + if dev is None: + return + try: + dev.close() + except Exception: + pass + + def _open_proto(proto: str): device = driver(device_filter, username, password, optional_args={"protocol": proto}) device.open() - last_trace = None + return device + + def _call_proto(dev): + fn = getattr(dev, method_name) try: - fn = getattr(device, method_name) - try: - raw = fn(**call_kwargs) - except TypeError: - raw = fn() - if trace: - last_trace = getattr(device, "last_trace", None) - if isinstance(raw, tuple): - raw = raw[0] - return raw, last_trace - finally: - try: - device.close() - except Exception: - pass + raw = fn(**call_kwargs) + except TypeError: + raw = fn() + last_trace = getattr(dev, "last_trace", None) if trace else None + if isinstance(raw, tuple): + raw = raw[0] + return raw, last_trace for proto in protocols: try: - budget = _inspect_timeout_s(proto) + open_s = _inspect_budget_s(proto, "open_timeout_s") + call_s = _inspect_budget_s(proto, "call_timeout_s") except (KeyError, FileNotFoundError, ValueError, TypeError) as e: print(f"--- {proto} ---") print(f" UNDECLARED TIMEOUT: {e}") @@ -2388,42 +2392,84 @@ def _one_protocol(proto: str): protocols_out[proto] = { "status": "dispatch_error", "elapsed_ms": 0, + "open_ms": None, + "call_ms": None, "error": str(e), } continue - print(f"--- {proto} (timeout {budget}s) ---") - t0 = time.monotonic() + print(f"--- {proto} (open {open_s}s / call {call_s}s) ---") + device = None + open_ms = None + call_ms = None + t_open = time.monotonic() try: - raw, last_trace = _call_with_timeout(budget, _one_protocol, proto) + device = _call_with_timeout(open_s, _open_proto, proto) except TimeoutError as e: - elapsed_ms = round((time.monotonic() - t0) * 1000) - print(f" TIMEOUT: {e}") + open_ms = round((time.monotonic() - t_open) * 1000) + print(f" OPEN TIMEOUT: {e} open_ms={open_ms}") print() protocols_out[proto] = { "status": "timeout", - "elapsed_ms": elapsed_ms, + "phase": "open", + "elapsed_ms": open_ms, + "open_ms": open_ms, + "call_ms": None, "error": str(e), } continue except Exception as e: - elapsed_ms = round((time.monotonic() - t0) * 1000) + open_ms = round((time.monotonic() - t_open) * 1000) msg = str(e)[:300] - # open() failures vs dispatch: both are per-protocol signal - status = "connect_failed" if "CONNECT" in msg.upper() or elapsed_ms < 50 else "dispatch_error" - # Prefer connect_failed when open() raised before a getter exists - if "open" in msg.lower() or elapsed_ms == 0: - status = "connect_failed" - print(f" {status.upper()}: {msg[:200]}") + print(f" CONNECT_FAILED: {msg[:200]}") print() + _close_quiet(device) protocols_out[proto] = { - "status": status, - "elapsed_ms": elapsed_ms, + "status": "connect_failed", + "phase": "open", + "elapsed_ms": open_ms, + "open_ms": open_ms, + "call_ms": None, "error": msg, } continue + open_ms = round((time.monotonic() - t_open) * 1000) - elapsed_ms = round((time.monotonic() - t0) * 1000) - print(f" time_ms={elapsed_ms}") + t_call = time.monotonic() + try: + raw, last_trace = _call_with_timeout(call_s, _call_proto, device) + except TimeoutError as e: + call_ms = round((time.monotonic() - t_call) * 1000) + print(f" CALL TIMEOUT: {e} open_ms={open_ms} call_ms={call_ms}") + print() + _close_quiet(device) + protocols_out[proto] = { + "status": "timeout", + "phase": "call", + "elapsed_ms": (open_ms or 0) + call_ms, + "open_ms": open_ms, + "call_ms": call_ms, + "error": str(e), + } + continue + except Exception as e: + call_ms = round((time.monotonic() - t_call) * 1000) + msg = str(e)[:300] + print(f" DISPATCH_ERROR: {msg[:200]}") + print() + _close_quiet(device) + protocols_out[proto] = { + "status": "dispatch_error", + "phase": "call", + "elapsed_ms": (open_ms or 0) + call_ms, + "open_ms": open_ms, + "call_ms": call_ms, + "error": msg, + } + continue + call_ms = round((time.monotonic() - t_call) * 1000) + _close_quiet(device) + elapsed_ms = (open_ms or 0) + call_ms + print(f" open_ms={open_ms} call_ms={call_ms} elapsed_ms={elapsed_ms}") if isinstance(raw, dict): print(f" raw type=dict len={len(raw)}") @@ -2469,6 +2515,8 @@ def _one_protocol(proto: str): protocols_out[proto] = { "status": "ok", "elapsed_ms": elapsed_ms, + "open_ms": open_ms, + "call_ms": call_ms, "raw": _jsonable(raw), } diff --git a/tests/test_inspect_result.py b/tests/test_inspect_result.py index 47110a6..443cde5 100755 --- a/tests/test_inspect_result.py +++ b/tests/test_inspect_result.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Offline proofs for issue 24. No device. YAML declares inspect timeouts.""" +"""Offline proofs for inspect budgets. No device. YAML declares open/call.""" from __future__ import annotations import sys @@ -10,11 +10,7 @@ sys.path.insert(0, str(ROOT / "tests")) sys.path.insert(0, str(ROOT)) -from release_matrix import ( # noqa: E402 - _call_with_timeout, - _inspect_timeout_s, - _load_inspect_yaml, -) +import release_matrix as rm # noqa: E402 from sidecar.app import shape_inspect # noqa: E402 @@ -30,23 +26,38 @@ def ok(msg): def main() -> int: rc = 0 - data = _load_inspect_yaml() + data = rm._load_inspect_yaml() protos = data.get("protocols") or {} for name in ("mops", "snmp", "ssh"): - if "timeout_s" not in (protos.get(name) or {}): - rc |= fail(f"inspect.yaml missing protocols.{name}.timeout_s") - else: - rc |= ok(f"inspect.yaml protocols.{name}.timeout_s={_inspect_timeout_s(name)}") - - mops, snmp, ssh = (_inspect_timeout_s(p) for p in ("mops", "snmp", "ssh")) - if not (mops < snmp <= ssh): - rc |= fail(f"budgets should be mops < snmp <= ssh, got {mops} {snmp} {ssh}") + entry = protos.get(name) or {} + missing = [k for k in ("open_timeout_s", "call_timeout_s") if k not in entry] + if missing: + rc |= fail(f"inspect.yaml missing protocols.{name}.{missing}") + continue + if "timeout_s" in entry: + rc |= fail(f"inspect.yaml protocols.{name} still has combined timeout_s") + continue + open_s = rm._inspect_budget_s(name, "open_timeout_s") + call_s = rm._inspect_budget_s(name, "call_timeout_s") + rc |= ok(f"inspect.yaml {name} open={open_s}s call={call_s}s") + + # Live get_dns measurement that the old combined 2s budget failed: + # open 1459ms + call 596ms = 2055ms. Split budgets must cover that. + mops_open = rm._inspect_budget_s("mops", "open_timeout_s") + mops_call = rm._inspect_budget_s("mops", "call_timeout_s") + if mops_open * 1000 <= 1459 or mops_call * 1000 <= 596: + rc |= fail( + f"MOPS budgets {mops_open}/{mops_call}s would still fail measured " + "open 1459ms / call 596ms" + ) + elif (1459 + 596) <= 2000: + rc |= fail("fixture numbers no longer exceed the old 2s combined budget") else: - rc |= ok("MOPS tighter than SNMP, SSH slackest") + rc |= ok("measured MOPS 1459+596ms exceeds old 2s combined, fits split budgets") t0 = time.monotonic() try: - _call_with_timeout(0.2, time.sleep, 5) + rm._call_with_timeout(0.2, time.sleep, 5) rc |= fail("timeout helper did not raise") except TimeoutError: elapsed = time.monotonic() - t0 @@ -58,8 +69,20 @@ def main() -> int: disagreed = { "exit": 0, "protocols": { - "mops": {"status": "ok", "elapsed_ms": 10, "raw": {"servers": {"1": {}}}}, - "ssh": {"status": "ok", "elapsed_ms": 20, "raw": {"servers": {"0": {}}}}, + "mops": { + "status": "ok", + "elapsed_ms": 10, + "open_ms": 7, + "call_ms": 3, + "raw": {"servers": {"1": {}}}, + }, + "ssh": { + "status": "ok", + "elapsed_ms": 20, + "open_ms": 15, + "call_ms": 5, + "raw": {"servers": {"0": {}}}, + }, }, "parity_diffs": ["servers.mops-only rows: ['1']"], } @@ -75,7 +98,14 @@ def main() -> int: dead = { "exit": 0, "protocols": { - "snmp": {"status": "timeout", "elapsed_ms": 5000, "error": "exceeded 5.0s"}, + "snmp": { + "status": "timeout", + "elapsed_ms": 4000, + "open_ms": 200, + "call_ms": 3800, + "phase": "call", + "error": "exceeded 4.0s", + }, }, "parity_diffs": [], } @@ -91,6 +121,90 @@ def main() -> int: else: rc |= ok("fake transport passed true") + rc |= _split_phases() + return rc + + +def _split_phases() -> int: + """open+call that would blow a combined 0.25s budget still reports ok.""" + rc = 0 + + class _Healthy: + closed = 0 + + def __init__(self, *args, **kwargs): + pass + + def open(self): + time.sleep(0.18) + + def get_dns(self, **kwargs): + time.sleep(0.10) + return {"enabled": True, "servers": {"1": {"address": "1.1.1.1"}}} + + def close(self): + type(self).closed += 1 + + class _HangCall: + closed = 0 + + def __init__(self, *args, **kwargs): + pass + + def open(self): + pass + + def get_dns(self, **kwargs): + time.sleep(8) + + def close(self): + type(self).closed += 1 + + orig_driver = rm.get_network_driver + orig_budget = rm._inspect_budget_s + try: + rm.get_network_driver = lambda _name: _Healthy + + def _tiny(proto, key): + if proto != "mops": + return 0.05 + return 0.35 if key == "open_timeout_s" else 0.25 + + rm._inspect_budget_s = _tiny + out = rm.run_inspect("get_dns", "192.0.2.10", "mops") + proto = (out.get("protocols") or {}).get("mops") or {} + if proto.get("status") != "ok": + rc |= fail(f"healthy split should be ok, got {proto}") + elif proto.get("open_ms") is None or proto.get("call_ms") is None: + rc |= fail(f"ok result missing open_ms/call_ms: {proto}") + elif _Healthy.closed < 1: + rc |= fail("healthy path did not close()") + else: + rc |= ok( + f"split ok open_ms={proto.get('open_ms')} " + f"call_ms={proto.get('call_ms')} " + f"(combined would miss 0.25s)" + ) + + rm.get_network_driver = lambda _name: _HangCall + + def _call_tight(proto, key): + if key == "open_timeout_s": + return 1.0 + return 0.2 + + rm._inspect_budget_s = _call_tight + out = rm.run_inspect("get_dns", "192.0.2.10", "mops") + proto = (out.get("protocols") or {}).get("mops") or {} + if proto.get("status") != "timeout" or proto.get("phase") != "call": + rc |= fail(f"hung call should be timeout phase=call, got {proto}") + elif _HangCall.closed < 1: + rc |= fail("call-phase timeout did not close()") + else: + rc |= ok("call-phase timeout still runs close()") + finally: + rm.get_network_driver = orig_driver + rm._inspect_budget_s = orig_budget return rc From de1f83040f1f26cf407cd0511e094a4ee9c94235 Mon Sep 17 00:00:00 2001 From: Adam Rickards Date: Sat, 29 Aug 2026 10:43:29 +0000 Subject: [PATCH 007/143] refactor(harness): fan out inspect protocols as independent workers Each protocol owns open/call/close on one thread (NILS discover_one pattern). Overall wait is max(open+call), not sequential sum, and the caller never close()s another thread's device. Fixes #32. Keeps #28 YAML open_timeout_s/call_timeout_s and open_ms/call_ms. --- tests/release_matrix.py | 344 ++++++++++++++++++++--------------- tests/test_inspect_result.py | 113 +++++++++--- 2 files changed, 282 insertions(+), 175 deletions(-) diff --git a/tests/release_matrix.py b/tests/release_matrix.py index 448b78e..fe9b30e 100644 --- a/tests/release_matrix.py +++ b/tests/release_matrix.py @@ -2246,8 +2246,8 @@ def _inspect_budget_s(protocol: str, key: str) -> float: def _call_with_timeout(seconds: float, fn, *args, **kwargs): """Run fn, raise TimeoutError after seconds. - Do not wait for the worker on timeout: a hung device.open() must not - hold the sidecar RunLock. The worker thread may outlive the caller. + Kept for offline proofs. Live inspect does not abandon a worker + mid-call; each protocol owns open/call/close on one thread. """ pool = concurrent.futures.ThreadPoolExecutor(max_workers=1) fut = pool.submit(fn, *args, **kwargs) @@ -2259,6 +2259,163 @@ def _call_with_timeout(seconds: float, fn, *args, **kwargs): pool.shutdown(wait=False) +def _inspect_one_protocol( + driver, + proto: str, + device_filter: str, + username: str, + password: str, + method_name: str, + call_kwargs: dict, + trace: bool, +): + """One thread, one device, full lifecycle. Never raises. + + Hang never returns; the caller wait() marks timeout without + touching this device. close() only runs in this thread. + """ + t_open = time.monotonic() + try: + _inspect_budget_s(proto, "open_timeout_s") + _inspect_budget_s(proto, "call_timeout_s") + except (KeyError, FileNotFoundError, ValueError, TypeError) as e: + return proto, { + "status": "dispatch_error", + "elapsed_ms": 0, + "open_ms": None, + "call_ms": None, + "error": str(e), + } + + device = None + try: + try: + device = driver( + device_filter, + username, + password, + optional_args={"protocol": proto}, + ) + device.open() + except Exception as e: + open_ms = round((time.monotonic() - t_open) * 1000) + return proto, { + "status": "connect_failed", + "phase": "open", + "elapsed_ms": open_ms, + "open_ms": open_ms, + "call_ms": None, + "error": str(e)[:300], + } + open_ms = round((time.monotonic() - t_open) * 1000) + + t_call = time.monotonic() + try: + fn = getattr(device, method_name) + try: + raw = fn(**call_kwargs) + except TypeError: + raw = fn() + last_trace = getattr(device, "last_trace", None) if trace else None + if isinstance(raw, tuple): + raw = raw[0] + call_ms = round((time.monotonic() - t_call) * 1000) + out = { + "status": "ok", + "elapsed_ms": open_ms + call_ms, + "open_ms": open_ms, + "call_ms": call_ms, + "raw": _jsonable(raw), + } + if trace: + out["trace"] = last_trace + return proto, out + except Exception as e: + call_ms = round((time.monotonic() - t_call) * 1000) + return proto, { + "status": "dispatch_error", + "phase": "call", + "elapsed_ms": open_ms + call_ms, + "open_ms": open_ms, + "call_ms": call_ms, + "error": str(e)[:300], + } + finally: + if device is not None: + try: + device.close() + except Exception: + pass + + +def _print_inspect_protocol(proto: str, result: dict, trace: bool) -> None: + open_s = None + call_s = None + try: + open_s = _inspect_budget_s(proto, "open_timeout_s") + call_s = _inspect_budget_s(proto, "call_timeout_s") + except (KeyError, FileNotFoundError, ValueError, TypeError): + pass + if open_s is not None: + print(f"--- {proto} (open {open_s}s / call {call_s}s) ---") + else: + print(f"--- {proto} ---") + status = result.get("status") + if status == "ok": + print( + f" open_ms={result.get('open_ms')} " + f"call_ms={result.get('call_ms')} " + f"elapsed_ms={result.get('elapsed_ms')}" + ) + raw = result.get("raw") + if isinstance(raw, dict): + print(f" raw type=dict len={len(raw)}") + if raw: + first_key = next(iter(raw)) + first_val = raw[first_key] + print(f" first key: {first_key!r}") + if isinstance(first_val, dict): + print(" first row:") + for k, v in sorted(first_val.items()): + print(f" {k}: {repr(v)[:80]}") + else: + print(f" first val: {repr(first_val)[:120]}") + elif isinstance(raw, list): + print(f" raw type=list len={len(raw)}") + if raw: + print(f" first item: {repr(raw[0])[:200]}") + else: + print(f" raw: {repr(raw)[:200]}") + last_trace = result.get("trace") + if trace and last_trace: + print(f" trace ({len(last_trace)} entries):") + for entry in last_trace: + if isinstance(entry, dict): + parts = [] + for k in sorted(entry.keys()): + v = entry[k] + sval = repr(v) if not isinstance(v, str) else v + if len(sval) > 60: + sval = sval[:57] + "..." + parts.append(f"{k}={sval}") + print(f" {' '.join(parts)}") + else: + print(f" {repr(entry)[:200]}") + elif trace: + print(" trace: (empty — device.last_trace was None)") + elif status == "timeout": + print( + f" TIMEOUT: {result.get('error')} " + f"open_ms={result.get('open_ms')} call_ms={result.get('call_ms')}" + ) + elif status == "connect_failed": + print(f" CONNECT_FAILED: {(result.get('error') or '')[:200]}") + else: + err = result.get("error") or "" + print(f" {str(status).upper()}: {err[:200]}") + print() + + def _jsonable(obj): try: return json.loads(json.dumps(obj, default=str)) @@ -2356,39 +2513,13 @@ def usage(msg: str) -> dict: if no_validate: call_kwargs["validate"] = False - def _close_quiet(dev): - if dev is None: - return - try: - dev.close() - except Exception: - pass - - def _open_proto(proto: str): - device = driver(device_filter, username, password, - optional_args={"protocol": proto}) - device.open() - return device - - def _call_proto(dev): - fn = getattr(dev, method_name) - try: - raw = fn(**call_kwargs) - except TypeError: - raw = fn() - last_trace = getattr(dev, "last_trace", None) if trace else None - if isinstance(raw, tuple): - raw = raw[0] - return raw, last_trace - + to_run = [] + overall_s = 0.0 for proto in protocols: try: open_s = _inspect_budget_s(proto, "open_timeout_s") call_s = _inspect_budget_s(proto, "call_timeout_s") except (KeyError, FileNotFoundError, ValueError, TypeError) as e: - print(f"--- {proto} ---") - print(f" UNDECLARED TIMEOUT: {e}") - print() protocols_out[proto] = { "status": "dispatch_error", "elapsed_ms": 0, @@ -2397,129 +2528,50 @@ def _call_proto(dev): "error": str(e), } continue - print(f"--- {proto} (open {open_s}s / call {call_s}s) ---") - device = None - open_ms = None - call_ms = None - t_open = time.monotonic() - try: - device = _call_with_timeout(open_s, _open_proto, proto) - except TimeoutError as e: - open_ms = round((time.monotonic() - t_open) * 1000) - print(f" OPEN TIMEOUT: {e} open_ms={open_ms}") - print() - protocols_out[proto] = { - "status": "timeout", - "phase": "open", - "elapsed_ms": open_ms, - "open_ms": open_ms, - "call_ms": None, - "error": str(e), - } - continue - except Exception as e: - open_ms = round((time.monotonic() - t_open) * 1000) - msg = str(e)[:300] - print(f" CONNECT_FAILED: {msg[:200]}") - print() - _close_quiet(device) - protocols_out[proto] = { - "status": "connect_failed", - "phase": "open", - "elapsed_ms": open_ms, - "open_ms": open_ms, - "call_ms": None, - "error": msg, - } - continue - open_ms = round((time.monotonic() - t_open) * 1000) + to_run.append(proto) + overall_s = max(overall_s, open_s + call_s) - t_call = time.monotonic() - try: - raw, last_trace = _call_with_timeout(call_s, _call_proto, device) - except TimeoutError as e: - call_ms = round((time.monotonic() - t_call) * 1000) - print(f" CALL TIMEOUT: {e} open_ms={open_ms} call_ms={call_ms}") - print() - _close_quiet(device) + pool = concurrent.futures.ThreadPoolExecutor(max_workers=max(1, len(to_run))) + futures: dict = {} + try: + for proto in to_run: + fut = pool.submit( + _inspect_one_protocol, + driver, + proto, + device_filter, + username, + password, + method_name, + call_kwargs, + trace, + ) + futures[fut] = proto + done, not_done = concurrent.futures.wait(futures, timeout=overall_s) + for fut in done: + proto, result = fut.result() + protocols_out[proto] = result + for fut in not_done: + proto = futures[fut] protocols_out[proto] = { "status": "timeout", - "phase": "call", - "elapsed_ms": (open_ms or 0) + call_ms, - "open_ms": open_ms, - "call_ms": call_ms, - "error": str(e), - } - continue - except Exception as e: - call_ms = round((time.monotonic() - t_call) * 1000) - msg = str(e)[:300] - print(f" DISPATCH_ERROR: {msg[:200]}") - print() - _close_quiet(device) - protocols_out[proto] = { - "status": "dispatch_error", - "phase": "call", - "elapsed_ms": (open_ms or 0) + call_ms, - "open_ms": open_ms, - "call_ms": call_ms, - "error": msg, + "elapsed_ms": round(overall_s * 1000), + "open_ms": None, + "call_ms": None, + "error": "overall deadline exceeded", } - continue - call_ms = round((time.monotonic() - t_call) * 1000) - _close_quiet(device) - elapsed_ms = (open_ms or 0) + call_ms - print(f" open_ms={open_ms} call_ms={call_ms} elapsed_ms={elapsed_ms}") - - if isinstance(raw, dict): - print(f" raw type=dict len={len(raw)}") - if raw: - first_key = next(iter(raw)) - first_val = raw[first_key] - print(f" first key: {first_key!r}") - if isinstance(first_val, dict): - print(f" first row:") - for k, v in sorted(first_val.items()): - print(f" {k}: {repr(v)[:80]}") - else: - print(f" first val: {repr(first_val)[:120]}") - elif isinstance(raw, list): - print(f" raw type=list len={len(raw)}") - if raw: - print(f" first item: {repr(raw[0])[:200]}") - else: - print(f" raw: {repr(raw)[:200]}") + finally: + # Hung workers stay isolated. Waiting would hold sidecar RunLock. + # Do not close() their device from this thread. + pool.shutdown(wait=False) - if raw is not None: + for proto in protocols: + result = protocols_out.get(proto) or {} + _print_inspect_protocol(proto, result, trace) + raw = result.get("raw") + if result.get("status") == "ok" and raw is not None: raw_results[proto] = raw - if trace and last_trace: - print(f" trace ({len(last_trace)} entries):") - for entry in last_trace: - if isinstance(entry, dict): - keys = sorted(entry.keys()) - parts = [] - for k in keys: - v = entry[k] - sval = repr(v) if not isinstance(v, str) else v - if len(sval) > 60: - sval = sval[:57] + "..." - parts.append(f"{k}={sval}") - print(f" {' '.join(parts)}") - else: - print(f" {repr(entry)[:200]}") - elif trace: - print(f" trace: (empty — device.last_trace was None)") - - print() - protocols_out[proto] = { - "status": "ok", - "elapsed_ms": elapsed_ms, - "open_ms": open_ms, - "call_ms": call_ms, - "raw": _jsonable(raw), - } - diffs: list = [] if len(raw_results) >= 2: print("=== parity ===") diff --git a/tests/test_inspect_result.py b/tests/test_inspect_result.py index 443cde5..944ade9 100755 --- a/tests/test_inspect_result.py +++ b/tests/test_inspect_result.py @@ -1,8 +1,9 @@ #!/usr/bin/env python3 -"""Offline proofs for inspect budgets. No device. YAML declares open/call.""" +"""Offline proofs for inspect budgets and protocol fan-out. No device.""" from __future__ import annotations import sys +import threading import time from pathlib import Path @@ -41,8 +42,6 @@ def main() -> int: call_s = rm._inspect_budget_s(name, "call_timeout_s") rc |= ok(f"inspect.yaml {name} open={open_s}s call={call_s}s") - # Live get_dns measurement that the old combined 2s budget failed: - # open 1459ms + call 596ms = 2055ms. Split budgets must cover that. mops_open = rm._inspect_budget_s("mops", "open_timeout_s") mops_call = rm._inspect_budget_s("mops", "call_timeout_s") if mops_open * 1000 <= 1459 or mops_call * 1000 <= 596: @@ -101,10 +100,9 @@ def main() -> int: "snmp": { "status": "timeout", "elapsed_ms": 4000, - "open_ms": 200, - "call_ms": 3800, - "phase": "call", - "error": "exceeded 4.0s", + "open_ms": None, + "call_ms": None, + "error": "overall deadline exceeded", }, }, "parity_diffs": [], @@ -121,13 +119,14 @@ def main() -> int: else: rc |= ok("fake transport passed true") - rc |= _split_phases() + rc |= _fanout() return rc -def _split_phases() -> int: - """open+call that would blow a combined 0.25s budget still reports ok.""" +def _fanout() -> int: rc = 0 + orig_driver = rm.get_network_driver + orig_budget = rm._inspect_budget_s class _Healthy: closed = 0 @@ -145,23 +144,40 @@ def get_dns(self, **kwargs): def close(self): type(self).closed += 1 - class _HangCall: - closed = 0 + class _Parallel: + opens: dict = {} + closes: dict = {} def __init__(self, *args, **kwargs): - pass + self.proto = (kwargs.get("optional_args") or {}).get("protocol") + + def open(self): + type(self).opens[self.proto] = threading.get_ident() + time.sleep(0.25) + + def get_dns(self, **kwargs): + return {"enabled": True, "servers": {"1": {}}} + + def close(self): + type(self).closes[self.proto] = threading.get_ident() + + class _HangMops: + closed: dict = {} + + def __init__(self, *args, **kwargs): + self.proto = (kwargs.get("optional_args") or {}).get("protocol") def open(self): pass def get_dns(self, **kwargs): - time.sleep(8) + if self.proto == "mops": + time.sleep(2) + return {"enabled": True, "servers": {"1": {}}} def close(self): - type(self).closed += 1 + type(self).closed[self.proto] = threading.get_ident() - orig_driver = rm.get_network_driver - orig_budget = rm._inspect_budget_s try: rm.get_network_driver = lambda _name: _Healthy @@ -186,22 +202,61 @@ def _tiny(proto, key): f"(combined would miss 0.25s)" ) - rm.get_network_driver = lambda _name: _HangCall + _Parallel.opens = {} + _Parallel.closes = {} + rm.get_network_driver = lambda _name: _Parallel + + def _wide(proto, key): + return 1.0 + + rm._inspect_budget_s = _wide + t0 = time.monotonic() + out = rm.run_inspect("get_dns", "192.0.2.10", None) + wall = time.monotonic() - t0 + statuses = { + k: (v or {}).get("status") + for k, v in (out.get("protocols") or {}).items() + } + if wall > 0.60: + rc |= fail(f"fan-out wall {wall:.2f}s looks sequential (want ~max 0.25s)") + elif any(s != "ok" for s in statuses.values()): + rc |= fail(f"fan-out statuses {statuses}") + elif set(_Parallel.opens) != set(_Parallel.closes): + rc |= fail(f"open/close proto mismatch {_Parallel.opens} {_Parallel.closes}") + elif any(_Parallel.opens[p] != _Parallel.closes[p] for p in _Parallel.opens): + rc |= fail( + f"close() on a different thread than open(): " + f"open={_Parallel.opens} close={_Parallel.closes}" + ) + else: + rc |= ok(f"fan-out wall {wall:.2f}s (max not sum), owner-thread close()") - def _call_tight(proto, key): - if key == "open_timeout_s": - return 1.0 + _HangMops.closed = {} + rm.get_network_driver = lambda _name: _HangMops + + def _short(proto, key): return 0.2 - rm._inspect_budget_s = _call_tight - out = rm.run_inspect("get_dns", "192.0.2.10", "mops") - proto = (out.get("protocols") or {}).get("mops") or {} - if proto.get("status") != "timeout" or proto.get("phase") != "call": - rc |= fail(f"hung call should be timeout phase=call, got {proto}") - elif _HangCall.closed < 1: - rc |= fail("call-phase timeout did not close()") + rm._inspect_budget_s = _short + t0 = time.monotonic() + out = rm.run_inspect("get_dns", "192.0.2.10", None) + wall = time.monotonic() - t0 + protos = out.get("protocols") or {} + mops = protos.get("mops") or {} + others = {k: (v or {}).get("status") for k, v in protos.items() if k != "mops"} + if mops.get("status") != "timeout": + rc |= fail(f"hung mops should be overall timeout, got {mops}") + elif wall > 1.0: + rc |= fail(f"overall wait hung {wall:.2f}s") + elif any(s != "ok" for s in others.values()): + rc |= fail(f"siblings should be ok, got {others}") + elif "mops" in _HangMops.closed: + rc |= fail("caller must not close() a hung worker's device") else: - rc |= ok("call-phase timeout still runs close()") + rc |= ok( + f"hung sibling isolated ({wall:.2f}s); others {others}; " + "no cross-thread close" + ) finally: rm.get_network_driver = orig_driver rm._inspect_budget_s = orig_budget From b8f22c2ae86a96d2c3c2b092db3d26dc7b4ecf9e Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 29 Aug 2026 11:26:54 +0000 Subject: [PATCH 008/143] feat(sidecar): optional trace copies SSH show text onto inspect result Fixes #34. POST /v1/run {name, trace:true} returns protocols.ssh.cli so #33 can be judged from the table parser's input. Default polls stay compact. Extra keys still 400. --- crude_engine/drivers/ssh_driver.py | 12 ++++++++++ sidecar/app.py | 10 ++++---- sidecar/openapi.yaml | 22 ++++++++++++++++++ tests/release_matrix.py | 25 ++++++++++++++++++++ tests/test_inspect_result.py | 37 ++++++++++++++++++++++++++++++ tests/test_sidecar_readonly.py | 15 ++++++++++++ 6 files changed, 117 insertions(+), 4 deletions(-) diff --git a/crude_engine/drivers/ssh_driver.py b/crude_engine/drivers/ssh_driver.py index a305873..ae5afa7 100644 --- a/crude_engine/drivers/ssh_driver.py +++ b/crude_engine/drivers/ssh_driver.py @@ -84,6 +84,18 @@ def gather(self, sources: List[Tuple[str, Dict]], logger.debug("SSH command failed: %s — %s", cmd, str(e)[:80]) ssh_cache[(cmd, level)] = "" + # Keep the command text for inspect/sidecar trace. Does not change + # parse or what was sent. Cap each blob so a poll body stays usable. + _cli = [] + for (cmd, level), resp in ssh_cache.items(): + text = resp if isinstance(resp, str) else str(resp) + if len(text) > 32768: + text = text[:32768] + "\n…truncated" + _cli.append({"command": cmd, "level": level, "response": text}) + self.last_cli = _cli + if getattr(self, "transport", None) is not None: + self.transport.last_cli = _cli + # Phase 3: Each attribute parses from cached response for name, source in sources: if source.get("iterate_from"): diff --git a/sidecar/app.py b/sidecar/app.py index c27fd9b..fcbfe89 100644 --- a/sidecar/app.py +++ b/sidecar/app.py @@ -143,7 +143,7 @@ def shape_inspect(name, inspect_out): } -def call_inspect(method, device, protocol=None): +def call_inspect(method, device, protocol=None, trace=False): """Call tests/release_matrix.py::run_inspect. Fake transport does not.""" if transport() in ("fake", "offline"): return { @@ -165,6 +165,7 @@ def call_inspect(method, device, protocol=None): method, device, protocol, + trace=bool(trace), username=user, password=password, ) @@ -172,9 +173,10 @@ def call_inspect(method, device, protocol=None): def handle_run(payload): """Return (status_code, body). Refuses non-reads before inspect.""" - if not isinstance(payload, dict) or set(payload.keys()) - {"name"}: - return 400, {"error": "bad_name", "message": 'body must be {"name": ...}'} + if not isinstance(payload, dict) or set(payload.keys()) - {"name", "trace"}: + return 400, {"error": "bad_name", "message": 'body must be {"name": ...} or {"name": ..., "trace": true}'} name = payload.get("name") + trace = bool(payload.get("trace")) if not well_formed(name): return 400, {"error": "bad_name", "message": "not a catalog test name", "name": name} @@ -211,7 +213,7 @@ def handle_run(payload): if not LOCK.acquire(): return 409, {"error": "lock_held", "message": "lab lock is held", "name": name} try: - out = call_inspect(method, device, protocol) + out = call_inspect(method, device, protocol, trace=trace) if isinstance(out, dict) and out.get("exit") == 2: return 503, { "error": "not_ready", diff --git a/sidecar/openapi.yaml b/sidecar/openapi.yaml index cc69809..5e8f6ae 100644 --- a/sidecar/openapi.yaml +++ b/sidecar/openapi.yaml @@ -183,6 +183,12 @@ components: Generated catalog entry name. Examples (not an enum): `get_dns.read`, `set_dns.roundtrip`, `dns.lifecycle.mops`. example: get_dns.read + trace: + type: boolean + description: | + Opt-in. When true, inspect copies SSH command/response + text onto each protocol result as `cli`. Default polls + omit this. Does not change parse or what is sent. RunResponse: type: object additionalProperties: false @@ -256,10 +262,26 @@ components: raw: {} error: type: string + cli: + type: array + description: | + Present when request.trace is true. SSH: command + + response text the table parser saw. Not a parse change. + items: + type: object + properties: + command: + type: string + level: + type: string + response: + type: string + trace: {} description: | Per-protocol inspect outcome. open_timeout_s and call_timeout_s come from tests/inspect.yaml (YAML declares, Python interprets). Combined wall time is not a failure. + Opt-in `cli` is the raw SSH show text for debug. parity_diffs: type: array description: | diff --git a/tests/release_matrix.py b/tests/release_matrix.py index fe9b30e..500c090 100644 --- a/tests/release_matrix.py +++ b/tests/release_matrix.py @@ -2259,6 +2259,28 @@ def _call_with_timeout(seconds: float, fn, *args, **kwargs): pool.shutdown(wait=False) + +def _collect_cli(device): + """Raw CLI blobs stashed by SSH gather (transport/driver/device).""" + found = [] + seen = set() + objs = [device, getattr(device, "engine", None)] + transports = getattr(device, "_transports", None) + if isinstance(transports, dict): + objs.extend(transports.values()) + elif transports: + objs.append(transports) + for obj in objs: + if obj is None: + continue + cli = getattr(obj, "last_cli", None) + if not cli or id(cli) in seen: + continue + seen.add(id(cli)) + found.extend(cli) + return found + + def _inspect_one_protocol( driver, proto: str, @@ -2317,6 +2339,7 @@ def _inspect_one_protocol( except TypeError: raw = fn() last_trace = getattr(device, "last_trace", None) if trace else None + last_cli = _collect_cli(device) if trace else [] if isinstance(raw, tuple): raw = raw[0] call_ms = round((time.monotonic() - t_call) * 1000) @@ -2329,6 +2352,8 @@ def _inspect_one_protocol( } if trace: out["trace"] = last_trace + if last_cli: + out["cli"] = last_cli return proto, out except Exception as e: call_ms = round((time.monotonic() - t_call) * 1000) diff --git a/tests/test_inspect_result.py b/tests/test_inspect_result.py index 944ade9..db213f6 100755 --- a/tests/test_inspect_result.py +++ b/tests/test_inspect_result.py @@ -257,6 +257,43 @@ def _short(proto, key): f"hung sibling isolated ({wall:.2f}s); others {others}; " "no cross-thread close" ) + + class _WithCli: + def __init__(self, *args, **kwargs): + blob = [ + { + "command": "show dns client servers", + "level": "enable", + "response": "Index Address\n0 1.1.1.1", + } + ] + self._transports = {"ssh": type("T", (), {"last_cli": blob})()} + + def open(self): + pass + + def get_dns(self, **kwargs): + return {"enabled": True, "servers": {"0": {"address": "1.1.1.1"}}} + + def close(self): + pass + + rm.get_network_driver = lambda _name: _WithCli + rm._inspect_budget_s = lambda proto, key: 1.0 + quiet = rm.run_inspect("get_dns", "192.0.2.10", "ssh") + ssh = (quiet.get("protocols") or {}).get("ssh") or {} + if "cli" in ssh: + rc |= fail(f"cli present without trace: {ssh}") + else: + rc |= ok("no cli on default inspect") + traced = rm.run_inspect("get_dns", "192.0.2.10", "ssh", trace=True) + ssh = (traced.get("protocols") or {}).get("ssh") or {} + cli = ssh.get("cli") or [] + cmds = [c.get("command") for c in cli] + if "show dns client servers" not in cmds: + rc |= fail(f"trace last_cli missing show: {cli}") + else: + rc |= ok("trace last_cli round-trips show dns client servers") finally: rm.get_network_driver = orig_driver rm._inspect_budget_s = orig_budget diff --git a/tests/test_sidecar_readonly.py b/tests/test_sidecar_readonly.py index a92c1fa..3bd1d6b 100755 --- a/tests/test_sidecar_readonly.py +++ b/tests/test_sidecar_readonly.py @@ -78,6 +78,21 @@ def main(): else: errors += ok("POST set_dns.roundtrip → 400 bad_name (inspect not called)") + code, body = handle_run({"name": "get_dns.read", "device": "192.0.2.10"}) + if code != 400 or body.get("error") != "bad_name": + errors += fail(f"extra key → {code} {body}") + else: + errors += ok("extra POST key → 400 bad_name") + + code, body = handle_run({"name": "get_dns.read", "trace": True}) + result = body.get("result") or {} + if code != 200 or result.get("passed") is not True: + errors += fail(f"trace true → {code} {body}") + elif "cli" in str(result.get("protocols") or {}): + errors += fail(f"fake trace should have empty protocols, got {result}") + else: + errors += ok("POST get_dns.read trace=true still 200 (fake)") + code, body = handle_run({"name": "get_no_such_method.read"}) if code != 404 or body.get("error") != "unknown_name": errors += fail(f"unknown → {code} {body}") From 2e6208b0e636acfcc77b71e8332c6bc2fa0cfdbb Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 29 Aug 2026 12:04:39 +0000 Subject: [PATCH 009/143] feat(sidecar): POST /v1/sync pulls main, checks out an allowlisted PR, and cleans back to main Fixes #36. op: pr looks up the GitHub author and 403s before fetch unless YAML allow_pr_authors matches (AdamRickards). Fake transport never gits. --- sidecar/README.md | 6 +- sidecar/__main__.py | 4 +- sidecar/app.py | 212 ++++++++++++++++++++++++++++++++- sidecar/openapi.yaml | 102 +++++++++++++++- sidecar/sync.yaml | 10 ++ tests/test_sidecar_readonly.py | 53 +++++++++ 6 files changed, 379 insertions(+), 8 deletions(-) create mode 100644 sidecar/sync.yaml diff --git a/sidecar/README.md b/sidecar/README.md index c267899..995fe18 100644 --- a/sidecar/README.md +++ b/sidecar/README.md @@ -27,7 +27,11 @@ Example pool host is TEST-NET `192.0.2.10` in `tests/device_pool.yaml.example`. Passwords from the environment (`CRUDE_DEVICE_PASSWORD`). -Sync: iterate tests on a PC, merge to GitHub, sidecar machine git pull. +Sync: `POST /v1/sync` `{op: main}` (live pull), `{op: pr, number: N}` +(allowlisted author only), `{op: clean}` (back to origin/main). Same +lock as `/v1/run`. Not a second harness. VPS Claude is the fallback +until this endpoint is on main. + ## Run diff --git a/sidecar/__main__.py b/sidecar/__main__.py index 4ccfd92..3faa7b6 100644 --- a/sidecar/__main__.py +++ b/sidecar/__main__.py @@ -10,7 +10,7 @@ def main(argv=None): p = argparse.ArgumentParser( prog="python -m sidecar", description=( - "POST /v1/run {name} maps catalog *.read to " + "POST /v1/run {name} and POST /v1/sync {op} over " "tests/release_matrix.py --inspect --method . " "Default mode is read-only." ), @@ -21,7 +21,7 @@ def main(argv=None): from sidecar.app import make_server, mode print( - f"sidecar mode={mode()!r} POST /v1/run on {args.host}:{args.port} " + f"sidecar mode={mode()!r} POST /v1/run and /v1/sync on {args.host}:{args.port} " f"inspect=tests/release_matrix.py::run_inspect", file=sys.stderr, ) diff --git a/sidecar/app.py b/sidecar/app.py index fcbfe89..8fac8cd 100644 --- a/sidecar/app.py +++ b/sidecar/app.py @@ -13,6 +13,7 @@ import json import os import re +import subprocess import sys import threading import time @@ -23,6 +24,7 @@ ROOT = Path(__file__).resolve().parents[1] CATALOG_PATH = ROOT / "tests" / "catalog.yaml" POOL_PATH = ROOT / "tests" / "device_pool.yaml" +SYNC_YAML = ROOT / "sidecar" / "sync.yaml" NAME_RE = re.compile(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$") DEFAULT_MODE = "read-only" @@ -45,6 +47,9 @@ def release(self): LOCK = RunLock() +# Tests assign a callable(number) -> login. Live uses GitHub. Never a PAT. +pr_author_lookup = None + def mode(): return (os.environ.get("CRUDE_SIDECAR_MODE") or DEFAULT_MODE).strip().lower() @@ -131,6 +136,7 @@ def shape_inspect(name, inspect_out): "protocols": protocols, "parity_diffs": diffs, }, + "sidecar": current_head(), "audit": {"diff": {"buckets": []}}, "timings": { "encode_dispatch_ms": 0, @@ -232,6 +238,189 @@ def handle_run(payload): LOCK.release() +def load_sync_yaml(): + """Allowlist and repo. YAML declares; Python only reads.""" + if yaml is None: + raise FileNotFoundError("pyyaml required") + if not SYNC_YAML.is_file(): + raise FileNotFoundError(str(SYNC_YAML)) + data = yaml.safe_load(SYNC_YAML.read_text()) or {} + authors = [str(a) for a in (data.get("allow_pr_authors") or []) if a] + repo = data.get("repo") + api_host = data.get("api_host") + if not authors: + raise ValueError("sidecar/sync.yaml allow_pr_authors is empty") + if not repo or not api_host: + raise ValueError("sidecar/sync.yaml missing repo or api_host") + return { + "allow_pr_authors": authors, + "repo": str(repo), + "api_host": str(api_host), + } + + +def current_head(): + """sha + ref of this checkout. Fake transport does not git.""" + if transport() in ("fake", "offline"): + return {"sha": "fake", "ref": "main"} + sha = _git("rev-parse", "HEAD") + ref = _git("rev-parse", "--abbrev-ref", "HEAD") + sha_s = sha.stdout.strip() if sha.returncode == 0 else None + ref_s = ref.stdout.strip() if ref.returncode == 0 else None + if ref_s == "HEAD": + ref_s = "detached" + return {"sha": sha_s, "ref": ref_s} + + +def _git(*args): + return subprocess.run( + ["git", *args], + cwd=str(ROOT), + capture_output=True, + text=True, + timeout=60, + ) + + +def _git_sync_main(): + dirty = _git("status", "--porcelain") + if dirty.returncode != 0: + raise RuntimeError(dirty.stderr[-300:] or "git status failed") + if dirty.stdout.strip(): + raise RuntimeError("working tree dirty") + fetched = _git("fetch", "origin", "main") + if fetched.returncode != 0: + raise RuntimeError((fetched.stderr or fetched.stdout)[-300:]) + checked = _git("checkout", "main") + if checked.returncode != 0: + raise RuntimeError((checked.stderr or checked.stdout)[-300:]) + merged = _git("merge", "--ff-only", "origin/main") + if merged.returncode != 0: + raise RuntimeError((merged.stderr or merged.stdout)[-300:]) + + +def _git_sync_pr(number): + dirty = _git("status", "--porcelain") + if dirty.returncode != 0: + raise RuntimeError(dirty.stderr[-300:] or "git status failed") + if dirty.stdout.strip(): + raise RuntimeError("working tree dirty") + refspec = "pull/%d/head" % int(number) + fetched = _git("fetch", "origin", refspec) + if fetched.returncode != 0: + raise RuntimeError((fetched.stderr or fetched.stdout)[-300:]) + checked = _git("checkout", "--detach", "FETCH_HEAD") + if checked.returncode != 0: + raise RuntimeError((checked.stderr or checked.stdout)[-300:]) + + +def lookup_pr_author_github(number, cfg): + """Public PR lookup. No PAT. Host comes from YAML, not a committed URL.""" + import urllib.error + import urllib.request + + scheme = "https" + path = "/repos/%s/pulls/%d" % (cfg["repo"], int(number)) + url = scheme + "://" + cfg["api_host"] + path + req = urllib.request.Request(url, headers={"Accept": "application/vnd.github+json"}) + try: + with urllib.request.urlopen(req, timeout=15) as resp: + data = json.loads(resp.read().decode("utf-8")) + except (urllib.error.URLError, TimeoutError, ValueError, OSError) as exc: + raise RuntimeError("PR lookup failed: %s" % exc) from exc + user = data.get("user") if isinstance(data, dict) else None + login = user.get("login") if isinstance(user, dict) else None + return str(login) if login else None + + +def lookup_pr_author(number, cfg): + if pr_author_lookup is not None: + return pr_author_lookup(number) + if transport() in ("fake", "offline"): + return None + return lookup_pr_author_github(number, cfg) + + +def _sync_restart_pending(): + """Live sync asks systemd to bring us back. Tests never set this.""" + if transport() in ("fake", "offline"): + return False + return True + + +def handle_sync(payload): + """Return (status_code, body). Git only after allowlist. Fake skips git.""" + if not isinstance(payload, dict): + return 400, {"error": "bad_name", "message": "body must be JSON object"} + op = payload.get("op") + keys = set(payload.keys()) + if op not in ("main", "pr", "clean"): + return 400, { + "error": "bad_name", + "message": 'body.op must be "main", "pr", or "clean"', + } + if op == "pr": + if keys - {"op", "number"}: + return 400, {"error": "bad_name", "message": 'pr body is {"op":"pr","number": N}'} + number = payload.get("number") + if not isinstance(number, int) or isinstance(number, bool) or number < 1: + return 400, {"error": "bad_name", "message": "number must be a positive integer"} + elif keys - {"op"}: + return 400, {"error": "bad_name", "message": 'body is {"op":"main"} or {"op":"clean"}'} + + try: + cfg = load_sync_yaml() + except (FileNotFoundError, ValueError) as exc: + return 503, {"error": "not_ready", "message": str(exc)} + + if op == "pr": + try: + login = lookup_pr_author(number, cfg) + except RuntimeError as exc: + return 503, {"error": "not_ready", "message": str(exc)} + if not login or login not in cfg["allow_pr_authors"]: + return 403, { + "error": "forbidden", + "message": "PR author not allowlisted", + "author": login, + } + + if not LOCK.acquire(): + return 409, {"error": "lock_held", "message": "lab lock is held"} + try: + fake = transport() in ("fake", "offline") + if not fake: + try: + if op == "pr": + _git_sync_pr(number) + else: + _git_sync_main() + except RuntimeError as exc: + return 503, {"error": "not_ready", "message": str(exc)} + if fake: + if op == "pr": + head = {"sha": "fake", "ref": "pr-%d" % number} + else: + head = {"sha": "fake", "ref": "main"} + else: + head = current_head() + if op == "pr": + head = {"sha": head.get("sha"), "ref": "pr-%d" % number} + restart = False if fake else _sync_restart_pending() + body = { + "ok": True, + "op": op, + "head": head.get("sha"), + "ref": head.get("ref"), + "restart": restart, + } + if op == "pr": + body["number"] = number + return 200, body + finally: + LOCK.release() + + class Handler(BaseHTTPRequestHandler): def log_message(self, fmt, *args): sys.stderr.write("%s - %s\n" % (self.address_string(), fmt % args)) @@ -246,8 +435,8 @@ def _send(self, code, body): def do_POST(self): path = urlparse(self.path).path - if path != "/v1/run": - self._send(404, {"error": "unknown_name", "message": "not POST /v1/run"}) + if path not in ("/v1/run", "/v1/sync"): + self._send(404, {"error": "unknown_name", "message": "not POST /v1/run or /v1/sync"}) return length = int(self.headers.get("Content-Length") or 0) raw = self.rfile.read(length) if length else b"" @@ -257,7 +446,10 @@ def do_POST(self): self._send(400, {"error": "bad_name", "message": "body is not JSON"}) return try: - code, body = handle_run(payload) + if path == "/v1/sync": + code, body = handle_sync(payload) + else: + code, body = handle_run(payload) except Exception as exc: self._send( 500, @@ -268,9 +460,21 @@ def do_POST(self): ) return self._send(code, body) + if ( + path == "/v1/sync" + and code == 200 + and isinstance(body, dict) + and body.get("restart") + ): + threading.Thread(target=_exit_after_flush, daemon=True).start() def do_GET(self): - self._send(400, {"error": "bad_name", "message": "POST /v1/run only"}) + self._send(400, {"error": "bad_name", "message": "POST /v1/run or /v1/sync"}) + + +def _exit_after_flush(): + time.sleep(0.3) + os._exit(0) def make_server(host, port): diff --git a/sidecar/openapi.yaml b/sidecar/openapi.yaml index 5e8f6ae..52de220 100644 --- a/sidecar/openapi.yaml +++ b/sidecar/openapi.yaml @@ -168,6 +168,63 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' + /v1/sync: + post: + operationId: syncCheckout + summary: Pull main, checkout an allowlisted PR, or clean back to main + description: | + RPC for the git pull the sidecar already does by hand. Not a + second harness. Not extra keys on `/v1/run`. + + `op: pr` looks up the PR author and compares to YAML + `allow_pr_authors` (start: AdamRickards) *before* fetch. + Mismatch is 403; no checkout, no restart. `main` and `clean` + do not need that check. Never a caller-supplied ref, never + push, never force. Same RunLock as `/v1/run`. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SyncRequest' + examples: + main: + value: { op: main } + pr: + value: { op: pr, number: 36 } + clean: + value: { op: clean } + responses: + '200': + description: Checkout moved. `restart` true means the process will exit so systemd can reload YAML/Python. + content: + application/json: + schema: + $ref: '#/components/schemas/SyncResponse' + '400': + description: Body is not a known op. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '403': + description: PR author is not in allow_pr_authors. No fetch. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '409': + description: Lab lock is held. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '503': + description: Git or PR lookup not ready. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' components: schemas: RunRequest: @@ -200,6 +257,49 @@ components: $ref: '#/components/schemas/Audit' timings: $ref: '#/components/schemas/Timings' + sidecar: + $ref: '#/components/schemas/SidecarHead' + SyncRequest: + type: object + additionalProperties: false + required: [op] + properties: + op: + type: string + enum: [main, pr, clean] + number: + type: integer + minimum: 1 + description: Required when op is pr. GitHub PR number, not a ref. + SyncResponse: + type: object + additionalProperties: false + required: [ok, op, head, ref, restart] + properties: + ok: + type: boolean + op: + type: string + enum: [main, pr, clean] + head: + type: string + nullable: true + ref: + type: string + restart: + type: boolean + number: + type: integer + SidecarHead: + type: object + additionalProperties: false + properties: + sha: + type: string + nullable: true + ref: + type: string + nullable: true Result: type: object additionalProperties: false @@ -388,7 +488,7 @@ components: properties: error: type: string - enum: [bad_name, unknown_name, lock_held, not_ready] + enum: [bad_name, unknown_name, lock_held, not_ready, forbidden] message: type: string name: diff --git a/sidecar/sync.yaml b/sidecar/sync.yaml new file mode 100644 index 0000000..9242cd1 --- /dev/null +++ b/sidecar/sync.yaml @@ -0,0 +1,10 @@ +# sidecar/sync.yaml — POST /v1/sync. YAML declares; Python interprets. +# No live URLs. api_host is a host name only (scheme is not stored here). +# +# allow_pr_authors: GitHub logins that may be fetched via op: pr. +# Anyone else → 403, no fetch, no checkout, no restart. + +repo: AdamRickards/crude-engine +api_host: api.github.com +allow_pr_authors: + - AdamRickards diff --git a/tests/test_sidecar_readonly.py b/tests/test_sidecar_readonly.py index 3bd1d6b..760e6c2 100755 --- a/tests/test_sidecar_readonly.py +++ b/tests/test_sidecar_readonly.py @@ -19,9 +19,11 @@ from sidecar.app import ( # noqa: E402 call_inspect, handle_run, + handle_sync, make_server, name_to_inspect, ) +import sidecar.app as sidecar_app # noqa: E402 def fail(msg): @@ -67,6 +69,7 @@ def main(): or "parity_diffs" not in result or "protocols" not in result or result.get("passed") is not True + or (body.get("sidecar") or {}).get("ref") != "main" ): errors += fail(f"get_dns.read → {code} {body}") else: @@ -93,6 +96,56 @@ def main(): else: errors += ok("POST get_dns.read trace=true still 200 (fake)") + def boom(*_a, **_k): + raise AssertionError("git must not run on fake or 403") + + sidecar_app._git_sync_main = boom + sidecar_app._git_sync_pr = boom + + code, body = handle_sync({"op": "main"}) + if code != 200 or body.get("ref") != "main" or body.get("restart") is not False: + errors += fail(f"sync main fake → {code} {body}") + else: + errors += ok("POST /v1/sync op=main fake → 200 no git") + + code, body = handle_sync({"op": "clean"}) + if code != 200 or body.get("ref") != "main": + errors += fail(f"sync clean fake → {code} {body}") + else: + errors += ok("POST /v1/sync op=clean fake → 200") + + code, body = handle_sync({"op": "pr"}) + if code != 400 or body.get("error") != "bad_name": + errors += fail(f"sync pr missing number → {code} {body}") + else: + errors += ok("POST /v1/sync pr without number → 400") + + code, body = handle_sync({"op": "pr", "number": 2, "ref": "heads/x"}) + if code != 400 or body.get("error") != "bad_name": + errors += fail(f"sync extra key → {code} {body}") + else: + errors += ok("POST /v1/sync extra key → 400") + + sidecar_app.pr_author_lookup = lambda n: "stranger" + code, body = handle_sync({"op": "pr", "number": 2}) + if code != 403 or body.get("error") != "forbidden": + errors += fail(f"foreign author → {code} {body}") + else: + errors += ok("POST /v1/sync pr foreign author → 403 no git") + + sidecar_app.pr_author_lookup = lambda n: "AdamRickards" + code, body = handle_sync({"op": "pr", "number": 1}) + if ( + code != 200 + or body.get("ref") != "pr-1" + or body.get("restart") is not False + or body.get("ok") is not True + ): + errors += fail(f"allowlisted pr fake → {code} {body}") + else: + errors += ok("POST /v1/sync pr AdamRickards fake → 200 no git") + sidecar_app.pr_author_lookup = None + code, body = handle_run({"name": "get_no_such_method.read"}) if code != 404 or body.get("error") != "unknown_name": errors += fail(f"unknown → {code} {body}") From e5f5886bcb6a70c1b614dc8d6fff00e14b022208 Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 29 Aug 2026 12:16:48 +0000 Subject: [PATCH 010/143] fix(wire): SSH dns servers table keys by No. column, not row_num Fixes #33. Overlay declares key_column: 0 so the live show "No. 1" becomes servers key "1", matching mops/snmp INDEX. Schema primary_key address is unchanged (separate inspect-map question). --- crude_engine/wire/ssh/dns.yaml | 4 +- scripts/ci_offline.sh | 2 + tests/test_ssh_dns_key_column.py | 65 ++++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 2 deletions(-) create mode 100755 tests/test_ssh_dns_key_column.py diff --git a/crude_engine/wire/ssh/dns.yaml b/crude_engine/wire/ssh/dns.yaml index 46a95a1..e0cfc68 100644 --- a/crude_engine/wire/ssh/dns.yaml +++ b/crude_engine/wire/ssh/dns.yaml @@ -43,14 +43,14 @@ attributes: hm2dnsclientserveraddress: sources: ssh: - read: {command: "show dns client servers", parser: table, column: 1} + read: {command: "show dns client servers", parser: table, column: 1, key_column: 0} create: {command: "dns client servers add {index} ip {address}", level: config} delete: {command: "dns client servers delete {index}", level: config, confirm: "y"} hm2dnsclientserverindex: sources: ssh: - read: {command: "show dns client servers", parser: table, column: 0} + read: {command: "show dns client servers", parser: table, column: 0, key_column: 0} hm2dnsclientserverrowstatus: sources: diff --git a/scripts/ci_offline.sh b/scripts/ci_offline.sh index 4b9566b..6eb681d 100644 --- a/scripts/ci_offline.sh +++ b/scripts/ci_offline.sh @@ -43,6 +43,7 @@ fi run "program-files" "$PY" scripts/generate_status.py --check run "inspect-result" "$PY" tests/test_inspect_result.py run "inspect-reaches-driver" "$PY" tests/test_inspect_reaches_driver.py +run "ssh-dns-key-column" "$PY" tests/test_ssh_dns_key_column.py run "principles" "$PY" scripts/check_principles.py # Catalogue proofs are the 2.10 exit. They are expected red in cycle 0 @@ -81,4 +82,5 @@ soft=0 "$PY" scripts/generate_status.py --check || soft=1 "$PY" tests/test_inspect_result.py || soft=1 "$PY" tests/test_inspect_reaches_driver.py || soft=1 +"$PY" tests/test_ssh_dns_key_column.py || soft=1 exit $soft diff --git a/tests/test_ssh_dns_key_column.py b/tests/test_ssh_dns_key_column.py new file mode 100755 index 0000000..e630005 --- /dev/null +++ b/tests/test_ssh_dns_key_column.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Offline proof for issue 33: SSH DNS overlay declares key_column 0.""" +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +import yaml # noqa: E402 +from crude_engine.drivers.ssh_driver import SSHGatherDriver # noqa: E402 + +SHOW = ( + "No. IP address Active \n" + "--- ---------------------------------------- ------\n" + " 1 192.168.3.1 [x]\n" +) + + +def fail(msg): + print(f"FAIL {msg}") + return 1 + + +def ok(msg): + print(f"PASS {msg}") + return 0 + + +def main() -> int: + rc = 0 + overlay = yaml.safe_load( + (ROOT / "crude_engine" / "wire" / "ssh" / "dns.yaml").read_text() + ) + attrs = overlay["attributes"] + for name, col in ( + ("hm2dnsclientserveraddress", 1), + ("hm2dnsclientserverindex", 0), + ): + read = attrs[name]["sources"]["ssh"]["read"] + if read.get("key_column") != 0 or read.get("column") != col: + rc |= fail(f"{name} read {read}") + else: + rc |= ok(f"{name} table column={col} key_column=0") + + drv = SSHGatherDriver.__new__(SSHGatherDriver) + drv._driver_config = {} + row_num = drv._parse_table(SHOW, {"parser": "table", "column": 1}) + keyed = drv._parse_table( + SHOW, {"parser": "table", "column": 1, "key_column": 0} + ) + if list(row_num.keys()) != ["0"]: + rc |= fail(f"without key_column expected ['0'], got {row_num}") + else: + rc |= ok("live show without key_column keys by row_num 0") + if list(keyed.keys()) != ["1"] or keyed.get("1") != "192.168.3.1": + rc |= fail(f"with key_column expected {{'1': address}}, got {keyed}") + else: + rc |= ok("live show with key_column 0 keys by No. 1") + return rc + + +if __name__ == "__main__": + sys.exit(main()) From 606b9ceafa5845129393b4f57e4831297e429190 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 30 Aug 2026 01:31:09 +1000 Subject: [PATCH 011/143] fix(wire): map HiDiscovery SSH CLI mode spelling to schema tokens (#77) SSH show network hidiscovery reports read-write/read-only. Schema canonical is readWrite/readOnly (value_map 1/2). Overlay tags the Operating mode read with value_map hidiscovery_mode so sidecar mops/ssh match without a Python rewrite. Closes #45 --- crude_engine/wire/netconfig.yaml | 4 ++++ crude_engine/wire/ssh/netconfig.yaml | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/crude_engine/wire/netconfig.yaml b/crude_engine/wire/netconfig.yaml index 08c5cf8..c01b996 100644 --- a/crude_engine/wire/netconfig.yaml +++ b/crude_engine/wire/netconfig.yaml @@ -1,5 +1,9 @@ version: 2.6.0 feature: netconfig +value_maps: + hidiscovery_mode: + read-write: readWrite + read-only: readOnly schemas: read_netconfig: type: dict diff --git a/crude_engine/wire/ssh/netconfig.yaml b/crude_engine/wire/ssh/netconfig.yaml index 55f1e41..253a351 100644 --- a/crude_engine/wire/ssh/netconfig.yaml +++ b/crude_engine/wire/ssh/netconfig.yaml @@ -39,7 +39,7 @@ attributes: type: string sources: ssh: - read: {command: "show network hidiscovery", field: "Operating mode"} + read: {command: "show network hidiscovery", field: "Operating mode", tag: value_map, map: hidiscovery_mode} write: {command: "network hidiscovery mode {value}", level: priv} hm2nethidiscoveryblinking: From 7027189dabab1985e46470f9e0ea168d15da08ea Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 30 Aug 2026 01:33:54 +1000 Subject: [PATCH 012/143] fix(schema): get_qos_mapping rows from accessible SNMP suffix (#78) Sub-table primary_key was the not-accessible INDEX (hm2TrafficClassPriority / hm2CosMapIpDscpValue). SNMP walks of those OIDs return no rows. Drive from the accessible value columns instead; the walk suffix is the map key, same as napalm-hios 1.17. SSH overlay keys those value columns by the show-table index column, and adds show classofservice ip-dscp-mapping. Closes #58 --- crude_engine/schemas/qos_mapping.yaml | 7 +++++-- crude_engine/wire/ssh/l2forwarding.yaml | 8 ++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/crude_engine/schemas/qos_mapping.yaml b/crude_engine/schemas/qos_mapping.yaml index 789d227..9e31943 100644 --- a/crude_engine/schemas/qos_mapping.yaml +++ b/crude_engine/schemas/qos_mapping.yaml @@ -8,12 +8,15 @@ methods: dot1p: {} dscp: {} sub_tables: + # Drive rows from the accessible value column. SNMP walk suffix is the + # INDEX (hm2TrafficClassPriority 0..7 / hm2CosMapIpDscpValue 0..63). + # Do not walk the not-accessible INDEX objects (1.17 same-wire). dot1p: - primary_key: dot1p_priority + primary_key: dot1p_traffic_class field_map: value: dot1p_traffic_class dscp: - primary_key: dscp_value + primary_key: dscp_traffic_class field_map: value: dscp_traffic_class set_qos_mapping: diff --git a/crude_engine/wire/ssh/l2forwarding.yaml b/crude_engine/wire/ssh/l2forwarding.yaml index c1f107e..5374713 100644 --- a/crude_engine/wire/ssh/l2forwarding.yaml +++ b/crude_engine/wire/ssh/l2forwarding.yaml @@ -3,14 +3,14 @@ feature: l2forwarding-ssh description: "SSH CLI sources for L2 forwarding (QoS mapping)" attributes: - hm2trafficclasspriority: + hm2trafficclass: type: string sources: ssh: - read: {command: "show classofservice dot1p-mapping", parser: table, column: 0} + read: {command: "show classofservice dot1p-mapping", parser: table, column: 1, key_column: 0} - hm2trafficclass: + hm2cosmapipdscptrafficclass: type: string sources: ssh: - read: {command: "show classofservice dot1p-mapping", parser: table, column: 1} + read: {command: "show classofservice ip-dscp-mapping", parser: table, column: 1, key_column: 0} From 2695cdbf6ed5a50719decae66e0a1ea1567a43ac Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 30 Aug 2026 01:41:46 +1000 Subject: [PATCH 013/143] fix(schema): get_users rows from accessible SNMP implied-string suffix (#79) INDEX hm2UserName is accessible-for-notify; 1.17 never walks it. Drive get_users from hm2UserAccessRole and decode IMPLIED suffix to name. --- crude_engine/schemas/user.yaml | 9 +++++++++ docs/SCHEMA_MODEL.md | 1 + local/generator/validate_schemas.py | 1 + 3 files changed, 11 insertions(+) diff --git a/crude_engine/schemas/user.yaml b/crude_engine/schemas/user.yaml index 85bb13b..7079bc8 100644 --- a/crude_engine/schemas/user.yaml +++ b/crude_engine/schemas/user.yaml @@ -5,6 +5,11 @@ methods: get_users: type: dict primary_key: username + # Drive rows from an accessible hm2UserConfigTable column. + # INDEX hm2UserName is accessible-for-notify; 1.17 never walks it. + # SNMP walk suffix is IMPLIED hm2UserName (e.g. 97.100.109.105.110 → admin). + index_fields: [username] + index_type: implied_string defaults: level: guest locked: false @@ -12,6 +17,10 @@ methods: snmp_auth: '' snmp_enc: '' default_password: false + attributes: + username: + wire: hm2useraccessrole + source: usermgmt set_user: type: upsert create_user: diff --git a/docs/SCHEMA_MODEL.md b/docs/SCHEMA_MODEL.md index 903e3ec..e6d73d4 100644 --- a/docs/SCHEMA_MODEL.md +++ b/docs/SCHEMA_MODEL.md @@ -130,6 +130,7 @@ No other top-level keys are valid. | `primary_key` | COND | string | Required for table getters (dict keyed by this field) | | `key_map` | OPT | string | Context map name for key remapping (e.g. `ifindex`) | | `index_fields` | OPT | list | RFC 2578 compound index decomposition fields | +| `index_type` | OPT | string | Last INDEX field encoding. `implied_string` = RFC 2578 IMPLIED (remaining sub-IDs as ASCII). Used with `index_fields`. | | `sub_tables` | OPT | dict | Nested table definitions (see Sub-Table Keys) | | `index_filter` | OPT | string | Regex filter on valid index values | diff --git a/local/generator/validate_schemas.py b/local/generator/validate_schemas.py index 90f1797..08e756b 100644 --- a/local/generator/validate_schemas.py +++ b/local/generator/validate_schemas.py @@ -27,6 +27,7 @@ VALID_METHOD_KEYS = { 'type', 'defaults', 'primary_key', 'key_map', 'index_fields', + 'index_type', 'sub_tables', 'row_status', 'index_key', 'required', 'fields', 'index_filter', 'linked_tables', 'attributes', 'schema', } From ebdc54ecc96561d7747024578303d5877bdb8069 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 30 Aug 2026 01:47:12 +1000 Subject: [PATCH 014/143] fix(schema): get_vlans SNMP rows from StaticTable suffix (#80) vlan_id SNMP was walking not-accessible CurrentTable INDEX. Walk accessible StaticName instead; suffix is vlan_id (1.17 same-wire). MOPS and SSH sources unchanged. --- crude_engine/schemas/vlan.yaml | 2 ++ crude_engine/wire/q-bridge.yaml | 10 +++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/crude_engine/schemas/vlan.yaml b/crude_engine/schemas/vlan.yaml index 895f94a..ae9aeb6 100644 --- a/crude_engine/schemas/vlan.yaml +++ b/crude_engine/schemas/vlan.yaml @@ -5,6 +5,8 @@ methods: get_vlans: type: dict primary_key: vlan_id + # SNMP: accessible dot1qVlanStaticTable, vlan_id from walk suffix. + # Not CurrentTable INDEX (1.17 same-wire). defaults: name: '' ports: {} diff --git a/crude_engine/wire/q-bridge.yaml b/crude_engine/wire/q-bridge.yaml index f64d08e..ae293e9 100644 --- a/crude_engine/wire/q-bridge.yaml +++ b/crude_engine/wire/q-bridge.yaml @@ -1352,8 +1352,16 @@ attributes: sources: snmp: read: - oid: 1.3.6.1.2.1.17.7.1.4.2.1.2 + # Accessible StaticTable column. CurrentTable INDEX + # (1.3.6.1.2.1.17.7.1.4.2.1.2) is not-accessible; 1.17 never walks it. + # Walk suffix is vlan_id. + oid: 1.3.6.1.2.1.17.7.1.4.3.1.1 method: walk + index_fields: + - name: vlan_id + type: integer + key_field: vlan_id + value_from_index: vlan_id mops: read: mib: Q-BRIDGE-MIB From c72db8f4f8a768eb5908ad8eb161cd409732e1ff Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 30 Aug 2026 01:50:20 +1000 Subject: [PATCH 015/143] fix(schema): get_arp_table from ipNetToMediaTable (#81) YAML bound ipNetToPhysical* (empty SNMP). 1.17 walks accessible Media columns; suffix is ifIndex.ip. age has no Media column (0.0). SSH Physical overlay leftover, not this ticket. --- crude_engine/schemas/arp.yaml | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/crude_engine/schemas/arp.yaml b/crude_engine/schemas/arp.yaml index 5086f24..ef97e55 100644 --- a/crude_engine/schemas/arp.yaml +++ b/crude_engine/schemas/arp.yaml @@ -5,6 +5,8 @@ methods: get_arp_table: type: dict primary_key: ip + # IP-MIB ipNetToMediaTable (1.17 same-wire). Not ipNetToPhysical*. + # Accessible Media columns; SNMP suffix is ifIndex.ip. defaults: interface: '' mac: '' @@ -57,19 +59,18 @@ methods: type: upsert fields: [dai_vlan_enabled, dai_vlan_logging, dai_vlan_binding_check, dai_vlan_acl_static, dai_vlan_acl_name] attributes: - # ARP table (get_arp_table) + # ARP table (get_arp_table) — ipNetToMediaTable, not Physical. + # Media has no lastUpdated; age stays default 0.0 (1.17 same). interface: - wire: ipnettophysicalifindex + wire: ipnettomediaifindex source: ip mac: - wire: ipnettophysicalphysaddress + wire: ipnettomediaphysaddress source: ip ip: - wire: ipnettophysicalnetaddress - source: ip - age: - wire: ipnettophysicallastupdated + wire: ipnettomedianetaddress source: ip + age: {} # DAI global validate_src_mac: wire: hm2agentdaisrcmacvalidate From 3424a6cfbda7e01b092998de03d847002f14a0bd Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 30 Aug 2026 01:54:26 +1000 Subject: [PATCH 016/143] fix(schema): ipv6 neighbors from ipNetToMediaTable (#82) get_ipv6_neighbors and get_ipv6_neighbors_table share Physical INDEX (empty SNMP). Same family as ARP: accessible Media columns, suffix ifIndex.ip. 1.17 has no ND getter. SSH leftover not this ticket. --- crude_engine/schemas/ipv6.yaml | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/crude_engine/schemas/ipv6.yaml b/crude_engine/schemas/ipv6.yaml index 742c885..71b4294 100644 --- a/crude_engine/schemas/ipv6.yaml +++ b/crude_engine/schemas/ipv6.yaml @@ -5,6 +5,8 @@ methods: get_ipv6_neighbors: type: dict primary_key: ip + # Same table as get_ipv6_neighbors_table and ARP family (#75): + # ipNetToMediaTable, not Physical INDEX (1.17 has no ND getter). defaults: interface: '' ip: '' @@ -25,23 +27,15 @@ attributes: ipv6_state: wire: hm2netipv6adminstatus source: netconfig + # ipNetToMediaTable (accessible columns; suffix ifIndex.ip). + # Physical 4.35 is empty on SNMP. Media has no ND state; default reachable. interface: - wire: ipnettophysicalifindex + wire: ipnettomediaifindex source: ip ip: - wire: ipnettophysicalnetaddress + wire: ipnettomedianetaddress source: ip mac: - wire: ipnettophysicalphysaddress + wire: ipnettomediaphysaddress source: ip - state: - wire: ipnettophysicalstate - source: ip - value_map: - '1': reachable - '2': stale - '3': delay - '4': probe - '5': invalid - '6': unknown - '7': incomplete + state: {} From 3dbd72cdf0474999aea95ccae134abe18256fef4 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 30 Aug 2026 01:57:23 +1000 Subject: [PATCH 017/143] fix(schema): get_sflow_receiver SNMP rows from accessible suffix (#83) INDEX sFlowRcvrIndex is not-accessible. 1.17 walks Owner and takes 1-8 from the suffix. MOPS/SSH unchanged. Sampler/poller DataSource is a compound OID suffix, not this ticket. --- crude_engine/schemas/sflow.yaml | 2 ++ crude_engine/wire/sflow.yaml | 9 ++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/crude_engine/schemas/sflow.yaml b/crude_engine/schemas/sflow.yaml index 3d87f79..b924a9a 100644 --- a/crude_engine/schemas/sflow.yaml +++ b/crude_engine/schemas/sflow.yaml @@ -5,6 +5,8 @@ methods: get_sflow_receiver: type: dict primary_key: receiver_index + # SNMP: accessible sFlowRcvrOwner, index from walk suffix (1.17). + # Not INDEX sFlowRcvrIndex. Sampler/poller DataSource is a different suffix. defaults: receiver_index: 0 owner: '' diff --git a/crude_engine/wire/sflow.yaml b/crude_engine/wire/sflow.yaml index a8f8d45..b8ec16b 100644 --- a/crude_engine/wire/sflow.yaml +++ b/crude_engine/wire/sflow.yaml @@ -329,8 +329,15 @@ attributes: sources: snmp: read: - oid: 1.3.6.1.4.1.14706.1.1.4.1.1 + # Accessible sFlowRcvrOwner. INDEX sFlowRcvrIndex is + # not-accessible; 1.17 never walks it. Suffix is 1-8. + oid: 1.3.6.1.4.1.14706.1.1.4.1.2 method: walk + index_fields: + - name: receiver_index + type: integer + key_field: receiver_index + value_from_index: receiver_index mops: read: mib: SFLOW-MIB From 8bc0c22568da512a0ead09ca0812b55a0b731f2f Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 30 Aug 2026 01:59:50 +1000 Subject: [PATCH 018/143] fix(schema): sflow sampler/poller SNMP from DataSource suffix (#84) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit INDEX DataSource is not-accessible. 1.17 walks accessible Receiver and takes ifIndex from {oid_len}.1.3.6…ifIndex.instance. Regex 2.2.1.1.(ifIndex) fits MOPS OID and SNMP suffix. SSH leftover. --- crude_engine/schemas/sflow.yaml | 7 +++++-- crude_engine/wire/sflow.yaml | 10 ++++++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/crude_engine/schemas/sflow.yaml b/crude_engine/schemas/sflow.yaml index b924a9a..581830b 100644 --- a/crude_engine/schemas/sflow.yaml +++ b/crude_engine/schemas/sflow.yaml @@ -21,6 +21,7 @@ methods: type: dict primary_key: sampler_datasource key_map: ifindex + # SNMP: accessible FsReceiver; DataSource suffix {oid_len}.…ifIndex.instance. defaults: sampler_datasource: '' sampler_receiver: 0 @@ -32,6 +33,7 @@ methods: type: dict primary_key: poller_datasource key_map: ifindex + # Same DataSource suffix encoding as sampler (1.17 _sflow_suffix_to_ifindex). defaults: poller_datasource: '' poller_receiver: 0 @@ -65,7 +67,8 @@ attributes: sampler_datasource: wire: sflowfsdatasource source: sflow - regex: '(\d+)$' + # ifIndex in ifEntry DataSource (MOPS OID or SNMP suffix+instance). + regex: '2\.2\.1\.1\.(\d+)' sampler_receiver: wire: sflowfsreceiver source: sflow @@ -79,7 +82,7 @@ attributes: poller_datasource: wire: sflowcpdatasource source: sflow - regex: '\.(\d+)$' + regex: '2\.2\.1\.1\.(\d+)' poller_receiver: wire: sflowcpreceiver source: sflow diff --git a/crude_engine/wire/sflow.yaml b/crude_engine/wire/sflow.yaml index b8ec16b..7c671ad 100644 --- a/crude_engine/wire/sflow.yaml +++ b/crude_engine/wire/sflow.yaml @@ -66,8 +66,11 @@ attributes: sources: snmp: read: - oid: 1.3.6.1.4.1.14706.1.1.6.1.1 + # Accessible sFlowCpReceiver. INDEX DataSource is not-accessible. + # Suffix is {oid_len}.1.3.6.1.2.1.2.2.1.1.{ifIndex}.{instance}. + oid: 1.3.6.1.4.1.14706.1.1.6.1.3 method: walk + key_tag: crude_text mops: read: mib: SFLOW-MIB @@ -160,8 +163,11 @@ attributes: sources: snmp: read: - oid: 1.3.6.1.4.1.14706.1.1.5.1.1 + # Accessible sFlowFsReceiver. INDEX DataSource is not-accessible. + # Suffix is {oid_len}.1.3.6.1.2.1.2.2.1.1.{ifIndex}.{instance}. + oid: 1.3.6.1.4.1.14706.1.1.5.1.3 method: walk + key_tag: crude_text mops: read: mib: SFLOW-MIB From 9ae08cb8d25e119c59444086c5d49ff2c2983f01 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 30 Aug 2026 02:06:16 +1000 Subject: [PATCH 019/143] fix(schema): get_lldp_neighbors SNMP from RemTable suffix (#85) INDEX lldpRemLocalPortNum is not-accessible. 1.17 walks accessible lldpRemSysName at 1.0.8802 and takes localPortNum from the second sub-id of timeMark.localPortNum.remIndex. No key_field so list_append keeps multiple remIndex per port. Rem columns these getters use moved to the IEEE tree. MOPS/SSH unchanged. --- crude_engine/wire/lldp-ext-dot1.yaml | 2 +- crude_engine/wire/lldp-ext-dot3.yaml | 10 +++++----- crude_engine/wire/lldp.yaml | 30 ++++++++++++++++++++-------- 3 files changed, 28 insertions(+), 14 deletions(-) diff --git a/crude_engine/wire/lldp-ext-dot1.yaml b/crude_engine/wire/lldp-ext-dot1.yaml index 5aefb18..47ec06e 100644 --- a/crude_engine/wire/lldp-ext-dot1.yaml +++ b/crude_engine/wire/lldp-ext-dot1.yaml @@ -516,7 +516,7 @@ attributes: sources: snmp: read: - oid: 1.3.6.1.2.1.0.8802.1.1.2.1.5.32962.1.3.1.1.1 + oid: 1.0.8802.1.1.2.1.5.32962.1.3.1.1.1 method: walk mops: read: diff --git a/crude_engine/wire/lldp-ext-dot3.yaml b/crude_engine/wire/lldp-ext-dot3.yaml index cf87c94..d1b4be7 100644 --- a/crude_engine/wire/lldp-ext-dot3.yaml +++ b/crude_engine/wire/lldp-ext-dot3.yaml @@ -455,7 +455,7 @@ attributes: sources: snmp: read: - oid: 1.3.6.1.2.1.0.8802.1.1.2.1.5.4623.1.3.3.1.2 + oid: 1.0.8802.1.1.2.1.5.4623.1.3.3.1.2 method: walk mops: read: @@ -475,7 +475,7 @@ attributes: sources: snmp: read: - oid: 1.3.6.1.2.1.0.8802.1.1.2.1.5.4623.1.3.3.1.1 + oid: 1.0.8802.1.1.2.1.5.4623.1.3.3.1.1 method: walk mops: read: @@ -574,7 +574,7 @@ attributes: sources: snmp: read: - oid: 1.3.6.1.2.1.0.8802.1.1.2.1.5.4623.1.3.1.1.2 + oid: 1.0.8802.1.1.2.1.5.4623.1.3.1.1.2 method: walk mops: read: @@ -594,7 +594,7 @@ attributes: sources: snmp: read: - oid: 1.3.6.1.2.1.0.8802.1.1.2.1.5.4623.1.3.1.1.1 + oid: 1.0.8802.1.1.2.1.5.4623.1.3.1.1.1 method: walk mops: read: @@ -628,7 +628,7 @@ attributes: sources: snmp: read: - oid: 1.3.6.1.2.1.0.8802.1.1.2.1.5.4623.1.3.1.1.4 + oid: 1.0.8802.1.1.2.1.5.4623.1.3.1.1.4 method: walk mops: read: diff --git a/crude_engine/wire/lldp.yaml b/crude_engine/wire/lldp.yaml index 0b24293..45525e6 100644 --- a/crude_engine/wire/lldp.yaml +++ b/crude_engine/wire/lldp.yaml @@ -721,7 +721,7 @@ attributes: sources: snmp: read: - oid: 1.3.6.1.2.1.0.8802.1.1.2.1.4.1.1.5 + oid: 1.0.8802.1.1.2.1.4.1.1.5 method: walk mops: read: @@ -789,8 +789,22 @@ attributes: sources: snmp: read: - oid: 1.3.6.1.2.1.0.8802.1.1.2.1.4.1.1.2 + # Accessible lldpRemSysName. INDEX lldpRemLocalPortNum + # (1.3.6.1.2.1.0.8802.1.1.2.1.4.1.1.2) is not-accessible; + # 1.17 never walks it. Suffix is timeMark.localPortNum.remIndex. + # IEEE tree 1.0.8802 matches 1.17 (not 1.3.6.1.2.1.0.8802). + # No key_field: join key stays the full suffix so list_append + # can keep multiple remIndex on the same local port. + oid: 1.0.8802.1.1.2.1.4.1.1.9 method: walk + index_fields: + - name: time_mark + type: integer + - name: local_port + type: integer + - name: rem_index + type: integer + value_from_index: local_port mops: read: mib: LLDP-MIB @@ -1029,7 +1043,7 @@ attributes: sources: snmp: read: - oid: 1.3.6.1.2.1.0.8802.1.1.2.1.4.1.1.8 + oid: 1.0.8802.1.1.2.1.4.1.1.8 method: walk mops: read: @@ -1048,7 +1062,7 @@ attributes: sources: snmp: read: - oid: 1.3.6.1.2.1.0.8802.1.1.2.1.4.1.1.7 + oid: 1.0.8802.1.1.2.1.4.1.1.7 method: walk mops: read: @@ -1081,7 +1095,7 @@ attributes: sources: snmp: read: - oid: 1.3.6.1.2.1.0.8802.1.1.2.1.4.1.1.12 + oid: 1.0.8802.1.1.2.1.4.1.1.12 method: walk mops: read: @@ -1098,7 +1112,7 @@ attributes: sources: snmp: read: - oid: 1.3.6.1.2.1.0.8802.1.1.2.1.4.1.1.11 + oid: 1.0.8802.1.1.2.1.4.1.1.11 method: walk mops: read: @@ -1114,7 +1128,7 @@ attributes: sources: snmp: read: - oid: 1.3.6.1.2.1.0.8802.1.1.2.1.4.1.1.10 + oid: 1.0.8802.1.1.2.1.4.1.1.10 method: walk mops: read: @@ -1133,7 +1147,7 @@ attributes: sources: snmp: read: - oid: 1.3.6.1.2.1.0.8802.1.1.2.1.4.1.1.9 + oid: 1.0.8802.1.1.2.1.4.1.1.9 method: walk mops: read: From 5c75d1cea08c97706621ebc9434e7b3407d2c920 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 30 Aug 2026 02:08:49 +1000 Subject: [PATCH 020/143] fix(schema): get_snmp_trap_destinations from TAddress suffix (#86) INDEX snmpTargetAddrName is not-accessible. 1.17 walks accessible snmpTargetAddrTAddress and decodes the IMPLIED dest name from the suffix. Getter-only PK override; create/delete catalog unchanged. --- crude_engine/schemas/snmp.yaml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crude_engine/schemas/snmp.yaml b/crude_engine/schemas/snmp.yaml index adb7dbf..ff302f0 100644 --- a/crude_engine/schemas/snmp.yaml +++ b/crude_engine/schemas/snmp.yaml @@ -17,11 +17,20 @@ methods: get_snmp_trap_destinations: type: dict primary_key: name + # Drive rows from accessible snmpTargetAddrTAddress. + # INDEX snmpTargetAddrName (1.3.6.1.6.3.12.1.2.1.1) is not-accessible; + # 1.17 never walks it. Suffix is IMPLIED dest name (e.g. 4.116.114.97.112 → trap). + index_fields: [name] + index_type: implied_string defaults: address: '' security_model: '' security_name: '' security_level: '' + attributes: + name: + wire: snmptargetaddrtaddress + source: snmp-target create_snmp_trap_dest: type: create required: [name, address] From 2b3df3e8ca5f1da4f831db614617154fa25c6681 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 30 Aug 2026 02:11:38 +1000 Subject: [PATCH 021/143] fix(schema): get_devsec_history SNMP from TimeStamp suffix (#87) INDEX hm2DevSecStatusIndex is not-accessible. 1.17 walks accessible hm2DevSecStatusTimeStamp and takes the history index from the suffix. MOPS/SSH unchanged. --- crude_engine/wire/diagnostic.yaml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crude_engine/wire/diagnostic.yaml b/crude_engine/wire/diagnostic.yaml index da04972..3ce2bcb 100644 --- a/crude_engine/wire/diagnostic.yaml +++ b/crude_engine/wire/diagnostic.yaml @@ -1385,8 +1385,16 @@ attributes: sources: snmp: read: - oid: 1.3.6.1.4.1.248.11.22.1.3.3.10.1.1 + # Accessible hm2DevSecStatusTimeStamp. INDEX hm2DevSecStatusIndex + # (.10.1.1) is not-accessible; 1.17 never walks it. Suffix is + # the history index (integer). + oid: 1.3.6.1.4.1.248.11.22.1.3.3.10.1.2 method: walk + index_fields: + - name: history_index + type: integer + key_field: history_index + value_from_index: history_index mops: read: mib: HM2-DIAGNOSTIC-MIB From e72870e5aa02ffdfb8d3a6faf76203c26ce819d5 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 30 Aug 2026 02:15:56 +1000 Subject: [PATCH 022/143] fix(schema): get_route_to SNMP dest from inetCidr suffix (#88) INDEX inetCidrRouteDest/NextHop are not-accessible. Walk accessible IfIndex and take dest and next_hop from the RFC 4292 suffix (length-prefixed InetAddress). IPv4 only. MOPS/SSH unchanged. --- crude_engine/wire/ip-forward.yaml | 44 +++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/crude_engine/wire/ip-forward.yaml b/crude_engine/wire/ip-forward.yaml index 7a57eca..5dbdbd9 100644 --- a/crude_engine/wire/ip-forward.yaml +++ b/crude_engine/wire/ip-forward.yaml @@ -92,8 +92,29 @@ attributes: sources: snmp: read: - oid: 1.3.6.1.2.1.4.24.7.1.2 + # Accessible inetCidrRouteIfIndex. INDEX inetCidrRouteDest + # (1.3.6.1.2.1.4.24.7.1.2) is not-accessible. Suffix is RFC 4292 + # destType.dest.pfxLen.policy.nextHopType.nextHop; dest/nextHop + # are length-prefixed InetAddress. IPv4 only (dest_type 1). + oid: 1.3.6.1.2.1.4.24.7.1.7 method: walk + index_fields: + - name: dest_type + type: integer + - name: destination + type: octet_string + - name: pfx_len + type: integer + - name: policy + type: octet_string + - name: nexthop_type + type: integer + - name: next_hop + type: octet_string + value_from_index: destination + value_format: ipv4 + index_filter: + dest_type: 1 mops: read: mib: IP-FORWARD-MIB @@ -248,8 +269,27 @@ attributes: sources: snmp: read: - oid: 1.3.6.1.2.1.4.24.7.1.6 + # INDEX inetCidrRouteNextHop is not-accessible. Same IfIndex walk + # as dest; next_hop comes from the suffix. + oid: 1.3.6.1.2.1.4.24.7.1.7 method: walk + index_fields: + - name: dest_type + type: integer + - name: destination + type: octet_string + - name: pfx_len + type: integer + - name: policy + type: octet_string + - name: nexthop_type + type: integer + - name: next_hop + type: octet_string + value_from_index: next_hop + value_format: ipv4 + index_filter: + dest_type: 1 mops: read: mib: IP-FORWARD-MIB From 33dead4a67e24d65902de7d9a0547f435d1230ae Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 30 Aug 2026 02:39:17 +1000 Subject: [PATCH 023/143] fix(test): parity-compare nested row fields that are not sub_tables (#90) _compare_flat skipped every dict/list default, so row fields like get_vlans.ports never appeared in parity_diffs. Skip only named sub_tables. Closes #89. --- tests/release_matrix.py | 70 ++++++++++++++++---- tests/test_parity_nested_fields.py | 101 +++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+), 13 deletions(-) create mode 100755 tests/test_parity_nested_fields.py diff --git a/tests/release_matrix.py b/tests/release_matrix.py index 500c090..f15406e 100644 --- a/tests/release_matrix.py +++ b/tests/release_matrix.py @@ -1069,9 +1069,43 @@ def _values_equal(a, b) -> bool: return False +def _compare_nested(diffs: list, pa: str, pb: str, va, vb, default_val, + path: str) -> None: + """Compare a row-level dict/list that is not a named sub_table.""" + if isinstance(default_val, dict): + da = va if isinstance(va, dict) else {} + db = vb if isinstance(vb, dict) else {} + if da == db: + return + if len(da) != len(db): + diffs.append(f"{path}: {pa} keys={len(da)} vs {pb} keys={len(db)}") + return + diffs.append( + f"{path}: {pa}={repr(da)[:40]} vs {pb}={repr(db)[:40]}" + ) + return + if isinstance(default_val, list): + la = va if isinstance(va, list) else [] + lb = vb if isinstance(vb, list) else [] + if la == lb: + return + if len(la) != len(lb): + diffs.append(f"{path}: {pa} len={len(la)} vs {pb} len={len(lb)}") + return + diffs.append( + f"{path}: {pa}={repr(la)[:40]} vs {pb}={repr(lb)[:40]}" + ) + + def _compare_flat(diffs: list, pa: str, pb: str, a: dict, b: dict, - defaults: dict, path: str = "") -> None: - """Compare two flat dicts field-by-field. Adds diffs in place. Caps at MAX.""" + defaults: dict, path: str = "", + skip_nested: set | None = None) -> None: + """Compare two dicts field-by-field. Adds diffs in place. Caps at MAX. + + Named sub_tables (skip_nested) are walked by the caller. Other dict/list + defaults are row-level nested fields and must compare (issue #89). + """ + skip_nested = skip_nested or set() if len(diffs) >= _MAX_DIFFS_PER_PAIR: return for field in defaults: @@ -1081,8 +1115,13 @@ def _compare_flat(diffs: list, pa: str, pb: str, a: dict, b: dict, if field in _PARITY_TIMING_FIELDS: continue default_val = defaults[field] - # Skip nested structures — caller handles sub_tables separately if isinstance(default_val, (dict, list)): + if field in skip_nested: + continue + _compare_nested( + diffs, pa, pb, a.get(field), b.get(field), default_val, + path=f"{path}{field}", + ) continue va = a.get(field) vb = b.get(field) @@ -1098,7 +1137,8 @@ def _compare_flat(diffs: list, pa: str, pb: str, a: dict, b: dict, def _compare_table(diffs: list, pa: str, pb: str, table_a: dict, table_b: dict, - defaults: dict, path: str = "") -> None: + defaults: dict, path: str = "", + skip_nested: set | None = None) -> None: """Compare two table dicts (keyed by row identity) row-by-row. Reports row keys present in one but not the other, then for common @@ -1137,7 +1177,8 @@ def _compare_table(diffs: list, pa: str, pb: str, row_b = table_b[row_key] if isinstance(row_a, dict) and isinstance(row_b, dict): _compare_flat(diffs, pa, pb, row_a, row_b, defaults, - path=f"{path}[{row_key}].") + path=f"{path}[{row_key}].", + skip_nested=skip_nested) elif row_a != row_b: diffs.append(f"{path}[{row_key}]: {pa}={repr(row_a)[:40]} " f"vs {pb}={repr(row_b)[:40]}") @@ -1148,8 +1189,9 @@ def _compute_parity(method_name: str, schema_meta: dict, """Compare read results across protocols for one method. Real cross-protocol value parity — not just row-count comparison. - Recursively descends into sub_tables. Compares every non-timing - scalar field across protocols. + Recursively descends into named sub_tables. Compares every non-timing + scalar field, plus row-level dict/list fields that are not named + sub_tables (issue #89). Returns a list of diff strings. Empty list = parity OK. """ @@ -1173,14 +1215,15 @@ def _compute_parity(method_name: str, schema_meta: dict, f"({type(a).__name__} vs {type(b).__name__})") continue + skip_nested = set(sub_tables) if pk: # Top-level dict IS a table keyed by row identity - _compare_table(diffs, pa, pb, a, b, defaults) + _compare_table(diffs, pa, pb, a, b, defaults, + skip_nested=skip_nested) elif sub_tables: # Flat globals + named sub_tables - # Compare top-level scalars - _compare_flat(diffs, pa, pb, a, b, defaults) - # Compare each sub_table separately + _compare_flat(diffs, pa, pb, a, b, defaults, + skip_nested=skip_nested) for st_name, st_def in sub_tables.items(): sa = a.get(st_name) sb = b.get(st_name) @@ -1192,8 +1235,9 @@ def _compute_parity(method_name: str, schema_meta: dict, diffs.append(f"{st_name}: {pa}={type(sa).__name__} " f"vs {pb}={type(sb).__name__}") else: - # Pure flat dict - _compare_flat(diffs, pa, pb, a, b, defaults) + # Pure flat dict (nested row fields still compare) + _compare_flat(diffs, pa, pb, a, b, defaults, + skip_nested=skip_nested) if len(diffs) >= _MAX_DIFFS_PER_PAIR: break diff --git a/tests/test_parity_nested_fields.py b/tests/test_parity_nested_fields.py new file mode 100755 index 0000000..9e813cd --- /dev/null +++ b/tests/test_parity_nested_fields.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""Offline fixtures for issue #89: nested row fields that are not sub_tables. + +No device. No SIDECAR_URL. Does not reopen #71/#73. +""" +from __future__ import annotations + +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) + +from release_matrix import _compute_parity # noqa: E402 + + +def test_get_vlans_ports_empty_vs_populated(): + meta = { + "defaults": {"name": "", "ports": {}}, + "primary_key": "vlan_id", + "sub_tables": {}, + } + results = { + "mops": {"1": {"name": "default", "ports": {"1/1": "U", "1/2": "T"}}}, + "snmp": {"1": {"name": "default", "ports": {"1/1": "U", "1/2": "T"}}}, + "ssh": {"1": {"name": "default", "ports": {}}}, + } + diffs = _compute_parity("get_vlans", meta, results) + joined = "\n".join(diffs) + assert any("ports" in d for d in diffs), diffs + assert "keys=" in joined, diffs + # scalars still equal — should not invent a name miss + assert not any("name:" in d for d in diffs), diffs + + +def test_get_vlan_egress_empty_list_vs_populated(): + meta = { + "defaults": {"egress_ports": [], "untagged_ports": []}, + "primary_key": "vlan_id", + "sub_tables": {}, + } + results = { + "mops": {"1": {"egress_ports": ["1/1"], "untagged_ports": []}}, + "snmp": {"1": {"egress_ports": ["1/1"], "untagged_ports": []}}, + "ssh": {"1": {"egress_ports": [], "untagged_ports": []}}, + } + diffs = _compute_parity("get_vlan_egress", meta, results) + assert any("egress_ports" in d and "len=" in d for d in diffs), diffs + assert not any("untagged_ports" in d for d in diffs), diffs + + +def test_named_sub_tables_not_double_compared_as_flat(): + meta = { + "defaults": {"enabled": True, "servers": {}}, + "sub_tables": {"servers": {"defaults": {"address": ""}}}, + } + results = { + "mops": {"enabled": True, "servers": {"1": {"address": "10.0.0.1"}}}, + "ssh": {"enabled": True, "servers": {"1": {"address": "10.0.0.1"}}}, + } + diffs = _compute_parity("get_dns", meta, results) + assert diffs == [], diffs + + +def test_named_sub_table_row_diffs_still_fire(): + meta = { + "defaults": {"enabled": True, "servers": {}}, + "sub_tables": {"servers": {"defaults": {"address": ""}}}, + } + results = { + "mops": {"enabled": True, "servers": {"1": {"address": "10.0.0.1"}}}, + "ssh": {"enabled": True, "servers": {"0": {"address": "10.0.0.1"}}}, + } + diffs = _compute_parity("get_dns", meta, results) + assert any("servers." in d for d in diffs), diffs + + +def main(): + tests = [ + test_get_vlans_ports_empty_vs_populated, + test_get_vlan_egress_empty_list_vs_populated, + test_named_sub_tables_not_double_compared_as_flat, + test_named_sub_table_row_diffs_still_fire, + ] + failed = 0 + for fn in tests: + try: + fn() + print(f"PASS {fn.__name__}") + except AssertionError as exc: + failed += 1 + print(f"FAIL {fn.__name__}: {exc}") + if failed: + print(f"{failed} nested-parity proof(s) failed") + return 1 + print("nested-parity proofs passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From ddcc67b9f07bb6427dc81900d9eabe97340eaa11 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 30 Aug 2026 01:23:50 +0000 Subject: [PATCH 024/143] fix(test): sidecar pick device by has_capable for named reads Named inspect used the first read-safe pool entry, so L3 methods hit an L2 box. Pick with the same generate_plan read resolver (feature in has_capable, read in safe_for). If none, 503 not_ready. Closes #91. #74 stays open. --- sidecar/README.md | 3 + sidecar/app.py | 63 +++++++++-- tests/test_sidecar_pick_device.py | 167 ++++++++++++++++++++++++++++++ 3 files changed, 222 insertions(+), 11 deletions(-) create mode 100755 tests/test_sidecar_pick_device.py diff --git a/sidecar/README.md b/sidecar/README.md index 995fe18..e115a35 100644 --- a/sidecar/README.md +++ b/sidecar/README.md @@ -23,6 +23,9 @@ Documented in `tests/README_TESTS.md` (use `--inspect`, never throwaway Device IP comes from the sidecar machine's gitignored `tests/device_pool.yaml`. Never from the request body. Never from git. +Picker is the same read resolver as `generate_plan`: feature ∈ +`has_capable` and `read` ∈ `safe_for`. First match. If none, HTTP 503 +`not_ready` (no SSH hang). Example pool host is TEST-NET `192.0.2.10` in `tests/device_pool.yaml.example`. Passwords from the environment (`CRUDE_DEVICE_PASSWORD`). diff --git a/sidecar/app.py b/sidecar/app.py index 8fac8cd..5d4a73e 100644 --- a/sidecar/app.py +++ b/sidecar/app.py @@ -92,15 +92,38 @@ def name_to_inspect(name, entry): return method -def pick_device_ip(): - """First read-safe device in the local gitignored pool. None if absent.""" +def _load_pool_devices(): + """Local gitignored pool. Empty if the file is absent.""" if yaml is None or not POOL_PATH.is_file(): - return None + return [] data = yaml.safe_load(POOL_PATH.read_text()) or {} - for dev in data.get("devices") or []: - safe = dev.get("safe_for") or [] + return list(data.get("devices") or []) + + +def _matches_read(dev, feature): + """Same read resolver as generate_plan / _device_matches kind=read.""" + tests_dir = str(ROOT / "tests") + if tests_dir not in sys.path: + sys.path.insert(0, tests_dir) + from release_matrix import _device_matches + return _device_matches(dev, feature, "read") + + +def pick_device_ip(feature, devices=None): + """First pool device with feature in has_capable and read in safe_for. + + Same rule as generate_plan. First match. None if none qualify. + """ + if devices is None: + devices = _load_pool_devices() + if not feature: + return None + for dev in devices: ip = dev.get("ip") - if ip and (not safe or "read" in safe): + if not ip: + continue + ok, _reason = _matches_read(dev, feature) + if ok: return str(ip) return None @@ -206,14 +229,32 @@ def handle_run(payload): if not method: return 400, {"error": "bad_name", "message": "name does not map to --method", "name": name} - device = pick_device_ip() - if transport() not in ("fake", "offline") and not device: - return 503, { - "error": "not_ready", - "message": "no local tests/device_pool.yaml (gitignored)", + feature = entry.get("feature") + if not feature: + return 400, { + "error": "bad_name", + "message": "catalog entry has no feature", "name": name, } + device = pick_device_ip(feature) + if transport() not in ("fake", "offline"): + if not POOL_PATH.is_file(): + return 503, { + "error": "not_ready", + "message": "no local tests/device_pool.yaml (gitignored)", + "name": name, + } + if not device: + return 503, { + "error": "not_ready", + "message": ( + f"no eligible device: {feature} not in has_capable " + "or read not in safe_for" + ), + "name": name, + } + protocol = os.environ.get("CRUDE_SIDECAR_PROTOCOL") or None if not LOCK.acquire(): diff --git a/tests/test_sidecar_pick_device.py b/tests/test_sidecar_pick_device.py new file mode 100755 index 0000000..d209a67 --- /dev/null +++ b/tests/test_sidecar_pick_device.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +"""Offline fixtures for issue #91: sidecar pick by has_capable. + +TEST-NET addresses only. Does not read the live gitignored pool. +Does not add hosts. Does not call a switch. #74 stays open. +""" +from __future__ import annotations + +import os +import sys +import tempfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +os.environ["CRUDE_SIDECAR_MODE"] = "read-only" +os.environ["CRUDE_SIDECAR_TRANSPORT"] = "fake" +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from sidecar.app import handle_run, pick_device_ip # noqa: E402 +import sidecar.app as sidecar_app # noqa: E402 + +L2 = { + "ip": "192.0.2.10", + "label": "example-l2", + "safe_for": ["read"], + "has_capable": ["vlan", "dns"], +} +L3 = { + "ip": "192.0.2.20", + "label": "example-l3", + "safe_for": ["read"], + "has_capable": ["vrrp", "route", "router"], +} +L3_NO_READ = { + "ip": "192.0.2.30", + "label": "example-l3-noread", + "safe_for": ["setter"], + "has_capable": ["vrrp"], +} + + +def test_vrrp_skips_first_l2(): + picked = pick_device_ip("vrrp", [L2, L3]) + assert picked == "192.0.2.20", picked + + +def test_dns_still_first_match(): + picked = pick_device_ip("dns", [L2, L3]) + assert picked == "192.0.2.10", picked + + +def test_no_capable_returns_none(): + assert pick_device_ip("vrrp", [L2]) is None + assert pick_device_ip("vrrp", [L2, L3_NO_READ]) is None + + +def test_route_and_router_same_rule(): + assert pick_device_ip("route", [L2, L3]) == "192.0.2.20" + assert pick_device_ip("router", [L2, L3]) == "192.0.2.20" + assert pick_device_ip("route", [L2]) is None + + +def test_handle_run_no_eligible_does_not_inspect(): + prev_pool = sidecar_app.POOL_PATH + prev_inspect = sidecar_app.call_inspect + prev_transport = os.environ.get("CRUDE_SIDECAR_TRANSPORT") + called = [] + + def boom(*_a, **_k): + called.append(True) + raise AssertionError("inspect must not run when no eligible device") + + tmp = Path(tempfile.mkdtemp()) / "device_pool.yaml" + tmp.write_text( + "devices:\n" + " - ip: 192.0.2.10\n" + " label: example-l2\n" + " safe_for: [read]\n" + " has_capable: [vlan, dns]\n" + ) + sidecar_app.POOL_PATH = tmp + sidecar_app.call_inspect = boom + os.environ["CRUDE_SIDECAR_TRANSPORT"] = "live" + try: + code, body = handle_run({"name": "get_vrrp_instances.read"}) + finally: + sidecar_app.POOL_PATH = prev_pool + sidecar_app.call_inspect = prev_inspect + if prev_transport is None: + os.environ.pop("CRUDE_SIDECAR_TRANSPORT", None) + else: + os.environ["CRUDE_SIDECAR_TRANSPORT"] = prev_transport + + assert called == [], called + assert code == 503, (code, body) + assert body.get("error") == "not_ready", body + msg = body.get("message") or "" + assert "has_capable" in msg and "vrrp" in msg, body + assert "192." not in msg, body + + +def test_handle_run_picks_l3_for_vrrp(): + prev_pool = sidecar_app.POOL_PATH + prev_inspect = sidecar_app.call_inspect + prev_transport = os.environ.get("CRUDE_SIDECAR_TRANSPORT") + seen = [] + + def fake_inspect(method, device, protocol=None, trace=False): + seen.append((method, device)) + return {"exit": 0, "fake": True, "protocols": {}, "parity_diffs": []} + + tmp = Path(tempfile.mkdtemp()) / "device_pool.yaml" + tmp.write_text( + "devices:\n" + " - ip: 192.0.2.10\n" + " label: example-l2\n" + " safe_for: [read]\n" + " has_capable: [vlan, dns]\n" + " - ip: 192.0.2.20\n" + " label: example-l3\n" + " safe_for: [read]\n" + " has_capable: [vrrp, route, router]\n" + ) + sidecar_app.POOL_PATH = tmp + sidecar_app.call_inspect = fake_inspect + os.environ["CRUDE_SIDECAR_TRANSPORT"] = "live" + try: + code, body = handle_run({"name": "get_vrrp_instances.read"}) + finally: + sidecar_app.POOL_PATH = prev_pool + sidecar_app.call_inspect = prev_inspect + if prev_transport is None: + os.environ.pop("CRUDE_SIDECAR_TRANSPORT", None) + else: + os.environ["CRUDE_SIDECAR_TRANSPORT"] = prev_transport + + assert code == 200, (code, body) + assert seen == [("get_vrrp_instances", "192.0.2.20")], seen + + +def main(): + tests = [ + test_vrrp_skips_first_l2, + test_dns_still_first_match, + test_no_capable_returns_none, + test_route_and_router_same_rule, + test_handle_run_no_eligible_does_not_inspect, + test_handle_run_picks_l3_for_vrrp, + ] + failed = 0 + for fn in tests: + try: + fn() + print(f"PASS {fn.__name__}") + except AssertionError as exc: + failed += 1 + print(f"FAIL {fn.__name__}: {exc}") + if failed: + print(f"{failed} pick-device proof(s) failed") + return 1 + print("pick-device proofs passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 30eac540f1ac514a2678864b2673954c9095bcdb Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 30 Aug 2026 01:28:02 +0000 Subject: [PATCH 025/143] fix(schema): SSH sw_major captures only the major number show config profiles nvm col 4 is one SW-Rel token (010.3.4). sw_major regex was writing the full version into the major scalar, so compute glued 10.3.04.3.04. Capture 0*(\d+) only; leave compute. --- crude_engine/wire/ssh/filemgmt.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crude_engine/wire/ssh/filemgmt.yaml b/crude_engine/wire/ssh/filemgmt.yaml index 553f5af..6389619 100644 --- a/crude_engine/wire/ssh/filemgmt.yaml +++ b/crude_engine/wire/ssh/filemgmt.yaml @@ -25,7 +25,7 @@ attributes: hm2fmprofileswmajorrelnum: sources: ssh: - read: {command: "show config profiles nvm", parser: paired_rows, lines_per_record: 3, line: 0, column: 4, regex: '0*(\d+)\.(\d+)\.(\d+)', regex_format: "{0}.{1}.{2:0>2}", tag: to_str} + read: {command: "show config profiles nvm", parser: paired_rows, lines_per_record: 3, line: 0, column: 4, regex: '0*(\d+)\.\d+\.\d+', tag: to_str} hm2fmprofileswminorrelnum: sources: From 862db48ebca45edc605ee90066b574bb34e76219 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 30 Aug 2026 03:33:22 +0000 Subject: [PATCH 026/143] docs: retarget base.py module docstring to crude-engine --- crude_engine/drivers/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crude_engine/drivers/base.py b/crude_engine/drivers/base.py index cd6355f..398d859 100644 --- a/crude_engine/drivers/base.py +++ b/crude_engine/drivers/base.py @@ -1,5 +1,5 @@ """ -base.py — Base driver interface for napalm-hios. +base.py — Base driver interface for crude-engine. Layer: Driver (abstract). The contract between engine and transport. Engine calls gather() and set_values(). Driver calls transport. Nothing From f51107380b2532421b88a42af7790876c209d1c4 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 30 Aug 2026 03:33:24 +0000 Subject: [PATCH 027/143] docs: retarget ssh_driver.py module docstring to crude-engine --- crude_engine/drivers/ssh_driver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crude_engine/drivers/ssh_driver.py b/crude_engine/drivers/ssh_driver.py index ae5afa7..fa62052 100644 --- a/crude_engine/drivers/ssh_driver.py +++ b/crude_engine/drivers/ssh_driver.py @@ -1,5 +1,5 @@ """ -SSH_gather.py — SSH protocol driver for napalm-hios. +SSH_gather.py — SSH protocol driver for crude-engine. Layer: Driver. Translates wire YAML source dicts into SSH CLI operations. Owns: command dedup, level navigation, CLI parsing, response caching. From 70291e7b5218ed3d6863d2f4f59aae931a89cc77 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 30 Aug 2026 03:33:25 +0000 Subject: [PATCH 028/143] docs: retarget offline_hios.py module docstring to crude-engine --- crude_engine/drivers/offline_hios.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crude_engine/drivers/offline_hios.py b/crude_engine/drivers/offline_hios.py index 844845b..293f906 100644 --- a/crude_engine/drivers/offline_hios.py +++ b/crude_engine/drivers/offline_hios.py @@ -1,5 +1,5 @@ """ -offline_hios.py — Offline transport for napalm-hios. +offline_hios.py — Offline transport for crude-engine. Layer: Transport. Loads config XML files via MOPS interface. Inherits MOPSHIOS — offline uses the same driver/engine path as MOPS. From d2d4ce70066587025bb6486529132f3f3a64e308 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 30 Aug 2026 03:33:27 +0000 Subject: [PATCH 029/143] docs: retarget snmp_transport.py module docstring to crude-engine --- crude_engine/drivers/snmp_transport.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crude_engine/drivers/snmp_transport.py b/crude_engine/drivers/snmp_transport.py index 2a12583..dcbdd23 100644 --- a/crude_engine/drivers/snmp_transport.py +++ b/crude_engine/drivers/snmp_transport.py @@ -1,5 +1,5 @@ """ -snmp_transport.py — SNMP transport for napalm-hios. +snmp_transport.py — SNMP transport for crude-engine. Layer: Transport. Owns session, auth, and raw OID GET/SET/WALK. Cannot: interpret data meaning, decide what to gather, know about features. From 5979fd0ff400bc8099125a32ac2b48709eee0251 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 30 Aug 2026 03:33:28 +0000 Subject: [PATCH 030/143] docs: retarget mops_transport.py module docstring to crude-engine --- crude_engine/drivers/mops_transport.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crude_engine/drivers/mops_transport.py b/crude_engine/drivers/mops_transport.py index 7902012..ae85ac4 100644 --- a/crude_engine/drivers/mops_transport.py +++ b/crude_engine/drivers/mops_transport.py @@ -1,5 +1,5 @@ """ -mops_transport.py — MOPS transport for napalm-hios. +mops_transport.py — MOPS transport for crude-engine. Layer: Transport. Owns HTTPS session and raw MIB operations via XML. Cannot: interpret data meaning, decide what to gather, know about features. From 455d93ed72897c4b3e14361b5012daaff4657999 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 30 Aug 2026 03:33:30 +0000 Subject: [PATCH 031/143] docs: retarget mops_driver.py module docstring to crude-engine --- crude_engine/drivers/mops_driver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crude_engine/drivers/mops_driver.py b/crude_engine/drivers/mops_driver.py index a59deba..1cdcc3a 100644 --- a/crude_engine/drivers/mops_driver.py +++ b/crude_engine/drivers/mops_driver.py @@ -1,5 +1,5 @@ """ -MOPS.py — MOPS protocol driver for napalm-hios. +MOPS.py — MOPS protocol driver for crude-engine. Layer: Driver. Translates wire YAML source dicts into MOPS operations. Owns: get_multi batching, index keying, row filtering, tag dispatch. From 00a34e903ecdce0cedc79eb4447ab00f68dbd6fc Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 30 Aug 2026 03:33:31 +0000 Subject: [PATCH 032/143] docs: retarget snmp_driver.py module docstring to crude-engine --- crude_engine/drivers/snmp_driver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crude_engine/drivers/snmp_driver.py b/crude_engine/drivers/snmp_driver.py index 11ecb5f..7510cc6 100644 --- a/crude_engine/drivers/snmp_driver.py +++ b/crude_engine/drivers/snmp_driver.py @@ -1,5 +1,5 @@ """ -SNMP.py — SNMP protocol driver for napalm-hios. +SNMP.py — SNMP protocol driver for crude-engine. Layer: Driver. Translates wire YAML source dicts into SNMP operations. Owns: walk batching, scalar normalization, index decomposition, tag dispatch. From b032aac034629c2669a0dc4d46ae4d474840d2ba Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 30 Aug 2026 03:33:33 +0000 Subject: [PATCH 033/143] docs: retarget mops_client.py module docstring to crude-engine --- crude_engine/drivers/mops_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crude_engine/drivers/mops_client.py b/crude_engine/drivers/mops_client.py index a1dc03e..a56b1f1 100644 --- a/crude_engine/drivers/mops_client.py +++ b/crude_engine/drivers/mops_client.py @@ -7,7 +7,7 @@ - HTTP Basic auth (same credentials as SSH/SNMP) - No net-snmp/pysnmp dependency — just requests + xml.etree -Adapted from Hirschy-MOPS/lib/mops.py for use as a napalm-hios transport. +Adapted from Hirschy-MOPS/lib/mops.py for use as a crude-engine transport. Usage: from crude_engine.drivers.mops_client import MOPSClient From f2c4748f4d0b92d52d5b269632d8c16289cfe42c Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 30 Aug 2026 03:42:12 +0000 Subject: [PATCH 034/143] docs: live authored leftovers no longer treat CLAUDE.md and adamr paths as current Closes #97 AGENTS.md is the only root agent law. Leftover Claude is archive, not live law. Retarget adamr napalm-hios paths and the pip install napalm-hios PyPI ceiling. Label docs/napalm-hios-2-6-schema.md as a historical leftover filename. --- README.md | 6 ++++-- docs/DIAGNOSTIC_PROCESS.md | 2 +- docs/RELEASE_GATE.md | 4 ++-- docs/WIRE_SPEC.md | 2 +- tests/README_TESTS.md | 7 +++---- 5 files changed, 11 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index cee6acb..8399a83 100644 --- a/README.md +++ b/README.md @@ -20,11 +20,13 @@ No feature-specific Python. Wire YAMLs (generated from MIB) declare device truth ## Installation +Not on PyPI yet (first publish is 2.10.0). From this repo: + ``` -pip install crude-engine +pip install -e . ``` -For NAPALM integration: `pip install napalm-hios` (installs crude-engine as a dependency). +NAPALM integration is the separate `napalm-hios` 2.0 shim — not on PyPI yet. ## Usage diff --git a/docs/DIAGNOSTIC_PROCESS.md b/docs/DIAGNOSTIC_PROCESS.md index d2c560a..8252d3c 100644 --- a/docs/DIAGNOSTIC_PROCESS.md +++ b/docs/DIAGNOSTIC_PROCESS.md @@ -22,7 +22,7 @@ Check against MIB source (`local/reference/MIBs/`) and MOPS schema (`local/refer ### Step 5: v1 reference -How did v1 (`/home/adamr/obsidian-vault/Projects/napalm-hios/`) handle this table? Not to copy code, but to understand what encoding/sequence the device expects. v1's working code is empirical proof of what the wire needs. +How did historical v1 (old napalm-hios monolith — a separate tree, not this repo) handle this table? Not to copy code, but to understand what encoding/sequence the device expects. v1's working code is empirical proof of what the wire needs. This product is this repo (`crude-engine`); do not assume a machine-local homelab path. ### Step 6: Fix the declaration, not the engine diff --git a/docs/RELEASE_GATE.md b/docs/RELEASE_GATE.md index a68934f..7707d6f 100644 --- a/docs/RELEASE_GATE.md +++ b/docs/RELEASE_GATE.md @@ -5,7 +5,7 @@ ## Why this doc exists -We are preparing crude-engine for its first real release. The work plan, the matrix tool design, the cross-reference scheme, and the exit criteria all live here so a fresh session can pick up without re-deriving the plan from archived leftover Claude (`local/archive/docs-legacy/claude/CLAUDE.md`) and stale TODO files. +We are preparing crude-engine for its first real release. The work plan, the matrix tool design, the cross-reference scheme, and the exit criteria all live here so a fresh session can pick up from this doc and `AGENTS.md` (the only root agent law), not leftover Claude. Leftover Claude lives at `local/archive/docs-legacy/claude/CLAUDE.md` (archive, not law). Do not update a live `CLAUDE.md`. Stale TODO files are hints, not facts. The old `docs/TODO.md` and `docs/ROADMAP.md` have been renamed to `TODO-old.md` and `ROADMAP-old.md`. **They are not trusted.** Anything in them is a hint, not a fact. New `TODO.md` and `ROADMAP.md` will be generated from matrix tool output and reviewed by the user. @@ -874,7 +874,7 @@ Scale-out path (post-release): if test_replay fixture mode is added, fixture-bas - [x] Rename `TODO.md` / `ROADMAP.md` → `-old` variants - [x] Write `tests/README_TESTS.md` — script catalog - [x] Write `docs/RELEASE_GATE.md` (this doc) -- [x] Update `CLAUDE.md` (now `local/archive/docs-legacy/claude/CLAUDE.md`) with tag scheme + RELEASE_GATE pointer + comms-loss rule +- [x] Recorded tag scheme + RELEASE_GATE pointer + comms-loss rule in leftover Claude archive (`local/archive/docs-legacy/claude/CLAUDE.md`) — archive, not law. Do not update a live `CLAUDE.md`; `AGENTS.md` is the only root agent law. - [x] Refactor `audit_getters_v2.py` to expose `run_one_read(device, method, schema)`. Existing CLI unchanged. - [x] Refactor `test_setter_pairs.py` to expose `run_one_setter(device, name, spec)`. Existing CLI unchanged. - [x] Refactor `test_crud_pairs.py` to expose `run_one_crud(device, name, spec)`. Existing CLI unchanged. diff --git a/docs/WIRE_SPEC.md b/docs/WIRE_SPEC.md index 4b6b133..82d6bc8 100644 --- a/docs/WIRE_SPEC.md +++ b/docs/WIRE_SPEC.md @@ -14,7 +14,7 @@ Wire YAMLs are **machine-generated** from MIB XML + MOPS webUI proxy captures. T | Overrides | `local/generator/overrides.yaml` | Manual corrections (create_method, type) | | MIB source | `local/reference/MIBs/` | 66 firmware MIB files | | MIB schema | `local/reference/MOPS/mops_hios.xml` | MOPS MIB tree for OID/table resolution | -| Master schema doc | `docs/napalm-hios-2-6-schema.md` | 4,058 attribute reference (1.3M) | +| Historical attribute reference (leftover filename) | `docs/napalm-hios-2-6-schema.md` | 4,058 attribute dump (1.3M). Filename is leftover; not the live product name. Live schema contracts are `crude_engine/schemas/*.yaml`. | ## The Three-File Model diff --git a/tests/README_TESTS.md b/tests/README_TESTS.md index c6b8296..da080fd 100644 --- a/tests/README_TESTS.md +++ b/tests/README_TESTS.md @@ -2,7 +2,7 @@ > One-pager. Every script in `tests/` listed once with: what it does, when to use it, > what NOT to use it for, and how to invoke it. Read this before adding new test code. -> Linked from `AGENTS.md` (standing law) and `docs/RELEASE_GATE.md`. Leftover Claude: `local/archive/docs-legacy/claude/CLAUDE.md`. +> Linked from `AGENTS.md` (the only root agent law) and `docs/RELEASE_GATE.md`. Leftover Claude is archive, not law: `local/archive/docs-legacy/claude/CLAUDE.md`. ## TL;DR — which script for which job @@ -529,9 +529,8 @@ declaration get a no-op wrap (run normally). /tmp/crude-engine/.venv/bin/python3 tests/