netdryrun 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
netdryrun/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """NetDryRun: test network changes in a real virtual lab before they touch production."""
2
+
3
+ __version__ = "0.1.0"
netdryrun/checks.py ADDED
@@ -0,0 +1,164 @@
1
+ """Run behavioral checks.
2
+
3
+ Positive checks poll until they pass (networks need time to converge), then must pass again on
4
+ consecutive samples so a flapping session can't pass on one lucky sample. Negative checks run
5
+ after convergence and must hold for a whole window.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import time
10
+ from dataclasses import dataclass, field
11
+ from datetime import datetime, timezone
12
+
13
+ from .config import Check
14
+ from .drivers import Driver
15
+
16
+
17
+ @dataclass
18
+ class Result:
19
+ check: Check
20
+ passed: bool
21
+ detail: str
22
+ attempts: int
23
+ duration: float
24
+ started: str
25
+ evidence: list[dict] = field(default_factory=list) # raw outputs from the final attempt
26
+ history: list[dict] = field(default_factory=list) # every sample: time offset, pass/fail, detail
27
+
28
+
29
+ def _bgp_session(d: Driver, p: dict) -> tuple[bool, str, list]:
30
+ obs = d.bgp_neighbors(p.get("vrf", "default"))
31
+ n = obs.value.get(p["neighbor"])
32
+ if n is None:
33
+ return False, f"neighbor {p['neighbor']} not configured (seen: {', '.join(obs.value) or 'none'})", obs.evidence
34
+ return n.state == p["state"], f"state is {n.state}", obs.evidence
35
+
36
+
37
+ def _bgp_prefixes(d: Driver, p: dict) -> tuple[bool, str, list]:
38
+ obs = d.bgp_neighbors(p.get("vrf", "default"))
39
+ n = obs.value.get(p["neighbor"])
40
+ if n is None:
41
+ return False, f"neighbor {p['neighbor']} not configured", obs.evidence
42
+ if n.state != "established":
43
+ # counters reset to 0 when a session drops (e.g. a max-prefix trip), so a count
44
+ # from a down session would satisfy "max" by accident
45
+ return False, f"session is {n.state}; prefix counts are only meaningful when established", obs.evidence
46
+ afi = p.get("afi", "ipv4-unicast")
47
+ count = n.prefixes.get(afi, 0) # established but the family reports nothing: zero prefixes
48
+ if "exact" in p:
49
+ ok = count == p["exact"]
50
+ else:
51
+ ok = count >= p.get("min", 0) and ("max" not in p or count <= p["max"])
52
+ return ok, f"{count} {afi} prefixes accepted", obs.evidence
53
+
54
+
55
+ def _route(d: Driver, p: dict) -> tuple[bool, str, list]:
56
+ obs = d.routes(p["prefix"], p.get("vrf", "default"))
57
+ want_proto = str(p.get("protocol", "")).lower()
58
+ matching = [r for r in obs.value if not want_proto or r.protocol == want_proto]
59
+ active = [r for r in matching if r.active]
60
+ if p.get("present", True):
61
+ if active:
62
+ return True, f"active via {active[0].protocol}", obs.evidence
63
+ seen = ", ".join(f"{r.protocol}{'' if r.active else ' (inactive)'}" for r in obs.value) or "nothing"
64
+ return False, f"not active{' via ' + want_proto if want_proto else ''}; seen: {seen}", obs.evidence
65
+ # Absent means not in the routing table at all: a received-but-not-selected route
66
+ # (e.g. a leaked default losing to a local one) is still a leak.
67
+ if matching:
68
+ r = matching[0]
69
+ return False, f"unexpectedly present via {r.protocol}{'' if r.active else ' (not selected)'}", obs.evidence
70
+ return True, "absent", obs.evidence
71
+
72
+
73
+ def _ping(d: Driver, p: dict) -> tuple[bool, str, list]:
74
+ count = int(p.get("count", 3))
75
+ obs = d.ping(p["target"], p.get("source"), count, p.get("vrf", "default"))
76
+ sent, received = obs.value
77
+ if sent == 0: # ping never ran properly (bad source address, missing VRF, ...): not a result
78
+ err = obs.evidence[-1].get("stderr", "").strip() if obs.evidence else ""
79
+ return False, f"error: ping did not run{': ' + err if err else ''}", obs.evidence
80
+ if p.get("expect", "success") == "success":
81
+ need = int(p.get("min_replies", max(1, sent - 1))) # tolerate one lost packet (first-packet ARP)
82
+ return received >= need, f"{received}/{sent} replies (need {need})", obs.evidence
83
+ return received == 0, f"{received}/{sent} replies", obs.evidence
84
+
85
+
86
+ EVALUATORS = {"bgp_session": _bgp_session, "bgp_prefixes": _bgp_prefixes, "route": _route, "ping": _ping}
87
+
88
+
89
+ def is_post_convergence(check: Check) -> bool:
90
+ """Checks that are trivially true before the network converges, so they must run afterwards
91
+ and hold: "must be absent", "must fail", a session expected NOT established, and upper bounds
92
+ on prefix counts (0 prefixes satisfies "max: 10" while BGP is still coming up)."""
93
+ p = check.params
94
+ return (
95
+ (check.type == "route" and p.get("present", True) is False)
96
+ or (check.type == "ping" and p.get("expect", "success") == "failure")
97
+ or (check.type == "bgp_session" and p.get("state", "established") != "established")
98
+ or (check.type == "bgp_prefixes" and ("max" in p or "exact" in p))
99
+ )
100
+
101
+
102
+ def _evaluate(check: Check, driver: Driver) -> tuple[bool, str, list]:
103
+ try:
104
+ return EVALUATORS[check.type](driver, check.params)
105
+ except Exception as e: # a crashed check is a failed check, with the reason kept
106
+ return False, f"error: {e}", []
107
+
108
+
109
+ def run_check(check: Check, driver: Driver, interval: float = 3.0, confirm: int = 2,
110
+ clock=time.monotonic, sleep=time.sleep) -> Result:
111
+ """Positive check: poll until it passes `confirm` samples in a row, or the timeout expires."""
112
+ started = datetime.now(timezone.utc).isoformat(timespec="seconds")
113
+ t0 = clock()
114
+ history: list[dict] = []
115
+ streak = 0
116
+ while True:
117
+ ok, detail, evidence = _evaluate(check, driver)
118
+ elapsed = clock() - t0
119
+ history.append({"t": round(elapsed, 1), "passed": ok, "detail": detail})
120
+ streak = streak + 1 if ok else 0
121
+ if streak >= confirm or elapsed >= check.timeout:
122
+ passed = streak >= confirm
123
+ if not passed and ok:
124
+ detail = f"{detail}, but not stable ({streak}/{confirm} consecutive passes before timeout)"
125
+ elif not passed and any(h["passed"] for h in history):
126
+ detail = f"{detail} (flapping: passed {sum(h['passed'] for h in history)} of {len(history)} samples)"
127
+ return Result(check, passed, detail, len(history), round(elapsed, 1), started, evidence, history[-50:])
128
+ sleep(min(interval, max(0.0, check.timeout - elapsed)))
129
+
130
+
131
+ def hold_check(check: Check, driver: Driver, hold: float = 10.0, interval: float = 3.0,
132
+ clock=time.monotonic, sleep=time.sleep) -> Result:
133
+ """Negative check: must stay true for the whole hold window; fails at the first violation."""
134
+ started = datetime.now(timezone.utc).isoformat(timespec="seconds")
135
+ t0 = clock()
136
+ history: list[dict] = []
137
+ while True:
138
+ ok, detail, evidence = _evaluate(check, driver)
139
+ elapsed = clock() - t0
140
+ history.append({"t": round(elapsed, 1), "passed": ok, "detail": detail})
141
+ if not ok or elapsed >= hold:
142
+ if ok:
143
+ detail = f"{detail} (held {round(elapsed)}s, {len(history)} samples)"
144
+ return Result(check, ok, detail, len(history), round(elapsed, 1), started, evidence, history[-50:])
145
+ sleep(min(interval, max(0.0, hold - elapsed)))
146
+
147
+
148
+ def converge_then_hold(check: Check, driver: Driver, hold: float = 10.0, interval: float = 3.0,
149
+ clock=time.monotonic, sleep=time.sleep) -> Result:
150
+ """Post-convergence check: reach the expected state (e.g. prefix count up to min), then hold it.
151
+
152
+ Only prefix counts need the "reach" phase (counters ramp up). For absent routes, failing pings
153
+ and down sessions, a violation seen after convergence is a real failure: fail fast."""
154
+ if check.type != "bgp_prefixes":
155
+ return hold_check(check, driver, hold=check.hold or hold, interval=interval, clock=clock, sleep=sleep)
156
+ first = run_check(check, driver, interval=interval, confirm=1, clock=clock, sleep=sleep)
157
+ if not first.passed:
158
+ return first
159
+ held = hold_check(check, driver, hold=check.hold or hold, interval=interval, clock=clock, sleep=sleep)
160
+ held.started = first.started
161
+ held.duration = round(first.duration + held.duration, 1)
162
+ held.attempts += first.attempts
163
+ held.history = (first.history + held.history)[-50:]
164
+ return held
netdryrun/cli.py ADDED
@@ -0,0 +1,88 @@
1
+ """Command-line interface: netdryrun run | check | validate."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import os
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from . import __version__, report
10
+ from .config import ConfigError, load
11
+ from .runner import run
12
+
13
+
14
+ def _write_outputs(rep, args) -> None:
15
+ outputs = {"json": report.to_json, "markdown": report.to_markdown, "junit": report.to_junit}
16
+ for key, render in outputs.items():
17
+ path = getattr(args, key, None)
18
+ if path:
19
+ Path(path).write_text(render(rep), encoding="utf-8")
20
+ print(f" wrote {key} report: {path}")
21
+ summary = os.environ.get("GITHUB_STEP_SUMMARY")
22
+ if summary and not args.no_step_summary:
23
+ with open(summary, "a", encoding="utf-8") as f:
24
+ f.write(report.to_markdown(rep))
25
+
26
+
27
+ def _cmd_run(args, deploy: bool) -> int:
28
+ try:
29
+ cfg = load(args.path)
30
+ except ConfigError as e:
31
+ print(f"config error: {e}", file=sys.stderr)
32
+ return 2
33
+ print(f"NetDryRun {__version__}: {len(cfg.checks)} checks from {cfg.path}")
34
+ rep = run(cfg, deploy=deploy, keep=args.keep, keep_on_failure=getattr(args, 'keep_on_failure', False),
35
+ settle=args.settle, hold=args.hold,
36
+ on_result=lambda r: print(report.console_line(r), flush=True),
37
+ log=lambda m: print(m, flush=True))
38
+ print(report.summary_line(rep))
39
+ _write_outputs(rep, args)
40
+ return rep.exit_code
41
+
42
+
43
+ def _cmd_validate(args) -> int:
44
+ try:
45
+ cfg = load(args.path)
46
+ except ConfigError as e:
47
+ print(f"config error: {e}", file=sys.stderr)
48
+ return 2
49
+ print(f"OK: {cfg.path} ({len(cfg.checks)} checks, lab {cfg.lab})")
50
+ for c in cfg.checks:
51
+ print(f" - [{c.type}] {c.name}")
52
+ return 0
53
+
54
+
55
+ def main(argv: list[str] | None = None) -> int:
56
+ ap = argparse.ArgumentParser(prog="netdryrun", description="Test network changes in a real virtual lab before production.")
57
+ ap.add_argument("--version", action="version", version=f"netdryrun {__version__}")
58
+ sub = ap.add_subparsers(dest="cmd", required=True)
59
+
60
+ def add_run_opts(p):
61
+ p.add_argument("path", nargs="?", default=".", help="netdryrun.yml or the folder containing it")
62
+ p.add_argument("--json", metavar="FILE", help="write the full evidence report as JSON")
63
+ p.add_argument("--markdown", metavar="FILE", help="write a Markdown summary (for PR comments)")
64
+ p.add_argument("--junit", metavar="FILE", help="write JUnit XML (for CI test reporting)")
65
+ p.add_argument("--settle", type=float, default=10.0, help="seconds to wait before negative checks (default 10)")
66
+ p.add_argument("--hold", type=float, default=10.0, help="seconds a negative check must hold (default 10)")
67
+ p.add_argument("--no-step-summary", action="store_true", help="don't append to $GITHUB_STEP_SUMMARY")
68
+
69
+ p_run = sub.add_parser("run", help="deploy the lab, run checks, destroy the lab")
70
+ add_run_opts(p_run)
71
+ p_run.add_argument("--keep", action="store_true", help="leave the lab running afterwards (for debugging)")
72
+ p_run.add_argument("--keep-on-failure", action="store_true", help="leave the lab running only if a check fails")
73
+
74
+ p_check = sub.add_parser("check", help="run checks against an already running lab")
75
+ add_run_opts(p_check)
76
+ p_check.set_defaults(keep=True)
77
+
78
+ p_val = sub.add_parser("validate", help="validate netdryrun.yml without running anything")
79
+ p_val.add_argument("path", nargs="?", default=".")
80
+
81
+ args = ap.parse_args(argv)
82
+ if args.cmd == "validate":
83
+ return _cmd_validate(args)
84
+ return _cmd_run(args, deploy=args.cmd == "run")
85
+
86
+
87
+ if __name__ == "__main__":
88
+ sys.exit(main())
netdryrun/config.py ADDED
@@ -0,0 +1,142 @@
1
+ """Load and validate netdryrun.yml."""
2
+ from __future__ import annotations
3
+
4
+ import ipaddress
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import yaml
10
+
11
+ CHECK_TYPES = {
12
+ "bgp_session": {"required": ("node", "neighbor"), "optional": ("state", "vrf")},
13
+ "bgp_prefixes": {"required": ("node", "neighbor"), "optional": ("min", "max", "exact", "afi", "vrf")},
14
+ "route": {"required": ("node", "prefix"), "optional": ("present", "protocol", "vrf")},
15
+ "ping": {"required": ("node", "target"), "optional": ("source", "expect", "vrf", "count", "min_replies")},
16
+ }
17
+ COMMON_KEYS = ("type", "name", "timeout", "hold")
18
+ BGP_STATES = ("idle", "connect", "active", "opensent", "openconfirm", "established")
19
+
20
+
21
+ class ConfigError(ValueError):
22
+ pass
23
+
24
+
25
+ @dataclass
26
+ class Check:
27
+ type: str
28
+ params: dict[str, Any]
29
+ name: str
30
+ timeout: float
31
+ hold: float | None = None # per-check hold window for post-convergence checks
32
+
33
+ @property
34
+ def node(self) -> str:
35
+ return self.params["node"]
36
+
37
+
38
+ @dataclass
39
+ class Config:
40
+ path: Path
41
+ lab: Path
42
+ timeout: float
43
+ checks: list[Check]
44
+ drivers: dict[str, str] = field(default_factory=dict) # node -> driver override
45
+
46
+
47
+ def _describe(ctype: str, p: dict[str, Any]) -> str:
48
+ if ctype == "bgp_session":
49
+ return f"{p['node']}: BGP session to {p['neighbor']} is {p.get('state', 'established')}"
50
+ if ctype == "bgp_prefixes":
51
+ rng = []
52
+ if "min" in p:
53
+ rng.append(f">= {p['min']}")
54
+ if "max" in p:
55
+ rng.append(f"<= {p['max']}")
56
+ return f"{p['node']}: prefixes from {p['neighbor']} {' and '.join(rng) or 'counted'}"
57
+ if ctype == "route":
58
+ verb = "present" if p.get("present", True) else "absent"
59
+ via = f" via {p['protocol']}" if p.get("protocol") else ""
60
+ return f"{p['node']}: route {p['prefix']}{via} is {verb}"
61
+ if ctype == "ping":
62
+ exp = p.get("expect", "success")
63
+ src = f" from {p['source']}" if p.get("source") else ""
64
+ return f"{p['node']}: ping {p['target']}{src} expects {exp}"
65
+ return ctype
66
+
67
+
68
+ def _validate_check(i: int, raw: Any, default_timeout: float) -> Check:
69
+ where = f"checks[{i}]"
70
+ if not isinstance(raw, dict):
71
+ raise ConfigError(f"{where}: must be a mapping")
72
+ ctype = raw.get("type")
73
+ if ctype not in CHECK_TYPES:
74
+ raise ConfigError(f"{where}: unknown type {ctype!r}; expected one of {sorted(CHECK_TYPES)}")
75
+ spec = CHECK_TYPES[ctype]
76
+ allowed = set(spec["required"]) | set(spec["optional"]) | set(COMMON_KEYS)
77
+ unknown = set(raw) - allowed
78
+ if unknown:
79
+ raise ConfigError(f"{where}: unknown keys {sorted(unknown)} for type {ctype}")
80
+ for key in spec["required"]:
81
+ if key not in raw:
82
+ raise ConfigError(f"{where}: missing required key {key!r}")
83
+ params = {k: v for k, v in raw.items() if k not in COMMON_KEYS}
84
+ try:
85
+ # canonical spellings so 2001:db8:0::1 in YAML matches 2001:db8::1 on the device
86
+ if ctype == "route":
87
+ params["prefix"] = str(ipaddress.ip_network(str(params["prefix"]), strict=False))
88
+ for key in ("neighbor", "target", "source"):
89
+ if key in params:
90
+ params[key] = str(ipaddress.ip_address(str(params[key])))
91
+ except ValueError as e:
92
+ raise ConfigError(f"{where}: {e}") from None
93
+ for key in ("min", "max", "exact", "count", "min_replies"):
94
+ if key in params and (isinstance(params[key], bool) or not isinstance(params[key], int) or params[key] < 0):
95
+ raise ConfigError(f"{where}: {key} must be a non-negative integer")
96
+ if "exact" in params and ({"min", "max"} & set(params)):
97
+ raise ConfigError(f"{where}: use exact on its own, or min/max, not both")
98
+ if params.get("min", 0) > params.get("max", float("inf")):
99
+ raise ConfigError(f"{where}: min is greater than max")
100
+ for key in ("timeout", "hold"):
101
+ if key in raw and (not isinstance(raw[key], (int, float)) or isinstance(raw[key], bool) or raw[key] <= 0):
102
+ raise ConfigError(f"{where}: {key} must be a positive number of seconds")
103
+ if "vrf" in params:
104
+ params["vrf"] = str(params["vrf"])
105
+ if ctype == "bgp_session":
106
+ params["state"] = str(params.get("state", "established")).lower()
107
+ if params["state"] not in BGP_STATES:
108
+ raise ConfigError(f"{where}: state must be one of {', '.join(BGP_STATES)}")
109
+ if ctype == "ping":
110
+ params["expect"] = str(params.get("expect", "success")).lower()
111
+ if params["expect"] not in ("success", "failure"):
112
+ raise ConfigError(f"{where}: expect must be 'success' or 'failure'")
113
+ if ctype == "bgp_prefixes" and not ({"min", "max", "exact"} & set(params)):
114
+ raise ConfigError(f"{where}: bgp_prefixes needs at least one of min, max or exact")
115
+ if ctype == "route" and "protocol" in params:
116
+ params["protocol"] = str(params["protocol"]).lower()
117
+ timeout = float(raw.get("timeout", default_timeout))
118
+ hold = float(raw["hold"]) if "hold" in raw else None
119
+ return Check(type=ctype, params=params, name=raw.get("name") or _describe(ctype, params), timeout=timeout, hold=hold)
120
+
121
+
122
+ def load(path: str | Path) -> Config:
123
+ path = Path(path)
124
+ if path.is_dir():
125
+ path = path / "netdryrun.yml"
126
+ if not path.exists():
127
+ raise ConfigError(f"{path}: not found")
128
+ data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
129
+ if not isinstance(data, dict):
130
+ raise ConfigError(f"{path}: top level must be a mapping")
131
+ if "lab" not in data:
132
+ raise ConfigError(f"{path}: missing 'lab' (path to the containerlab topology file)")
133
+ lab = (path.parent / data["lab"]).resolve()
134
+ if not lab.exists():
135
+ raise ConfigError(f"{path}: lab file {lab} not found")
136
+ timeout = float(data.get("timeout", 120))
137
+ raw_checks = data.get("checks") or []
138
+ if not raw_checks:
139
+ raise ConfigError(f"{path}: no checks defined")
140
+ checks = [_validate_check(i, c, timeout) for i, c in enumerate(raw_checks)]
141
+ drivers = {str(k): str(v.get("driver")) for k, v in (data.get("nodes") or {}).items() if isinstance(v, dict) and v.get("driver")}
142
+ return Config(path=path, lab=lab, timeout=timeout, checks=checks, drivers=drivers)
@@ -0,0 +1,32 @@
1
+ """Per-platform drivers that turn device state into a common shape."""
2
+ from __future__ import annotations
3
+
4
+ from ..lab import Lab, Node
5
+ from .base import Driver, DriverError
6
+ from .frr import FrrDriver
7
+ from .srlinux import SrlDriver
8
+
9
+ DRIVERS: dict[str, type[Driver]] = {"frr": FrrDriver, "srlinux": SrlDriver}
10
+
11
+
12
+ def detect(node: Node) -> str:
13
+ if node.kind in ("nokia_srlinux", "srl"):
14
+ return "srlinux"
15
+ if "frr" in node.image.lower():
16
+ return "frr"
17
+ raise DriverError(
18
+ f"cannot detect driver for node {node.name} (kind={node.kind!r}, image={node.image!r}); "
19
+ f"set nodes.{node.name}.driver in netdryrun.yml to one of {sorted(DRIVERS)}"
20
+ )
21
+
22
+
23
+ def for_node(lab: Lab, node: str, override: str | None = None) -> Driver:
24
+ if node not in lab.nodes:
25
+ raise DriverError(f"node {node!r} is not in the topology (nodes: {', '.join(lab.nodes)})")
26
+ name = override or detect(lab.nodes[node])
27
+ if name not in DRIVERS:
28
+ raise DriverError(f"unknown driver {name!r} for node {node}; expected one of {sorted(DRIVERS)}")
29
+ return DRIVERS[name](lab, node)
30
+
31
+
32
+ __all__ = ["Driver", "DriverError", "DRIVERS", "detect", "for_node"]
@@ -0,0 +1,57 @@
1
+ """Driver interface. Every method returns normalized data plus the raw evidence it came from."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass, field
5
+
6
+ from ..lab import Lab
7
+
8
+
9
+ class DriverError(RuntimeError):
10
+ pass
11
+
12
+
13
+ @dataclass
14
+ class BgpNeighbor:
15
+ address: str
16
+ state: str # lower-case, e.g. "established"
17
+ prefixes: dict[str, int] = field(default_factory=dict) # afi -> accepted/received prefixes
18
+
19
+
20
+ @dataclass
21
+ class Route:
22
+ prefix: str
23
+ protocol: str # lower-case, e.g. "bgp", "connected", "static"
24
+ active: bool
25
+
26
+
27
+ @dataclass
28
+ class Observation:
29
+ """What a driver saw, with the raw command output kept as evidence."""
30
+ value: object
31
+ evidence: list[dict]
32
+
33
+
34
+ class Driver:
35
+ name = "base"
36
+
37
+ def __init__(self, lab: Lab, node: str):
38
+ self.lab = lab
39
+ self.node = node
40
+
41
+ def _exec(self, cmd: list[str], evidence: list[dict], timeout: float = 60) -> str:
42
+ """Run a state command; a failing command is an error, never silently 'empty state'."""
43
+ r = self.lab.exec(self.node, cmd, timeout=timeout)
44
+ evidence.append({"node": self.node, "cmd": " ".join(cmd), "rc": r.rc, "output": r.stdout[-20000:], "stderr": r.stderr[-2000:]})
45
+ if r.rc != 0:
46
+ msg = (r.stderr or r.stdout).strip().splitlines()
47
+ raise DriverError(f"`{' '.join(cmd)}` failed on {self.node} (rc={r.rc}): {msg[-1] if msg else 'no output'}")
48
+ return r.stdout
49
+
50
+ def bgp_neighbors(self, vrf: str = "default") -> Observation:
51
+ raise NotImplementedError
52
+
53
+ def routes(self, prefix: str, vrf: str = "default") -> Observation:
54
+ raise NotImplementedError
55
+
56
+ def ping(self, target: str, source: str | None, count: int, vrf: str = "default") -> Observation:
57
+ raise NotImplementedError
@@ -0,0 +1,88 @@
1
+ """FRRouting driver: vtysh JSON output plus ping from the container."""
2
+ from __future__ import annotations
3
+
4
+ import ipaddress
5
+ import json
6
+ import re
7
+
8
+ from .base import BgpNeighbor, Driver, Observation, Route
9
+
10
+ AFI_NAMES = {"ipv4Unicast": "ipv4-unicast", "ipv6Unicast": "ipv6-unicast", "l2VpnEvpn": "evpn"}
11
+
12
+
13
+ def _canon_addr(addr: str) -> str:
14
+ """Canonical address key so 2001:db8:0::1 and 2001:db8::1 match; non-IP keys (interfaces) kept."""
15
+ try:
16
+ return str(ipaddress.ip_address(addr.split("%")[0]))
17
+ except ValueError:
18
+ return addr
19
+
20
+
21
+ def parse_bgp_neighbors(text: str) -> dict[str, BgpNeighbor]:
22
+ data = json.loads(text or "{}")
23
+ out = {}
24
+ for addr, info in data.items():
25
+ if not isinstance(info, dict) or "bgpState" not in info:
26
+ continue
27
+ prefixes = {}
28
+ for afi, af in (info.get("addressFamilyInfo") or {}).items():
29
+ if isinstance(af, dict) and "acceptedPrefixCounter" in af:
30
+ prefixes[AFI_NAMES.get(afi, afi)] = int(af["acceptedPrefixCounter"])
31
+ # "Idle (Admin)" -> "idle": the first word is the FSM state
32
+ out[_canon_addr(addr)] = BgpNeighbor(addr, str(info["bgpState"]).split()[0].lower(), prefixes)
33
+ return out
34
+
35
+
36
+ def parse_routes(text: str, prefix: str) -> list[Route]:
37
+ data = json.loads(text or "{}")
38
+ if not isinstance(data, dict):
39
+ raise ValueError(f"unexpected vtysh route output: {text.strip()[:200]}")
40
+ want = ipaddress.ip_network(prefix)
41
+ out = []
42
+ for key, entries in data.items():
43
+ try:
44
+ if ipaddress.ip_network(key) != want:
45
+ continue
46
+ except ValueError:
47
+ continue
48
+ if not isinstance(entries, list):
49
+ raise ValueError(f"unexpected vtysh route entry for {key}: {str(entries)[:200]}")
50
+ out += [Route(prefix, str(e.get("protocol", "")).lower(), bool(e.get("selected")) and bool(e.get("installed", True)))
51
+ for e in entries]
52
+ return out
53
+
54
+
55
+ def parse_ping(text: str) -> tuple[int, int]:
56
+ m = re.search(r"(\d+) packets transmitted, (\d+) (?:packets )?received", text)
57
+ return (int(m.group(1)), int(m.group(2))) if m else (0, 0)
58
+
59
+
60
+ class FrrDriver(Driver):
61
+ name = "frr"
62
+
63
+ def _vtysh(self, command: str, evidence: list[dict]) -> str:
64
+ return self._exec(["vtysh", "-c", command], evidence)
65
+
66
+ def bgp_neighbors(self, vrf: str = "default") -> Observation:
67
+ ev: list[dict] = []
68
+ cmd = "show bgp neighbors json" if vrf == "default" else f"show bgp vrf {vrf} neighbors json"
69
+ return Observation(parse_bgp_neighbors(self._vtysh(cmd, ev)), ev)
70
+
71
+ def routes(self, prefix: str, vrf: str = "default") -> Observation:
72
+ ev: list[dict] = []
73
+ fam = "ipv6" if ipaddress.ip_network(prefix).version == 6 else "ip"
74
+ vrf_part = "" if vrf == "default" else f" vrf {vrf}"
75
+ text = self._vtysh(f"show {fam} route{vrf_part} {prefix} json", ev)
76
+ return Observation(parse_routes(text, prefix), ev)
77
+
78
+ def ping(self, target: str, source: str | None, count: int, vrf: str = "default") -> Observation:
79
+ ev: list[dict] = []
80
+ cmd = ["ping", "-c", str(count), "-W", "1"]
81
+ if source:
82
+ cmd += ["-I", source]
83
+ cmd.append(target)
84
+ if vrf != "default":
85
+ cmd = ["ip", "vrf", "exec", vrf, *cmd]
86
+ r = self.lab.exec(self.node, cmd, timeout=count * 2 + 10)
87
+ ev.append({"node": self.node, "cmd": " ".join(cmd), "rc": r.rc, "output": r.stdout[-5000:], "stderr": r.stderr[-1000:]})
88
+ return Observation(parse_ping(r.stdout), ev)
@@ -0,0 +1,91 @@
1
+ """Nokia SR Linux driver: sr_cli JSON state, ping from the network-instance namespace."""
2
+ from __future__ import annotations
3
+
4
+ import ipaddress
5
+ import json
6
+
7
+ from .base import BgpNeighbor, Driver, DriverError, Observation, Route
8
+ from .frr import _canon_addr, parse_ping
9
+
10
+ # SR Linux route-type names -> the shared vocabulary used in netdryrun.yml (FRR's names)
11
+ PROTOCOLS = {"local": "connected", "host": "local", "ospfv2": "ospf", "ospfv3": "ospf6"}
12
+
13
+
14
+ def _json(text: str) -> dict:
15
+ if not text.strip():
16
+ return {} # sr_cli prints nothing when the path has no entries
17
+ try:
18
+ data = json.loads(text)
19
+ except json.JSONDecodeError:
20
+ raise DriverError(f"unexpected sr_cli output: {text.strip()[:200]}") from None
21
+ return data if isinstance(data, dict) else {}
22
+
23
+
24
+ def parse_bgp_neighbors(text: str) -> dict[str, BgpNeighbor]:
25
+ out = {}
26
+ for n in _json(text).get("neighbor", []):
27
+ prefixes = {}
28
+ for af in n.get("afi-safi", []):
29
+ if "received-routes" in af:
30
+ # accepted = received minus rejected by import policy (matches FRR's acceptedPrefixCounter)
31
+ prefixes[af["afi-safi-name"]] = int(af["received-routes"]) - int(af.get("rejected-routes", 0))
32
+ addr = str(n.get("peer-address", ""))
33
+ out[_canon_addr(addr)] = BgpNeighbor(addr, str(n.get("session-state", "")).lower(), prefixes)
34
+ return out
35
+
36
+
37
+ def parse_routes(text: str, prefix: str) -> list[Route]:
38
+ want = ipaddress.ip_network(prefix)
39
+ key = "ipv6-prefix" if want.version == 6 else "ipv4-prefix"
40
+ out = []
41
+ for r in _json(text).get("route", []):
42
+ try:
43
+ if ipaddress.ip_network(str(r.get(key))) != want:
44
+ continue
45
+ except ValueError:
46
+ continue
47
+ rtype = str(r.get("route-type", "")).lower()
48
+ out.append(Route(prefix, PROTOCOLS.get(rtype, rtype), bool(r.get("active"))))
49
+ return out
50
+
51
+
52
+ class SrlDriver(Driver):
53
+ name = "srlinux"
54
+
55
+ def _cli(self, command: str, evidence: list[dict]) -> str:
56
+ return self._exec(["sr_cli", "--output-format", "json", command], evidence)
57
+
58
+ def bgp_neighbors(self, vrf: str = "default") -> Observation:
59
+ ev: list[dict] = []
60
+ text = self._cli(f"info from state /network-instance {vrf} protocols bgp neighbor *", ev)
61
+ return Observation(parse_bgp_neighbors(text), ev)
62
+
63
+ def routes(self, prefix: str, vrf: str = "default") -> Observation:
64
+ ev: list[dict] = []
65
+ table = "ipv6-unicast" if ipaddress.ip_network(prefix).version == 6 else "ipv4-unicast"
66
+ text = self._cli(
67
+ f"info from state /network-instance {vrf} route-table {table} route {prefix} "
68
+ "id * route-type * route-owner * origin-network-instance *",
69
+ ev,
70
+ )
71
+ return Observation(parse_routes(text, prefix), ev)
72
+
73
+ def _netns(self, vrf: str) -> str:
74
+ """Linux namespace of a network-instance: srbase-<name>; some releases name default plain 'srbase'."""
75
+ r = self.lab.exec(self.node, ["ip", "netns", "list"], timeout=15)
76
+ names = {line.split()[0] for line in r.stdout.splitlines() if line.strip()}
77
+ wanted = f"srbase-{vrf}"
78
+ if wanted in names or vrf != "default":
79
+ return wanted
80
+ return "srbase" if "srbase" in names else wanted
81
+
82
+ def ping(self, target: str, source: str | None, count: int, vrf: str = "default") -> Observation:
83
+ ev: list[dict] = []
84
+ ping = "ping6" if ipaddress.ip_address(target).version == 6 else "ping"
85
+ cmd = ["ip", "netns", "exec", self._netns(vrf), ping, "-c", str(count), "-W", "1"]
86
+ if source:
87
+ cmd += ["-I", source]
88
+ cmd.append(target)
89
+ r = self.lab.exec(self.node, cmd, timeout=count * 2 + 10)
90
+ ev.append({"node": self.node, "cmd": " ".join(cmd), "rc": r.rc, "output": r.stdout[-5000:], "stderr": r.stderr[-1000:]})
91
+ return Observation(parse_ping(r.stdout), ev)