clabgen 0.3.0__tar.gz

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-0.3.0/LICENSE ADDED
@@ -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.
clabgen-0.3.0/PKG-INFO ADDED
@@ -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,28 @@
1
+ # clabgen (Python)
2
+
3
+ Python client and pytest helpers for
4
+ [clabgen](https://github.com/ripnet/clabgen), the protocol emulator and
5
+ traffic generator for containerlab. Standard library only; Python 3.9+.
6
+
7
+ ```sh
8
+ pip install clabgen
9
+ ```
10
+
11
+ Installing from a checkout or from git builds the package: Ubuntu 22.04's
12
+ own pip 22.0 builds it as `UNKNOWN`, so use a newer pip there (e.g. in a
13
+ virtualenv after `pip install -U pip`).
14
+
15
+ ```python
16
+ def test_failover(clabgen_lab): # pytest --clabgen-lab b2b
17
+ with clabgen_lab.measure() as m:
18
+ clabgen_lab["gen2"].port_down("eth1")
19
+ time.sleep(2)
20
+ clabgen_lab["gen2"].port_up("eth1")
21
+ m.assert_pass()
22
+ ```
23
+
24
+ See [docs/python.md](https://github.com/ripnet/clabgen/blob/main/docs/python.md).
25
+
26
+ Tests: `pytest` here. The unit tests need nothing; the `agent` tests run
27
+ the agent binary (`make build` at the top, or `CLABGEN_AGENT=path`) in a
28
+ container, so they need docker, and are skipped without it.
@@ -0,0 +1,38 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "clabgen"
7
+ dynamic = ["version"]
8
+ description = "Python client and pytest helpers for clabgen, the protocol emulator and traffic generator for containerlab"
9
+ readme = "README.md"
10
+ license = "BSD-3-Clause"
11
+ license-files = ["LICENSE"]
12
+ authors = [{ name = "Tom Young", email = "ripnet@gmail.com" }]
13
+ requires-python = ">=3.9"
14
+ dependencies = []
15
+ classifiers = [
16
+ "Framework :: Pytest",
17
+ "Topic :: System :: Networking",
18
+ "Programming Language :: Python :: 3",
19
+ ]
20
+
21
+ [project.optional-dependencies]
22
+ test = ["pytest>=7"]
23
+
24
+ [project.urls]
25
+ Homepage = "https://github.com/ripnet/clabgen"
26
+
27
+ [project.entry-points.pytest11]
28
+ clabgen = "clabgen.pytest_plugin"
29
+
30
+ [tool.setuptools.dynamic]
31
+ version = { attr = "clabgen.__version__" }
32
+
33
+ [tool.setuptools.packages.find]
34
+ where = ["src"]
35
+
36
+ [tool.pytest.ini_options]
37
+ testpaths = ["tests"]
38
+ markers = ["agent: needs docker and a clabgen-agent binary (CLABGEN_AGENT or ../bin/clabgen-agent)"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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__"]
@@ -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)
@@ -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}")
@@ -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
@@ -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,16 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/clabgen/__init__.py
5
+ src/clabgen/_connect.py
6
+ src/clabgen/agent.py
7
+ src/clabgen/lab.py
8
+ src/clabgen/pytest_plugin.py
9
+ src/clabgen.egg-info/PKG-INFO
10
+ src/clabgen.egg-info/SOURCES.txt
11
+ src/clabgen.egg-info/dependency_links.txt
12
+ src/clabgen.egg-info/entry_points.txt
13
+ src/clabgen.egg-info/requires.txt
14
+ src/clabgen.egg-info/top_level.txt
15
+ tests/test_connect.py
16
+ tests/test_live.py
@@ -0,0 +1,2 @@
1
+ [pytest11]
2
+ clabgen = clabgen.pytest_plugin
@@ -0,0 +1,3 @@
1
+
2
+ [test]
3
+ pytest>=7
@@ -0,0 +1 @@
1
+ clabgen
@@ -0,0 +1,131 @@
1
+ import json
2
+ import struct
3
+ import threading
4
+ from datetime import datetime, timezone
5
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
6
+
7
+ import pytest
8
+
9
+ from clabgen import Agent, ClabgenError
10
+ from clabgen._connect import from_wire
11
+ from clabgen.agent import _base_url, _vlan, merge
12
+
13
+
14
+ class Fake(BaseHTTPRequestHandler):
15
+ """A few AgentService methods the way connect-go answers them."""
16
+
17
+ calls: list = []
18
+
19
+ def log_message(self, *a):
20
+ pass
21
+
22
+ def do_POST(self):
23
+ method = self.path.rsplit("/", 1)[-1]
24
+ body = self.rfile.read(int(self.headers["Content-Length"]))
25
+ Fake.calls.append((self.path, self.headers["Content-Type"], body))
26
+ if method == "ListPorts":
27
+ self._json(200, {"ports": [{"name": "eth1", "adminUp": True, "counters": {"rxFrames": "12", "txErrors": "0"}}]})
28
+ elif method == "GetInfo":
29
+ self._json(200, {"hostname": "h", "startTime": "2026-10-23T16:29:23.944123456Z"})
30
+ elif method == "GetProtocol":
31
+ self._json(200, {"protocol": {"name": "p", "type": "x", "status": {"someKey": 1, "peers": [{"peerAs": 2}]}}})
32
+ elif method == "DeleteDevice":
33
+ self._json(404, {"code": "not_found", "message": "device nope: not found"})
34
+ elif method == "WatchEvents":
35
+ frames = b""
36
+ for msg, flags in [({"source": "port/eth1", "kind": "down", "time": "2026-10-23T16:00:00Z"}, 0),
37
+ ({"source": "port/eth1", "kind": "up"}, 0),
38
+ ({"error": {"code": "canceled", "message": "bye"}}, 2)]:
39
+ b = json.dumps(msg).encode()
40
+ frames += struct.pack(">BI", flags, len(b)) + b
41
+ self.send_response(200)
42
+ self.send_header("Content-Type", "application/connect+json")
43
+ self.send_header("Content-Length", str(len(frames)))
44
+ self.end_headers()
45
+ self.wfile.write(frames)
46
+ else:
47
+ self._json(404, {"code": "unimplemented", "message": method})
48
+
49
+ def _json(self, code, v):
50
+ b = json.dumps(v).encode()
51
+ self.send_response(code)
52
+ self.send_header("Content-Type", "application/json")
53
+ self.send_header("Content-Length", str(len(b)))
54
+ self.end_headers()
55
+ self.wfile.write(b)
56
+
57
+
58
+ @pytest.fixture(scope="module")
59
+ def fake():
60
+ srv = ThreadingHTTPServer(("127.0.0.1", 0), Fake)
61
+ threading.Thread(target=srv.serve_forever, daemon=True).start()
62
+ yield Agent(f"127.0.0.1:{srv.server_address[1]}")
63
+ srv.shutdown()
64
+
65
+
66
+ def test_unary_converts_names_and_int64(fake):
67
+ p = fake.ports()[0]
68
+ assert p == {"name": "eth1", "admin_up": True, "counters": {"rx_frames": 12, "tx_errors": 0}}
69
+ path, ctype, body = Fake.calls[-1]
70
+ assert path == "/clabgen.v1.AgentService/ListPorts" and ctype == "application/json" and json.loads(body) == {}
71
+
72
+
73
+ def test_timestamps(fake):
74
+ assert fake.info()["start_time"] == datetime(2026, 10, 23, 16, 29, 23, 944123, tzinfo=timezone.utc)
75
+
76
+
77
+ def test_struct_fields_untouched(fake):
78
+ # Protocol statuses keep the protocol's own keys.
79
+ assert fake.status("p") == {"someKey": 1, "peers": [{"peerAs": 2}]}
80
+
81
+
82
+ def test_errors(fake):
83
+ with pytest.raises(ClabgenError) as e:
84
+ fake.delete_device("nope")
85
+ assert e.value.code == "not_found" and "device nope" in str(e.value)
86
+
87
+
88
+ def test_unavailable():
89
+ with pytest.raises(ClabgenError) as e:
90
+ Agent("127.0.0.1:1", timeout=2).info()
91
+ assert e.value.code == "unavailable"
92
+
93
+
94
+ def test_stream(fake):
95
+ it = fake.events(replay=True)
96
+ first = next(it)
97
+ assert first["kind"] == "down" and first["time"].year == 2026
98
+ assert next(it)["kind"] == "up"
99
+ with pytest.raises(ClabgenError) as e:
100
+ next(it)
101
+ assert e.value.code == "canceled"
102
+ _, ctype, body = Fake.calls[-1]
103
+ assert ctype == "application/connect+json"
104
+ flags, n = struct.unpack(">BI", body[:5])
105
+ assert flags == 0 and json.loads(body[5:5 + n]) == {"replay": True}
106
+
107
+
108
+ @pytest.mark.parametrize("addr,url", [
109
+ ("clab-b2b-gen1", "http://clab-b2b-gen1:8080"),
110
+ ("10.0.0.1:9000", "http://10.0.0.1:9000"),
111
+ ("2001:db8::1", "http://[2001:db8::1]:8080"),
112
+ ("[2001:db8::1]:9000", "http://[2001:db8::1]:9000"),
113
+ ("http://host:8090/agents/x", "http://host:8090/agents/x"),
114
+ ])
115
+ def test_base_url(addr, url):
116
+ assert _base_url(addr) == url
117
+
118
+
119
+ def test_vlan():
120
+ assert [_vlan(v) for v in [100, "100", "100.5", "0x88a8:200", {"vid": 7}]] == [
121
+ {"vid": 100}, {"vid": 100}, {"vid": 100, "pcp": 5}, {"tpid": 0x88A8, "vid": 200}, {"vid": 7}]
122
+
123
+
124
+ def test_merge():
125
+ assert merge({"a": 1, "c": {"x": 1, "y": 2}}, {"b": 2, "c": {"y": 3}}) == {"a": 1, "b": 2, "c": {"x": 1, "y": 3}}
126
+
127
+
128
+ def test_from_wire_counters_map():
129
+ # GetDeviceIP counters: a map of uint64s.
130
+ assert from_wire({"counters": {"arp_rx": "3"}, "neighbors": [{"ageMs": "1500"}]}) == {
131
+ "counters": {"arp_rx": 3}, "neighbors": [{"age_ms": 1500}]}
@@ -0,0 +1,141 @@
1
+ """Against a real agent (see conftest.agent). Two devices on the same port
2
+ reach each other inside the agent, so flows run without a second node."""
3
+
4
+ import time
5
+
6
+ import pytest
7
+
8
+ from clabgen import ClabgenError, FlowsFailed, Lab, pcap_data
9
+
10
+ pytestmark = pytest.mark.agent
11
+
12
+ A_MAC, B_MAC = "02:00:00:00:0a:01", "02:00:00:00:0b:01"
13
+
14
+
15
+ def two_devices(agent, **kw):
16
+ agent.add_device("a", agent.test_port, vlans=[100], mac=A_MAC, **kw.get("a", {}))
17
+ agent.add_device("b", agent.test_port, vlans=[100], mac=B_MAC, **kw.get("b", {}))
18
+
19
+
20
+ def test_info_and_ports(agent):
21
+ assert agent.info()["version"]
22
+ p = agent.port(agent.test_port)
23
+ assert p["oper_up"] and isinstance(p["counters"].get("rx_frames", 0), int)
24
+
25
+
26
+ def test_devices(clean):
27
+ d = clean.add_device("d", clean.test_port, vlans=["0x88a8:200", "10.3"], addresses=["10.0.0.1/24"])
28
+ assert [v["vid"] for v in d["vlans"]] == [200, 10] and d["vlans"][0]["tpid"] == 0x88A8 and d["mac_derived"]
29
+ d = clean.update_device("d", vlans=[300])
30
+ assert [v["vid"] for v in d["vlans"]] == [300] and d["addresses"] == ["10.0.0.1/24"]
31
+ assert "10.0.0.1/24" in clean.device_ip("d")["addresses"]
32
+ clean.delete_device("d")
33
+ with pytest.raises(ClabgenError) as e:
34
+ clean.device("d")
35
+ assert e.value.code == "not_found"
36
+
37
+
38
+ def test_protocols(clean):
39
+ two_devices(clean)
40
+ p = clean.add_protocol("s", "l2", "a", dst_mac=B_MAC, rate=50, checks={"max_loss_pct": 1})
41
+ assert p["state"] == "running" and p["config"]["rate"] == 50
42
+ # Merged onto the current config.
43
+ p = clean.update_protocol("s", rate=20, checks={"max_outage_ms": 100})
44
+ assert p["config"]["rate"] == 20 and p["config"]["dst_mac"] == B_MAC
45
+ assert p["config"]["checks"] == {"max_loss_pct": 1, "max_outage_ms": 100}
46
+ assert clean.disable("s")["state"] == "disabled"
47
+ assert clean.enable("s")["state"] == "running"
48
+ assert clean.run("s", "preview")
49
+ assert [f["name"] for f in clean.flows()] == ["s"]
50
+ with pytest.raises(ClabgenError) as e:
51
+ clean.add_protocol("s", "l2", "a")
52
+ assert e.value.code == "already_exists"
53
+
54
+
55
+ def test_scenario(clean):
56
+ two_devices(clean)
57
+ y = clean.scenario()
58
+ assert "02:00:00:00:0a:01" in y
59
+ clean.apply_scenario("devices: []\n", replace=True)
60
+ assert clean.devices() == []
61
+ clean.apply_scenario(y)
62
+ assert {d["name"] for d in clean.devices()} == {"a", "b"}
63
+
64
+
65
+ def test_events(clean):
66
+ events = clean.events(timeout=10)
67
+ clean.add_device("ev", clean.test_port, vlans=[7])
68
+ for e in events:
69
+ if e["source"] == "device/ev":
70
+ assert e["kind"] and e["time"].year >= 2026
71
+ break
72
+
73
+
74
+ def echo_lab(agent):
75
+ two_devices(agent)
76
+ agent.add_protocol("echo", "l2-echo", "b")
77
+ agent.add_protocol("rt", "l2", "a", dst_mac=B_MAC, reply="echo", rate=100, timeout="300ms", auto_start=True,
78
+ checks={"max_loss_pct": 1, "max_outage_ms": 100})
79
+ agent.add_protocol("oneway", "l2", "a", dst_mac=B_MAC, rate=100, auto_start=True)
80
+ return Lab({"gen": agent})
81
+
82
+
83
+ def test_measure_pass(clean):
84
+ lab = echo_lab(clean)
85
+ lab.wait_flows_pass(timeout=20)
86
+ with lab.measure() as m:
87
+ time.sleep(2)
88
+ m.assert_pass()
89
+ rt, oneway = m["rt"], m["gen/oneway"]
90
+ assert rt.verdict == "pass" and rt.rx > 100 and rt.lost == 0 and rt.rtt_avg_ms is not None
91
+ # One way: the receiver's numbers, no verdict without replies.
92
+ assert oneway.one_way and oneway.rx > 100 and oneway.lost == 0 and oneway.received[0]["device"] == "b"
93
+ assert oneway.verdict is None
94
+
95
+
96
+ def test_measure_fail(clean):
97
+ lab = echo_lab(clean)
98
+ lab.wait_flows_pass(timeout=20)
99
+ with lab.measure() as m:
100
+ clean.port_down(clean.test_port)
101
+ time.sleep(1)
102
+ clean.port_up(clean.test_port)
103
+ with pytest.raises(FlowsFailed) as e:
104
+ m.assert_pass()
105
+ assert [r.id for r in e.value.failed] == ["gen/rt"]
106
+ assert "outage_ms" in m["rt"].failed and m["rt"].lost > 50
107
+ assert "gen/rt: fail" in str(e.value)
108
+ # Failed verdicts stay until cleared; wait_flows_pass clears them.
109
+ assert lab.flow_results(["rt"])[0].verdict == "fail"
110
+ lab.wait_flows_pass(["rt"], timeout=20)
111
+
112
+
113
+ def test_start_stop(clean):
114
+ lab = echo_lab(clean)
115
+ lab.stop_flows()
116
+ lab.stop_flows() # already stopped: fine
117
+ assert {r.state for r in lab.flow_results()} == {"idle"}
118
+ lab.start_flows(["rt"])
119
+ assert lab.flow_results(["rt"])[0].state == "running"
120
+ with pytest.raises(KeyError):
121
+ lab.flow_results(["nope"])
122
+
123
+
124
+ def test_packet_builder(clean):
125
+ two_devices(clean)
126
+ p = clean.preview("a", layers=[{"ethernet": {"dst": B_MAC}}, {"vlan": {"vid": 5}}, {"payload": {"size": 30}}],
127
+ modifiers=[{"field": "vlan.vid", "mode": "increment"}], index=2)
128
+ assert p["index"] == 2 and [l["title"] for l in p["layers"]] == ["Ethernet", "Device VLAN tags (1)", "EtherType", "VLAN", "Payload"]
129
+ vlan = {f["name"]: f["value"] for f in p["layers"][3]["fields"]}
130
+ assert vlan["vid"] == "7"
131
+ # The previewed frame, replayed from a capture: b counts it (it is signed).
132
+ frame = bytes.fromhex(clean.preview("a", layers=[{"ethernet": {"dst": B_MAC}}, {"payload": {"size": 46}}])["hex"])
133
+ import struct
134
+ cap = struct.pack("<IHHiIII", 0xA1B2C3D4, 2, 4, 0, 0, 65535, 1) + struct.pack("<IIII", 0, 0, len(frame), len(frame)) + frame
135
+ assert clean.preview("a", pcap=cap)["frames"][0]["length"] == len(frame)
136
+ clean.add_protocol("replay", "packet", "a", pcap={"data": pcap_data(cap)}, mode="count", count=20, rate=200, auto_start=True)
137
+ clean.wait_protocol("replay", lambda x: x["status"]["state"] == "done", timeout=10)
138
+ assert clean.status("replay")["tx"] == 20
139
+ with pytest.raises(ClabgenError) as e:
140
+ clean.preview("a", layers=[{"udp": {}}])
141
+ assert "needs an IPv4 or IPv6 layer" in e.value.message