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 +3 -0
- netdryrun/checks.py +164 -0
- netdryrun/cli.py +88 -0
- netdryrun/config.py +142 -0
- netdryrun/drivers/__init__.py +32 -0
- netdryrun/drivers/base.py +57 -0
- netdryrun/drivers/frr.py +88 -0
- netdryrun/drivers/srlinux.py +91 -0
- netdryrun/lab.py +113 -0
- netdryrun/mcp_server.py +91 -0
- netdryrun/report.py +85 -0
- netdryrun/runner.py +114 -0
- netdryrun-0.1.0.dist-info/METADATA +175 -0
- netdryrun-0.1.0.dist-info/RECORD +17 -0
- netdryrun-0.1.0.dist-info/WHEEL +4 -0
- netdryrun-0.1.0.dist-info/entry_points.txt +3 -0
- netdryrun-0.1.0.dist-info/licenses/LICENSE +201 -0
netdryrun/__init__.py
ADDED
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
|
netdryrun/drivers/frr.py
ADDED
|
@@ -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)
|