clabgen 0.3.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.
- clabgen/__init__.py +18 -0
- clabgen/_connect.py +136 -0
- clabgen/agent.py +321 -0
- clabgen/lab.py +306 -0
- clabgen/pytest_plugin.py +40 -0
- clabgen-0.3.0.dist-info/METADATA +45 -0
- clabgen-0.3.0.dist-info/RECORD +11 -0
- clabgen-0.3.0.dist-info/WHEEL +5 -0
- clabgen-0.3.0.dist-info/entry_points.txt +2 -0
- clabgen-0.3.0.dist-info/licenses/LICENSE +28 -0
- clabgen-0.3.0.dist-info/top_level.txt +1 -0
clabgen/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Python client for clabgen agents, with helpers for pytest.
|
|
2
|
+
|
|
3
|
+
from clabgen import Lab
|
|
4
|
+
|
|
5
|
+
lab = Lab.from_docker("b2b")
|
|
6
|
+
with lab.measure() as m:
|
|
7
|
+
lab["gen2"].port_down("eth1")
|
|
8
|
+
...
|
|
9
|
+
m.assert_pass()
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from ._connect import ClabgenError
|
|
13
|
+
from .agent import Agent, merge, pcap_data, wait_until
|
|
14
|
+
from .lab import FlowResult, FlowsFailed, Lab, Measurement
|
|
15
|
+
|
|
16
|
+
__version__ = "0.3.0"
|
|
17
|
+
|
|
18
|
+
__all__ = ["Agent", "ClabgenError", "FlowResult", "FlowsFailed", "Lab", "Measurement", "merge", "pcap_data", "wait_until", "__version__"]
|
clabgen/_connect.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Connect protocol (JSON encoding) over plain HTTP, standard library only.
|
|
2
|
+
|
|
3
|
+
Unary calls are a POST of the request as JSON. Server-streaming calls send
|
|
4
|
+
and receive enveloped messages: a flags byte, a 4-byte big-endian length,
|
|
5
|
+
then the JSON; the end-of-stream message (flag 0x02) carries any error.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
import struct
|
|
13
|
+
import urllib.error
|
|
14
|
+
import urllib.request
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
from typing import Any, Iterator
|
|
17
|
+
|
|
18
|
+
SERVICE = "clabgen.v1.AgentService"
|
|
19
|
+
|
|
20
|
+
# Fields holding free-form JSON (google.protobuf.Struct): their keys are the
|
|
21
|
+
# protocols' own and are left alone.
|
|
22
|
+
_STRUCT_FIELDS = {"config", "status", "stats", "args", "result", "data", "spec"}
|
|
23
|
+
# 64-bit integers, which protobuf's JSON mapping sends as strings.
|
|
24
|
+
_INT64_FIELDS = {"rx_frames", "rx_bytes", "tx_frames", "tx_bytes", "tx_errors", "rx_unmatched", "age_ms", "rtt_us"}
|
|
25
|
+
_TIME_FIELDS = {"start_time", "time"}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ClabgenError(Exception):
|
|
29
|
+
"""An error from an agent: code is Connect's ("not_found", "invalid_argument", …)."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, code: str, message: str, method: str = ""):
|
|
32
|
+
super().__init__(f"{method}: {code}: {message}" if method else f"{code}: {message}")
|
|
33
|
+
self.code = code
|
|
34
|
+
self.message = message
|
|
35
|
+
self.method = method
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _snake(name: str) -> str:
|
|
39
|
+
return re.sub(r"(?<=[a-z0-9])([A-Z])", r"_\1", name).lower()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _time(s: str) -> datetime:
|
|
43
|
+
# Python before 3.11 can't parse "Z" or more than 6 fraction digits.
|
|
44
|
+
s = s.replace("Z", "+00:00")
|
|
45
|
+
s = re.sub(r"(\.\d{6})\d+", r"\1", s)
|
|
46
|
+
return datetime.fromisoformat(s)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def from_wire(v: Any) -> Any:
|
|
50
|
+
"""Convert a decoded response: snake_case field names, ints and datetimes."""
|
|
51
|
+
if isinstance(v, list):
|
|
52
|
+
return [from_wire(x) for x in v]
|
|
53
|
+
if not isinstance(v, dict):
|
|
54
|
+
return v
|
|
55
|
+
out = {}
|
|
56
|
+
for k, x in v.items():
|
|
57
|
+
k = _snake(k)
|
|
58
|
+
if k in _STRUCT_FIELDS:
|
|
59
|
+
out[k] = x
|
|
60
|
+
elif k == "counters" and isinstance(x, dict):
|
|
61
|
+
# Port counters (a message) and device IP counters (a map): all uint64.
|
|
62
|
+
out[k] = {_snake(ck): int(cv) if isinstance(cv, str) and cv.isdigit() else cv for ck, cv in x.items()}
|
|
63
|
+
elif k in _INT64_FIELDS and isinstance(x, str):
|
|
64
|
+
out[k] = int(x)
|
|
65
|
+
elif k in _TIME_FIELDS and isinstance(x, str):
|
|
66
|
+
out[k] = _time(x)
|
|
67
|
+
else:
|
|
68
|
+
out[k] = from_wire(x)
|
|
69
|
+
return out
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _error(method: str, body: bytes, status: int) -> ClabgenError:
|
|
73
|
+
try:
|
|
74
|
+
e = json.loads(body)
|
|
75
|
+
return ClabgenError(e.get("code", "unknown"), e.get("message", ""), method)
|
|
76
|
+
except ValueError:
|
|
77
|
+
return ClabgenError("unknown", f"HTTP {status}: {body[:200].decode(errors='replace')}", method)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class Transport:
|
|
81
|
+
"""Calls one agent's AgentService at base_url (http://host:8080, or a
|
|
82
|
+
clabgen-ui proxy path like http://host:8090/agents/clab-b2b-gen1)."""
|
|
83
|
+
|
|
84
|
+
def __init__(self, base_url: str, timeout: float = 10.0):
|
|
85
|
+
self.base_url = base_url.rstrip("/")
|
|
86
|
+
self.timeout = timeout
|
|
87
|
+
|
|
88
|
+
def _url(self, method: str) -> str:
|
|
89
|
+
return f"{self.base_url}/{SERVICE}/{method}"
|
|
90
|
+
|
|
91
|
+
def call(self, method: str, request: dict | None = None, timeout: float | None = None) -> dict:
|
|
92
|
+
data = json.dumps(request or {}).encode()
|
|
93
|
+
req = urllib.request.Request(
|
|
94
|
+
self._url(method), data=data, method="POST",
|
|
95
|
+
headers={"Content-Type": "application/json", "Connect-Protocol-Version": "1"},
|
|
96
|
+
)
|
|
97
|
+
try:
|
|
98
|
+
with urllib.request.urlopen(req, timeout=timeout or self.timeout) as r:
|
|
99
|
+
return from_wire(json.loads(r.read() or b"{}"))
|
|
100
|
+
except urllib.error.HTTPError as e:
|
|
101
|
+
raise _error(method, e.read(), e.code) from None
|
|
102
|
+
except urllib.error.URLError as e:
|
|
103
|
+
raise ClabgenError("unavailable", str(e.reason), method) from None
|
|
104
|
+
|
|
105
|
+
def stream(self, method: str, request: dict | None = None, timeout: float | None = None) -> Iterator[dict]:
|
|
106
|
+
"""Server-streaming call; timeout is per read (None: wait forever).
|
|
107
|
+
The call is made before this returns, so nothing sent after it is
|
|
108
|
+
missed; the messages come from the returned iterator."""
|
|
109
|
+
payload = json.dumps(request or {}).encode()
|
|
110
|
+
req = urllib.request.Request(
|
|
111
|
+
self._url(method), data=struct.pack(">BI", 0, len(payload)) + payload, method="POST",
|
|
112
|
+
headers={"Content-Type": "application/connect+json", "Connect-Protocol-Version": "1"},
|
|
113
|
+
)
|
|
114
|
+
try:
|
|
115
|
+
r = urllib.request.urlopen(req, timeout=timeout)
|
|
116
|
+
except urllib.error.HTTPError as e:
|
|
117
|
+
raise _error(method, e.read(), e.code) from None
|
|
118
|
+
except urllib.error.URLError as e:
|
|
119
|
+
raise ClabgenError("unavailable", str(e.reason), method) from None
|
|
120
|
+
return self._messages(method, r)
|
|
121
|
+
|
|
122
|
+
@staticmethod
|
|
123
|
+
def _messages(method: str, r: Any) -> Iterator[dict]:
|
|
124
|
+
with r:
|
|
125
|
+
while True:
|
|
126
|
+
head = r.read(5)
|
|
127
|
+
if len(head) < 5:
|
|
128
|
+
return
|
|
129
|
+
flags, n = struct.unpack(">BI", head)
|
|
130
|
+
msg = json.loads(r.read(n) or b"{}")
|
|
131
|
+
if flags & 0x02:
|
|
132
|
+
if "error" in msg:
|
|
133
|
+
e = msg["error"]
|
|
134
|
+
raise ClabgenError(e.get("code", "unknown"), e.get("message", ""), method)
|
|
135
|
+
return
|
|
136
|
+
yield from_wire(msg)
|
clabgen/agent.py
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""One clabgen agent: everything clabgenctl and the web UI can do."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import copy
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Callable, Iterable, Iterator
|
|
10
|
+
|
|
11
|
+
from ._connect import ClabgenError, Transport
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _base_url(address: str) -> str:
|
|
15
|
+
if "://" in address:
|
|
16
|
+
return address
|
|
17
|
+
host, sep, port = address.rpartition(":")
|
|
18
|
+
if not sep or not port.isdigit() or host.endswith(":"): # no port, or a bare IPv6 address
|
|
19
|
+
host, port = address, "8080"
|
|
20
|
+
if ":" in host and not host.startswith("["):
|
|
21
|
+
host = f"[{host}]"
|
|
22
|
+
return f"http://{host}:{port}"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _vlan(v: Any) -> dict:
|
|
26
|
+
"""A VLAN tag from 100, "100", "100.5" (PCP), "0x88a8:200" or a dict."""
|
|
27
|
+
if isinstance(v, dict):
|
|
28
|
+
return v
|
|
29
|
+
if isinstance(v, int):
|
|
30
|
+
return {"vid": v}
|
|
31
|
+
s, tag = str(v), {}
|
|
32
|
+
if ":" in s:
|
|
33
|
+
tpid, s = s.split(":", 1)
|
|
34
|
+
tag["tpid"] = int(tpid, 0)
|
|
35
|
+
if "." in s:
|
|
36
|
+
s, pcp = s.split(".", 1)
|
|
37
|
+
tag["pcp"] = int(pcp)
|
|
38
|
+
tag["vid"] = int(s)
|
|
39
|
+
return tag
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def pcap_data(capture: str | Path | bytes) -> str:
|
|
43
|
+
"""A capture (a pcap or pcapng file's path, or its bytes) as the packet
|
|
44
|
+
type's pcap.data: add_protocol("r", "packet", "d1", pcap={"data": pcap_data("x.pcap")})."""
|
|
45
|
+
raw = capture if isinstance(capture, bytes) else Path(capture).read_bytes()
|
|
46
|
+
return base64.b64encode(raw).decode()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def merge(dst: dict, src: dict) -> dict:
|
|
50
|
+
"""Deep-merge src onto dst (nested dicts merged, everything else replaced)."""
|
|
51
|
+
for k, v in src.items():
|
|
52
|
+
if isinstance(v, dict) and isinstance(dst.get(k), dict):
|
|
53
|
+
merge(dst[k], v)
|
|
54
|
+
else:
|
|
55
|
+
dst[k] = v
|
|
56
|
+
return dst
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def wait_until(check: Callable[[], Any], timeout: float = 30, interval: float = 0.5, what: str = "condition") -> Any:
|
|
60
|
+
"""Call check until it returns something truthy (returned), or raise
|
|
61
|
+
TimeoutError after timeout seconds. Errors from check propagate."""
|
|
62
|
+
end = time.monotonic() + timeout
|
|
63
|
+
while True:
|
|
64
|
+
v = check()
|
|
65
|
+
if v:
|
|
66
|
+
return v
|
|
67
|
+
if time.monotonic() >= end:
|
|
68
|
+
raise TimeoutError(f"timed out after {timeout:g}s waiting for {what}")
|
|
69
|
+
time.sleep(interval)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Agent:
|
|
73
|
+
"""A clabgen agent's API.
|
|
74
|
+
|
|
75
|
+
address is host[:port] (port 8080 by default), e.g. "clab-b2b-gen1", or a
|
|
76
|
+
URL: "http://172.20.20.2:8080", or an agent behind clabgen-ui,
|
|
77
|
+
"http://host:8090/agents/clab-b2b-gen1".
|
|
78
|
+
|
|
79
|
+
Methods return the API's objects as dicts with snake_case keys. Protocol
|
|
80
|
+
configs and statuses are as each protocol type defines them (see
|
|
81
|
+
`types()` or `clabgenctl types TYPE`).
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
def __init__(self, address: str, timeout: float = 10.0, name: str | None = None):
|
|
85
|
+
self.url = _base_url(address)
|
|
86
|
+
self.name = name or address
|
|
87
|
+
self._t = Transport(self.url, timeout)
|
|
88
|
+
self._traffic: set[str] | None = None
|
|
89
|
+
|
|
90
|
+
def __repr__(self) -> str:
|
|
91
|
+
return f"Agent({self.name!r}, url={self.url!r})"
|
|
92
|
+
|
|
93
|
+
def call(self, method: str, request: dict | None = None, **kw: Any) -> dict:
|
|
94
|
+
"""Any AgentService method by name, e.g. call("ListPorts")."""
|
|
95
|
+
return self._t.call(method, request, **kw)
|
|
96
|
+
|
|
97
|
+
# Agent and ports.
|
|
98
|
+
|
|
99
|
+
def info(self) -> dict:
|
|
100
|
+
"""hostname, version and start_time."""
|
|
101
|
+
return self.call("GetInfo")
|
|
102
|
+
|
|
103
|
+
def ports(self) -> list[dict]:
|
|
104
|
+
return self.call("ListPorts").get("ports", [])
|
|
105
|
+
|
|
106
|
+
def port(self, name: str) -> dict:
|
|
107
|
+
for p in self.ports():
|
|
108
|
+
if p["name"] == name:
|
|
109
|
+
return p
|
|
110
|
+
raise ClabgenError("not_found", f"port {name}", "ListPorts")
|
|
111
|
+
|
|
112
|
+
def set_port(self, name: str, up: bool) -> dict:
|
|
113
|
+
return self.call("SetPortAdmin", {"name": name, "up": up})["port"]
|
|
114
|
+
|
|
115
|
+
def port_down(self, name: str) -> dict:
|
|
116
|
+
return self.set_port(name, False)
|
|
117
|
+
|
|
118
|
+
def port_up(self, name: str) -> dict:
|
|
119
|
+
return self.set_port(name, True)
|
|
120
|
+
|
|
121
|
+
# Devices.
|
|
122
|
+
|
|
123
|
+
def devices(self) -> list[dict]:
|
|
124
|
+
return self.call("ListDevices").get("devices", [])
|
|
125
|
+
|
|
126
|
+
def device(self, name: str) -> dict:
|
|
127
|
+
for d in self.devices():
|
|
128
|
+
if d["name"] == name:
|
|
129
|
+
return d
|
|
130
|
+
raise ClabgenError("not_found", f"device {name}", "ListDevices")
|
|
131
|
+
|
|
132
|
+
def add_device(self, name: str, port: str = "eth1", *, vlans: Iterable[Any] = (), mac: str = "",
|
|
133
|
+
addresses: Iterable[str] = (), gateway4: str = "", gateway6: str = "") -> dict:
|
|
134
|
+
"""Create a device. vlans are outermost first: 100, "100.5" (with PCP),
|
|
135
|
+
"0x88a8:200" or {"tpid":…, "vid":…, "pcp":…}. An empty mac is derived."""
|
|
136
|
+
d = {"name": name, "port": port, "mac": mac, "vlans": [_vlan(v) for v in vlans],
|
|
137
|
+
"addresses": list(addresses), "gateway4": gateway4, "gateway6": gateway6}
|
|
138
|
+
return self.call("CreateDevice", {"device": d})["device"]
|
|
139
|
+
|
|
140
|
+
def update_device(self, name: str, **changes: Any) -> dict:
|
|
141
|
+
"""Change some of a device's fields (port, mac, vlans, addresses,
|
|
142
|
+
gateway4, gateway6); the rest stay as they are."""
|
|
143
|
+
d = self.device(name)
|
|
144
|
+
if "vlans" in changes:
|
|
145
|
+
changes["vlans"] = [_vlan(v) for v in changes["vlans"]]
|
|
146
|
+
d.update(changes)
|
|
147
|
+
for k in ("mac_derived", "group"):
|
|
148
|
+
d.pop(k, None)
|
|
149
|
+
return self.call("UpdateDevice", {"device": d})["device"]
|
|
150
|
+
|
|
151
|
+
def delete_device(self, name: str) -> None:
|
|
152
|
+
self.call("DeleteDevice", {"name": name})
|
|
153
|
+
|
|
154
|
+
def device_ip(self, name: str) -> dict:
|
|
155
|
+
"""Effective addresses, gateways, neighbors and IP counters."""
|
|
156
|
+
return self.call("GetDeviceIP", {"name": name})
|
|
157
|
+
|
|
158
|
+
def resolve(self, device: str, ip: str) -> dict:
|
|
159
|
+
"""ARP/ND for ip (or its gateway): next_hop, mac, cached, rtt_us."""
|
|
160
|
+
return self.call("ResolveNeighbor", {"device": device, "ip": ip})
|
|
161
|
+
|
|
162
|
+
def clear_neighbors(self, device: str) -> None:
|
|
163
|
+
self.call("ClearNeighbors", {"device": device})
|
|
164
|
+
|
|
165
|
+
# Protocols (including traffic flows).
|
|
166
|
+
|
|
167
|
+
def types(self) -> list[dict]:
|
|
168
|
+
"""Protocol types with their config and action schemas (JSON strings)."""
|
|
169
|
+
return self.call("ListProtocolTypes").get("types", [])
|
|
170
|
+
|
|
171
|
+
def protocols(self, type: str | None = None, *, status: bool = True, brief: bool = False,
|
|
172
|
+
group_members: bool = True) -> list[dict]:
|
|
173
|
+
r = self.call("ListProtocols", {"with_status": status, "brief": brief, "no_group_members": not group_members})
|
|
174
|
+
ps = r.get("protocols", [])
|
|
175
|
+
return [p for p in ps if p["type"] == type] if type else ps
|
|
176
|
+
|
|
177
|
+
def protocol(self, name: str) -> dict:
|
|
178
|
+
return self.call("GetProtocol", {"name": name})["protocol"]
|
|
179
|
+
|
|
180
|
+
def status(self, name: str) -> dict:
|
|
181
|
+
"""A protocol's live status."""
|
|
182
|
+
return self.protocol(name).get("status", {})
|
|
183
|
+
|
|
184
|
+
def add_protocol(self, name: str, type: str, device: str = "", config: dict | None = None, **fields: Any) -> dict:
|
|
185
|
+
"""Create a protocol instance; keyword arguments are config fields
|
|
186
|
+
(merged onto config), e.g. add_protocol("p", "icmp", "d1", destination="10.0.0.2", rate=50)."""
|
|
187
|
+
cfg = merge(copy.deepcopy(config or {}), fields)
|
|
188
|
+
return self.call("CreateProtocol", {"protocol": {"name": name, "type": type, "device": device, "config": cfg}})["protocol"]
|
|
189
|
+
|
|
190
|
+
def update_protocol(self, name: str, config: dict | None = None, *, replace: bool = False, **fields: Any) -> dict:
|
|
191
|
+
"""Change a protocol's config and restart it. By default config and the
|
|
192
|
+
keyword fields are merged onto the current config (like clabgenctl
|
|
193
|
+
proto set); replace=True sends them as the whole config."""
|
|
194
|
+
changes = merge(copy.deepcopy(config or {}), fields)
|
|
195
|
+
cfg = changes if replace else merge(copy.deepcopy(self.protocol(name).get("config", {})), changes)
|
|
196
|
+
return self.call("UpdateProtocol", {"name": name, "config": cfg})["protocol"]
|
|
197
|
+
|
|
198
|
+
def preview(self, device: str, config: dict | None = None, *, type: str = "packet", index: int = 0,
|
|
199
|
+
pcap: str | Path | bytes | None = None, **fields: Any) -> dict:
|
|
200
|
+
"""What a config would send from device, without creating anything
|
|
201
|
+
(the packet builder's preview): hex, summary, and layers with their
|
|
202
|
+
fields. index picks the packet (modifiers change fields per packet)
|
|
203
|
+
or the captured frame; pcap is a capture to replay (a path or bytes)."""
|
|
204
|
+
cfg = merge(copy.deepcopy(config or {}), fields)
|
|
205
|
+
if pcap is not None:
|
|
206
|
+
merge(cfg, {"pcap": {"data": pcap_data(pcap)}})
|
|
207
|
+
r = self.call("PreviewProtocol", {"type": type, "device": device, "config": cfg, "args": {"index": index}})
|
|
208
|
+
return r.get("result", {})
|
|
209
|
+
|
|
210
|
+
def delete_protocol(self, name: str) -> None:
|
|
211
|
+
self.call("DeleteProtocol", {"name": name})
|
|
212
|
+
|
|
213
|
+
def disable(self, name: str) -> dict:
|
|
214
|
+
"""Stop a protocol but keep its config (state "disabled")."""
|
|
215
|
+
return self.call("SetProtocolDisabled", {"name": name, "disabled": True})["protocol"]
|
|
216
|
+
|
|
217
|
+
def enable(self, name: str) -> dict:
|
|
218
|
+
return self.call("SetProtocolDisabled", {"name": name, "disabled": False})["protocol"]
|
|
219
|
+
|
|
220
|
+
def run(self, name: str, action: str, args: dict | None = None, **fields: Any) -> dict:
|
|
221
|
+
"""Run a protocol action, e.g. run("mep1", "loopback", target_mep_id=2, count=5)."""
|
|
222
|
+
a = merge(copy.deepcopy(args or {}), fields)
|
|
223
|
+
return self.call("RunProtocolAction", {"name": name, "action": action, "args": a}).get("result", {})
|
|
224
|
+
|
|
225
|
+
# Traffic flows.
|
|
226
|
+
|
|
227
|
+
def traffic_types(self) -> set[str]:
|
|
228
|
+
"""Names of the protocol types that are traffic flows (icmp, udp, l2, …)."""
|
|
229
|
+
if self._traffic is None:
|
|
230
|
+
self._traffic = {t["name"] for t in self.types() if t.get("category") == "traffic"}
|
|
231
|
+
return self._traffic
|
|
232
|
+
|
|
233
|
+
def flows(self) -> list[dict]:
|
|
234
|
+
"""Traffic flow instances with their statuses."""
|
|
235
|
+
traffic = self.traffic_types()
|
|
236
|
+
return [p for p in self.protocols(group_members=False) if p["type"] in traffic]
|
|
237
|
+
|
|
238
|
+
def start(self, *flows: str) -> None:
|
|
239
|
+
for f in flows:
|
|
240
|
+
self.run(f, "start")
|
|
241
|
+
|
|
242
|
+
def stop(self, *flows: str) -> None:
|
|
243
|
+
for f in flows:
|
|
244
|
+
self.run(f, "stop")
|
|
245
|
+
|
|
246
|
+
def received(self, device: str = "") -> list[dict]:
|
|
247
|
+
"""Flows received by this agent's devices: one dict per flow and
|
|
248
|
+
device, with "device" and the one-way statistics (rx, lost, loss_pct,
|
|
249
|
+
reordered, duplicate, latency_*_us, outages, outage_max_ms, …)."""
|
|
250
|
+
flows = self.call("ListReceivedFlows", {"device": device}).get("flows", [])
|
|
251
|
+
return [{"device": f["device"], **f.get("stats", {})} for f in flows]
|
|
252
|
+
|
|
253
|
+
def clear_received(self, device: str = "", flow_id: str = "") -> None:
|
|
254
|
+
self.call("ClearReceivedFlows", {"device": device, "flow_id": flow_id})
|
|
255
|
+
|
|
256
|
+
# Device groups.
|
|
257
|
+
|
|
258
|
+
def groups(self) -> list[dict]:
|
|
259
|
+
return self.call("ListDeviceGroups").get("groups", [])
|
|
260
|
+
|
|
261
|
+
def group(self, name: str, members: bool = True) -> dict:
|
|
262
|
+
return self.call("GetDeviceGroup", {"name": name, "summary_only": not members})["group"]
|
|
263
|
+
|
|
264
|
+
def add_group(self, spec: dict | str) -> dict:
|
|
265
|
+
"""Create a device group from a spec dict or its YAML."""
|
|
266
|
+
g = {"yaml": spec} if isinstance(spec, str) else {"name": spec["name"], "spec": spec}
|
|
267
|
+
return self.call("CreateDeviceGroup", {"group": g})["group"]
|
|
268
|
+
|
|
269
|
+
def update_group(self, spec: dict | str) -> dict:
|
|
270
|
+
g = {"yaml": spec} if isinstance(spec, str) else {"name": spec["name"], "spec": spec}
|
|
271
|
+
return self.call("UpdateDeviceGroup", {"group": g})["group"]
|
|
272
|
+
|
|
273
|
+
def delete_group(self, name: str) -> None:
|
|
274
|
+
self.call("DeleteDeviceGroup", {"name": name})
|
|
275
|
+
|
|
276
|
+
def run_group(self, group: str, action: str, args: dict | None = None, *, protocol: str = "",
|
|
277
|
+
members: str = "", random: int = 0, **fields: Any) -> list[dict]:
|
|
278
|
+
"""Run an action on a group protocol's members: members is "" (all) or
|
|
279
|
+
1-based indices and ranges like "1-10,15"; random picks that many of them."""
|
|
280
|
+
a = merge(copy.deepcopy(args or {}), fields)
|
|
281
|
+
r = self.call("RunGroupAction", {"group": group, "protocol": protocol, "action": action, "args": a,
|
|
282
|
+
"members": members, "random": random})
|
|
283
|
+
return r.get("results", [])
|
|
284
|
+
|
|
285
|
+
def set_group_disabled(self, group: str, disabled: bool, *, protocol: str = "", members: str = "",
|
|
286
|
+
random: int = 0) -> list[dict]:
|
|
287
|
+
r = self.call("SetGroupDisabled", {"group": group, "protocol": protocol, "members": members,
|
|
288
|
+
"random": random, "disabled": disabled})
|
|
289
|
+
return r.get("results", [])
|
|
290
|
+
|
|
291
|
+
# Scenario and events.
|
|
292
|
+
|
|
293
|
+
def scenario(self) -> str:
|
|
294
|
+
"""The whole config as YAML."""
|
|
295
|
+
return self.call("GetScenario").get("yaml", "")
|
|
296
|
+
|
|
297
|
+
def apply_scenario(self, scenario: str | Path, replace: bool = False) -> None:
|
|
298
|
+
"""Apply a scenario: YAML text, or a Path to a YAML file. replace=True
|
|
299
|
+
removes existing devices and protocols first. Without it, items that
|
|
300
|
+
already exist fail: everything else is applied, then ClabgenError
|
|
301
|
+
lists what failed."""
|
|
302
|
+
if isinstance(scenario, Path):
|
|
303
|
+
scenario = scenario.read_text()
|
|
304
|
+
self.call("ApplyScenario", {"yaml": scenario, "replace": replace}, timeout=60)
|
|
305
|
+
|
|
306
|
+
def events(self, replay: bool = False, timeout: float | None = None) -> Iterator[dict]:
|
|
307
|
+
"""Stream events (time, source, kind, message, data) as they happen;
|
|
308
|
+
replay=True starts with the buffered recent ones. timeout bounds each
|
|
309
|
+
wait for the next event (TimeoutError / socket.timeout when it passes)."""
|
|
310
|
+
return self._t.stream("WatchEvents", {"replay": replay}, timeout=timeout)
|
|
311
|
+
|
|
312
|
+
# Waiting.
|
|
313
|
+
|
|
314
|
+
def wait_protocol(self, name: str, check: Callable[[dict], Any], timeout: float = 30,
|
|
315
|
+
interval: float = 0.5, what: str = "") -> dict:
|
|
316
|
+
"""Poll a protocol until check(protocol) is truthy; returns the protocol.
|
|
317
|
+
E.g. wait_protocol("bgp1", lambda p: p["status"]["peers"][0]["state"] == "established")."""
|
|
318
|
+
def once() -> dict | None:
|
|
319
|
+
p = self.protocol(name)
|
|
320
|
+
return p if check(p) else None
|
|
321
|
+
return wait_until(once, timeout, interval, what or f"{self.name}/{name}")
|
clabgen/lab.py
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"""A lab: several agents, and traffic flows judged across all of them."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
import time
|
|
8
|
+
import urllib.request
|
|
9
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any, Iterable, Iterator, Mapping
|
|
13
|
+
|
|
14
|
+
from ._connect import ClabgenError
|
|
15
|
+
from .agent import Agent, wait_until
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class FlowResult:
|
|
20
|
+
"""One traffic flow's measurement.
|
|
21
|
+
|
|
22
|
+
verdict is "pass", "fail" or "pending" (nothing measured yet), or None
|
|
23
|
+
when the flow has no checks. failed names the failed checks, e.g.
|
|
24
|
+
"loss_pct 2.6 > 1, outage_ms 2030 > 50". For one-way flows (no replies)
|
|
25
|
+
the agent can't judge loss, so rx, lost and loss_pct are the receivers'
|
|
26
|
+
totals and the verdict stays "pending".
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
agent: str
|
|
30
|
+
name: str
|
|
31
|
+
type: str
|
|
32
|
+
device: str
|
|
33
|
+
state: str
|
|
34
|
+
flow_id: str
|
|
35
|
+
one_way: bool
|
|
36
|
+
verdict: str | None
|
|
37
|
+
failed: str
|
|
38
|
+
tx: int
|
|
39
|
+
rx: int
|
|
40
|
+
lost: int
|
|
41
|
+
loss_pct: float
|
|
42
|
+
outage_max_ms: float
|
|
43
|
+
rtt_avg_ms: float | None
|
|
44
|
+
status: dict = field(repr=False)
|
|
45
|
+
received: list[dict] = field(default_factory=list, repr=False)
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def id(self) -> str:
|
|
49
|
+
return f"{self.agent}/{self.name}"
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def passed(self) -> bool:
|
|
53
|
+
return self.verdict == "pass"
|
|
54
|
+
|
|
55
|
+
def summary(self) -> str:
|
|
56
|
+
rtt = f" rtt {self.rtt_avg_ms:.2f} ms" if self.rtt_avg_ms is not None else ""
|
|
57
|
+
v = self.verdict or "no checks"
|
|
58
|
+
if self.failed:
|
|
59
|
+
v += f" ({self.failed})"
|
|
60
|
+
way = " one way" if self.one_way else ""
|
|
61
|
+
return (f"{self.id}: {v}; tx {self.tx} rx {self.rx} lost {self.lost} ({self.loss_pct:.2f}%)"
|
|
62
|
+
f" outage {self.outage_max_ms:g} ms{rtt}{way}")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _result(agent: str, p: dict, received: list[dict]) -> FlowResult:
|
|
66
|
+
s = p.get("status") or {}
|
|
67
|
+
v = s.get("verdict") or {}
|
|
68
|
+
one_way = s.get("tracks_replies") is False
|
|
69
|
+
rx, lost, loss_pct = s.get("rx", 0), s.get("lost", 0), s.get("loss_pct", 0.0)
|
|
70
|
+
outage = s.get("outage_max_ms", 0.0)
|
|
71
|
+
if one_way:
|
|
72
|
+
rx = sum(r.get("rx", 0) for r in received)
|
|
73
|
+
lost = sum(r.get("lost", 0) for r in received)
|
|
74
|
+
loss_pct = 100.0 * lost / (rx + lost) if rx + lost else 0.0
|
|
75
|
+
outage = max([r.get("outage_max_ms", 0.0) for r in received], default=0.0)
|
|
76
|
+
rtt = s.get("rtt") or {}
|
|
77
|
+
return FlowResult(
|
|
78
|
+
agent=agent, name=p["name"], type=p["type"], device=p.get("device", ""), state=s.get("state", p.get("state", "")),
|
|
79
|
+
flow_id=s.get("flow_id", ""), one_way=one_way, verdict=v.get("result"), failed=v.get("failed", ""),
|
|
80
|
+
tx=s.get("tx", 0), rx=rx, lost=lost, loss_pct=loss_pct, outage_max_ms=outage,
|
|
81
|
+
rtt_avg_ms=rtt["avg_us"] / 1000 if "avg_us" in rtt else None, status=s, received=received,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class FlowsFailed(AssertionError):
|
|
86
|
+
def __init__(self, failed: list[FlowResult], results: list[FlowResult]):
|
|
87
|
+
lines = [f"{len(failed)} of {len(results)} flows failed:"] + [" " + r.summary() for r in failed]
|
|
88
|
+
super().__init__("\n".join(lines))
|
|
89
|
+
self.failed = failed
|
|
90
|
+
self.results = results
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class Measurement:
|
|
94
|
+
"""What Lab.measure() measured; results is set when the with block ends."""
|
|
95
|
+
|
|
96
|
+
def __init__(self, lab: "Lab", flows: list[tuple[str, str]]):
|
|
97
|
+
self.lab = lab
|
|
98
|
+
self.flows = flows
|
|
99
|
+
self.started = time.time()
|
|
100
|
+
self.results: list[FlowResult] = []
|
|
101
|
+
|
|
102
|
+
def __getitem__(self, flow: str) -> FlowResult:
|
|
103
|
+
matches = [r for r in self.results if flow in (r.id, r.name)]
|
|
104
|
+
if len(matches) != 1:
|
|
105
|
+
raise KeyError(f"{flow!r}: {'no flow' if not matches else 'ambiguous, use agent/name'}")
|
|
106
|
+
return matches[0]
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def failed(self) -> list[FlowResult]:
|
|
110
|
+
return _failures(self.results, False)
|
|
111
|
+
|
|
112
|
+
def assert_pass(self, require_checks: bool = False) -> None:
|
|
113
|
+
"""Raise FlowsFailed (an AssertionError) if a flow failed its checks,
|
|
114
|
+
or an echo flow with checks measured nothing; require_checks=True
|
|
115
|
+
also fails flows without checks."""
|
|
116
|
+
failed = _failures(self.results, require_checks)
|
|
117
|
+
if failed:
|
|
118
|
+
raise FlowsFailed(failed, self.results)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _failures(results: list[FlowResult], require_checks: bool) -> list[FlowResult]:
|
|
122
|
+
out = []
|
|
123
|
+
for r in results:
|
|
124
|
+
if r.verdict == "fail" or (r.verdict == "pending" and not r.one_way) or (r.verdict is None and require_checks):
|
|
125
|
+
out.append(r)
|
|
126
|
+
return out
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _docker_agents(lab: str) -> dict[str, str]:
|
|
130
|
+
ids = subprocess.run(["docker", "ps", "-q", "--filter", f"label=containerlab={lab}"],
|
|
131
|
+
check=True, capture_output=True, text=True).stdout.split()
|
|
132
|
+
if not ids:
|
|
133
|
+
return {}
|
|
134
|
+
out = {}
|
|
135
|
+
for c in json.loads(subprocess.run(["docker", "inspect", *ids], check=True, capture_output=True, text=True).stdout):
|
|
136
|
+
name = c["Config"]["Labels"].get("clab-node-name") or c["Name"].lstrip("/")
|
|
137
|
+
ips = [n.get("IPAddress") for n in c["NetworkSettings"]["Networks"].values() if n.get("IPAddress")]
|
|
138
|
+
if ips:
|
|
139
|
+
out[name] = ips[0]
|
|
140
|
+
return out
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class Lab(Mapping[str, Agent]):
|
|
144
|
+
"""Agents by name. Build one from a containerlab lab on this host
|
|
145
|
+
(Lab.from_docker("b2b")), from a clabgen-ui (Lab.from_ui(url, "b2b")),
|
|
146
|
+
or from agents: Lab({"gen1": Agent("clab-b2b-gen1"), …})."""
|
|
147
|
+
|
|
148
|
+
def __init__(self, agents: Mapping[str, Agent] | Iterable[Agent]):
|
|
149
|
+
if isinstance(agents, Mapping):
|
|
150
|
+
self.agents = dict(agents)
|
|
151
|
+
else:
|
|
152
|
+
self.agents = {a.name: a for a in agents}
|
|
153
|
+
for name, a in self.agents.items():
|
|
154
|
+
a.name = name
|
|
155
|
+
|
|
156
|
+
@classmethod
|
|
157
|
+
def from_docker(cls, lab: str, timeout: float = 10.0) -> "Lab":
|
|
158
|
+
"""The clabgen agents of a running containerlab lab, by node name
|
|
159
|
+
(nodes that don't answer the agent API are left out)."""
|
|
160
|
+
nodes = _docker_agents(lab)
|
|
161
|
+
|
|
162
|
+
def probe(item: tuple[str, str]) -> Agent | None:
|
|
163
|
+
a = Agent(item[1], timeout=timeout, name=item[0])
|
|
164
|
+
try:
|
|
165
|
+
a.call("GetInfo", timeout=2)
|
|
166
|
+
return a
|
|
167
|
+
except ClabgenError:
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
with ThreadPoolExecutor(max_workers=16) as ex:
|
|
171
|
+
found = [a for a in ex.map(probe, sorted(nodes.items())) if a]
|
|
172
|
+
if not found:
|
|
173
|
+
raise ClabgenError("not_found", f"no clabgen agents in lab {lab!r}")
|
|
174
|
+
return cls(found)
|
|
175
|
+
|
|
176
|
+
@classmethod
|
|
177
|
+
def from_ui(cls, url: str, lab: str | None = None, timeout: float = 10.0) -> "Lab":
|
|
178
|
+
"""Agents known to a clabgen-ui (e.g. http://host:8090), reached
|
|
179
|
+
through its proxy; lab picks one lab's. Names are node names, or
|
|
180
|
+
agent IDs when that would be ambiguous."""
|
|
181
|
+
url = url.rstrip("/")
|
|
182
|
+
with urllib.request.urlopen(f"{url}/api/agents", timeout=timeout) as r:
|
|
183
|
+
agents = [a for a in json.load(r).get("agents", []) if lab is None or a.get("lab") == lab]
|
|
184
|
+
if not agents:
|
|
185
|
+
raise ClabgenError("not_found", f"no agents{f' in lab {lab!r}' if lab else ''} at {url}")
|
|
186
|
+
names = [a.get("name") or a["id"] for a in agents]
|
|
187
|
+
unique = len(set(names)) == len(names)
|
|
188
|
+
return cls({(n if unique else a["id"]): Agent(f"{url}/agents/{a['id']}", timeout=timeout)
|
|
189
|
+
for n, a in zip(names, agents)})
|
|
190
|
+
|
|
191
|
+
def __getitem__(self, name: str) -> Agent:
|
|
192
|
+
return self.agents[name]
|
|
193
|
+
|
|
194
|
+
def __iter__(self) -> Iterator[str]:
|
|
195
|
+
return iter(self.agents)
|
|
196
|
+
|
|
197
|
+
def __len__(self) -> int:
|
|
198
|
+
return len(self.agents)
|
|
199
|
+
|
|
200
|
+
def __repr__(self) -> str:
|
|
201
|
+
return f"Lab({list(self.agents)})"
|
|
202
|
+
|
|
203
|
+
def _each(self, fn: Any) -> dict[str, Any]:
|
|
204
|
+
with ThreadPoolExecutor(max_workers=max(1, len(self.agents))) as ex:
|
|
205
|
+
futs = {n: ex.submit(fn, a) for n, a in self.agents.items()}
|
|
206
|
+
return {n: f.result() for n, f in futs.items()}
|
|
207
|
+
|
|
208
|
+
# Flows.
|
|
209
|
+
|
|
210
|
+
def _select(self, flows: Iterable[str] | None) -> list[tuple[str, str]]:
|
|
211
|
+
all_flows = [(n, p["name"]) for n, ps in self._each(lambda a: a.flows()).items() for p in ps]
|
|
212
|
+
if flows is None:
|
|
213
|
+
return all_flows
|
|
214
|
+
out = []
|
|
215
|
+
for f in [flows] if isinstance(flows, str) else flows:
|
|
216
|
+
m = [x for x in all_flows if f in (f"{x[0]}/{x[1]}", x[1])]
|
|
217
|
+
if len(m) != 1:
|
|
218
|
+
raise KeyError(f"{f!r}: {'no such flow' if not m else 'ambiguous, use agent/name'}")
|
|
219
|
+
out.append(m[0])
|
|
220
|
+
return out
|
|
221
|
+
|
|
222
|
+
def flow_results(self, flows: Iterable[str] | None = None) -> list[FlowResult]:
|
|
223
|
+
"""Every flow (or the ones named "agent/name" or "name"), with what
|
|
224
|
+
it measured since its start or last clear."""
|
|
225
|
+
sel = set(self._select(flows))
|
|
226
|
+
protos = self._each(lambda a: a.flows())
|
|
227
|
+
received: dict[str, list[dict]] = {}
|
|
228
|
+
for n, rs in self._each(lambda a: a.received()).items():
|
|
229
|
+
for r in rs:
|
|
230
|
+
received.setdefault(r.get("flow_id", ""), []).append({"agent": n, **r})
|
|
231
|
+
out = []
|
|
232
|
+
for n, ps in protos.items():
|
|
233
|
+
for p in ps:
|
|
234
|
+
if (n, p["name"]) in sel:
|
|
235
|
+
fid = (p.get("status") or {}).get("flow_id", "")
|
|
236
|
+
out.append(_result(n, p, received.get(fid, []) if fid else []))
|
|
237
|
+
return out
|
|
238
|
+
|
|
239
|
+
def clear_flows(self, flows: Iterable[str] | None = None) -> None:
|
|
240
|
+
"""Reset flows' statistics at the senders and at every receiver."""
|
|
241
|
+
sel = set(self._select(flows))
|
|
242
|
+
ids = set()
|
|
243
|
+
for n, ps in self._each(lambda a: a.flows()).items():
|
|
244
|
+
for p in ps:
|
|
245
|
+
if (n, p["name"]) in sel:
|
|
246
|
+
self.agents[n].run(p["name"], "clear")
|
|
247
|
+
ids.add((p.get("status") or {}).get("flow_id", ""))
|
|
248
|
+
for fid in ids - {""}:
|
|
249
|
+
self._each(lambda a, fid=fid: a.clear_received(flow_id=fid))
|
|
250
|
+
|
|
251
|
+
def start_flows(self, flows: Iterable[str] | None = None) -> None:
|
|
252
|
+
for n, name in self._select(flows):
|
|
253
|
+
try:
|
|
254
|
+
self.agents[n].run(name, "start")
|
|
255
|
+
except ClabgenError as e:
|
|
256
|
+
if "already running" not in e.message:
|
|
257
|
+
raise
|
|
258
|
+
|
|
259
|
+
def stop_flows(self, flows: Iterable[str] | None = None) -> None:
|
|
260
|
+
for n, name in self._select(flows):
|
|
261
|
+
try:
|
|
262
|
+
self.agents[n].run(name, "stop")
|
|
263
|
+
except ClabgenError as e:
|
|
264
|
+
if "not running" not in e.message:
|
|
265
|
+
raise
|
|
266
|
+
|
|
267
|
+
@contextmanager
|
|
268
|
+
def measure(self, flows: Iterable[str] | None = None, settle: float = 2.0) -> Iterator[Measurement]:
|
|
269
|
+
"""Clear flows, run the with block (the test: flap a link, fail a
|
|
270
|
+
node, …), wait settle seconds for packets in flight to be answered or
|
|
271
|
+
time out, then collect the results:
|
|
272
|
+
|
|
273
|
+
with lab.measure() as m:
|
|
274
|
+
lab["gen2"].port_down("eth1"); time.sleep(2); lab["gen2"].port_up("eth1")
|
|
275
|
+
m.assert_pass()
|
|
276
|
+
|
|
277
|
+
settle should be at least the flows' reply timeout (1 s by default).
|
|
278
|
+
"""
|
|
279
|
+
sel = self._select(flows)
|
|
280
|
+
names = [f"{n}/{f}" for n, f in sel]
|
|
281
|
+
if sel:
|
|
282
|
+
self.clear_flows(names)
|
|
283
|
+
m = Measurement(self, sel)
|
|
284
|
+
yield m
|
|
285
|
+
time.sleep(settle)
|
|
286
|
+
m.results = self.flow_results(names) if sel else []
|
|
287
|
+
|
|
288
|
+
def wait_flows_pass(self, flows: Iterable[str] | None = None, timeout: float = 60, interval: float = 1) -> list[FlowResult]:
|
|
289
|
+
"""Wait until every flow with checks passes, e.g. while a lab
|
|
290
|
+
converges after deploying. A failed verdict stays until the flow is
|
|
291
|
+
cleared, so failed flows are cleared each round: this returns once
|
|
292
|
+
all of them have passed since their last failure."""
|
|
293
|
+
last: list[FlowResult] = []
|
|
294
|
+
|
|
295
|
+
def ok() -> bool:
|
|
296
|
+
nonlocal last
|
|
297
|
+
last = self.flow_results(flows)
|
|
298
|
+
if failed := [r.id for r in last if r.verdict == "fail"]:
|
|
299
|
+
self.clear_flows(failed)
|
|
300
|
+
return not [r for r in last if r.verdict is not None and r.verdict != "pass" and not r.one_way]
|
|
301
|
+
try:
|
|
302
|
+
wait_until(ok, timeout, interval, "flows to pass")
|
|
303
|
+
except TimeoutError as e:
|
|
304
|
+
bad = [r for r in last if r.verdict is not None and r.verdict != "pass" and not r.one_way]
|
|
305
|
+
raise TimeoutError(f"{e}:\n" + "\n".join(" " + r.summary() for r in bad)) from None
|
|
306
|
+
return last
|
clabgen/pytest_plugin.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""pytest fixtures for clabgen labs (loaded automatically once installed).
|
|
2
|
+
|
|
3
|
+
pytest --clabgen-lab b2b # a containerlab lab on this host
|
|
4
|
+
pytest --clabgen-lab b2b --clabgen-ui http://host:8090 # through a clabgen-ui
|
|
5
|
+
|
|
6
|
+
def test_failover(clabgen_lab):
|
|
7
|
+
with clabgen_lab.measure() as m:
|
|
8
|
+
clabgen_lab["gen2"].port_down("eth1")
|
|
9
|
+
...
|
|
10
|
+
m.assert_pass()
|
|
11
|
+
|
|
12
|
+
Tests using clabgen_lab are skipped when no lab is given.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
|
|
19
|
+
import pytest
|
|
20
|
+
|
|
21
|
+
from .lab import Lab
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def pytest_addoption(parser: pytest.Parser) -> None:
|
|
25
|
+
g = parser.getgroup("clabgen")
|
|
26
|
+
g.addoption("--clabgen-lab", default=None, help="containerlab lab whose clabgen agents tests use (env CLABGEN_LAB)")
|
|
27
|
+
g.addoption("--clabgen-ui", default=None, help="reach the agents through this clabgen-ui, e.g. http://host:8090 (env CLABGEN_UI)")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _opt(config: pytest.Config, name: str) -> str | None:
|
|
31
|
+
return config.getoption(name) or os.environ.get(name.lstrip("-").replace("-", "_").upper())
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@pytest.fixture(scope="session")
|
|
35
|
+
def clabgen_lab(pytestconfig: pytest.Config) -> Lab:
|
|
36
|
+
"""The lab's agents (clabgen.Lab), by node name."""
|
|
37
|
+
lab, ui = _opt(pytestconfig, "--clabgen-lab"), _opt(pytestconfig, "--clabgen-ui")
|
|
38
|
+
if not lab and not ui:
|
|
39
|
+
pytest.skip("no clabgen lab (--clabgen-lab or CLABGEN_LAB)")
|
|
40
|
+
return Lab.from_ui(ui, lab) if ui else Lab.from_docker(lab)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: clabgen
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Python client and pytest helpers for clabgen, the protocol emulator and traffic generator for containerlab
|
|
5
|
+
Author-email: Tom Young <ripnet@gmail.com>
|
|
6
|
+
License-Expression: BSD-3-Clause
|
|
7
|
+
Project-URL: Homepage, https://github.com/ripnet/clabgen
|
|
8
|
+
Classifier: Framework :: Pytest
|
|
9
|
+
Classifier: Topic :: System :: Networking
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Requires-Python: >=3.9
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Provides-Extra: test
|
|
15
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# clabgen (Python)
|
|
19
|
+
|
|
20
|
+
Python client and pytest helpers for
|
|
21
|
+
[clabgen](https://github.com/ripnet/clabgen), the protocol emulator and
|
|
22
|
+
traffic generator for containerlab. Standard library only; Python 3.9+.
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
pip install clabgen
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Installing from a checkout or from git builds the package: Ubuntu 22.04's
|
|
29
|
+
own pip 22.0 builds it as `UNKNOWN`, so use a newer pip there (e.g. in a
|
|
30
|
+
virtualenv after `pip install -U pip`).
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
def test_failover(clabgen_lab): # pytest --clabgen-lab b2b
|
|
34
|
+
with clabgen_lab.measure() as m:
|
|
35
|
+
clabgen_lab["gen2"].port_down("eth1")
|
|
36
|
+
time.sleep(2)
|
|
37
|
+
clabgen_lab["gen2"].port_up("eth1")
|
|
38
|
+
m.assert_pass()
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
See [docs/python.md](https://github.com/ripnet/clabgen/blob/main/docs/python.md).
|
|
42
|
+
|
|
43
|
+
Tests: `pytest` here. The unit tests need nothing; the `agent` tests run
|
|
44
|
+
the agent binary (`make build` at the top, or `CLABGEN_AGENT=path`) in a
|
|
45
|
+
container, so they need docker, and are skipped without it.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
clabgen/__init__.py,sha256=B5HSlZQoyUID3ODW5RJLORuvy1TuTvU2Bh_Ts7nkGD4,540
|
|
2
|
+
clabgen/_connect.py,sha256=RYroIvCEsvKk1HmI2VjACtN-ML9KcDjXJ0FmnVFqdpA,5469
|
|
3
|
+
clabgen/agent.py,sha256=sSvJjwOGy1EmMjFylKj9kyrZwJULMbZriiVcI1gqQ-o,14345
|
|
4
|
+
clabgen/lab.py,sha256=lEAj24aWMGlZmW-yxHcGwMGjuw4OkZgwgHCnIa95ayM,12459
|
|
5
|
+
clabgen/pytest_plugin.py,sha256=XGAlntf52PZCMoTbpphCbJXNCbfouwSdeteJAq8CbjE,1451
|
|
6
|
+
clabgen-0.3.0.dist-info/licenses/LICENSE,sha256=op4y_Rnf00IsF-ga_reUYyCu071PhbdMrbnNZHYoJzc,1544
|
|
7
|
+
clabgen-0.3.0.dist-info/METADATA,sha256=HKcIoY4UAWyObMAbT7sQp-hub4KH7jnWRROzcn2KUhU,1559
|
|
8
|
+
clabgen-0.3.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
9
|
+
clabgen-0.3.0.dist-info/entry_points.txt,sha256=fY7mL_ltmFGkwS_n0VGHGKeFFR2V8FJvdC2FnQ6Jioc,43
|
|
10
|
+
clabgen-0.3.0.dist-info/top_level.txt,sha256=oxx5s9w3VVjy224a6871-r7K4l7-Z4wmPbZDLVvLtW4,8
|
|
11
|
+
clabgen-0.3.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, Tom Young <ripnet@gmail.com> and the clabgen contributors
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
9
|
+
list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
16
|
+
contributors may be used to endorse or promote products derived from
|
|
17
|
+
this software without specific prior written permission.
|
|
18
|
+
|
|
19
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
22
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
23
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
24
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
25
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
26
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
27
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
28
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
clabgen
|