napalm-srlinux 0.2.2__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.
- napalm_srlinux/__init__.py +19 -0
- napalm_srlinux/device.py +259 -0
- napalm_srlinux/helpers.py +191 -0
- napalm_srlinux/srlinux.py +1606 -0
- napalm_srlinux-0.2.2.dist-info/METADATA +153 -0
- napalm_srlinux-0.2.2.dist-info/RECORD +10 -0
- napalm_srlinux-0.2.2.dist-info/WHEEL +5 -0
- napalm_srlinux-0.2.2.dist-info/licenses/AUTHORS +7 -0
- napalm_srlinux-0.2.2.dist-info/licenses/LICENSE +201 -0
- napalm_srlinux-0.2.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Copyright 2024 Nokia. All rights reserved.
|
|
2
|
+
#
|
|
3
|
+
# The contents of this file are licensed under the Apache License, Version 2.0
|
|
4
|
+
# (the "License"); you may not use this file except in compliance with the
|
|
5
|
+
# License. You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
|
11
|
+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
|
12
|
+
# License for the specific language governing permissions and limitations under
|
|
13
|
+
# the License.
|
|
14
|
+
|
|
15
|
+
"""napalm-srlinux package."""
|
|
16
|
+
|
|
17
|
+
from napalm_srlinux.srlinux import NokiaSRLinuxDriver
|
|
18
|
+
|
|
19
|
+
__all__ = ("NokiaSRLinuxDriver",)
|
napalm_srlinux/device.py
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
# Copyright 2024 Nokia. All rights reserved.
|
|
2
|
+
#
|
|
3
|
+
# The contents of this file are licensed under the Apache License, Version 2.0
|
|
4
|
+
# (the "License"); you may not use this file except in compliance with the
|
|
5
|
+
# License. You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
|
11
|
+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
|
12
|
+
# License for the specific language governing permissions and limitations under
|
|
13
|
+
# the License.
|
|
14
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
15
|
+
|
|
16
|
+
"""JSON-RPC transport for Nokia SR Linux."""
|
|
17
|
+
|
|
18
|
+
import enum
|
|
19
|
+
import itertools
|
|
20
|
+
import logging
|
|
21
|
+
import ssl
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
import httpx
|
|
25
|
+
from napalm.base.exceptions import CommandErrorException, ConnectionException
|
|
26
|
+
|
|
27
|
+
from napalm_srlinux.helpers import compose_jsonrpc_url, determine_jsonrpc_port
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SRLinuxDevice:
|
|
33
|
+
"""Represents a Nokia SR Linux device, abstracting the JSON-RPC transport.
|
|
34
|
+
|
|
35
|
+
Constructing the object performs no I/O; the HTTP client is created by
|
|
36
|
+
:meth:`open`.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
class RPCMethod(str, enum.Enum):
|
|
40
|
+
"""JSON-RPC methods supported by the SR Linux management server."""
|
|
41
|
+
|
|
42
|
+
GET = "get"
|
|
43
|
+
SET = "set"
|
|
44
|
+
VALIDATE = "validate"
|
|
45
|
+
CLI = "cli"
|
|
46
|
+
DIFF = "diff"
|
|
47
|
+
|
|
48
|
+
class RPCAction(str, enum.Enum):
|
|
49
|
+
"""Actions for set/validate/diff commands."""
|
|
50
|
+
|
|
51
|
+
REPLACE = "replace"
|
|
52
|
+
UPDATE = "update"
|
|
53
|
+
DELETE = "delete"
|
|
54
|
+
|
|
55
|
+
class Datastore(str, enum.Enum):
|
|
56
|
+
"""SR Linux configuration/state datastores."""
|
|
57
|
+
|
|
58
|
+
CANDIDATE = "candidate"
|
|
59
|
+
RUNNING = "running"
|
|
60
|
+
STATE = "state"
|
|
61
|
+
TOOLS = "tools"
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
hostname: str,
|
|
66
|
+
username: str,
|
|
67
|
+
password: str,
|
|
68
|
+
timeout: int = 60,
|
|
69
|
+
optional_args: dict | None = None,
|
|
70
|
+
):
|
|
71
|
+
optional_args = optional_args or {}
|
|
72
|
+
|
|
73
|
+
self.hostname = hostname
|
|
74
|
+
self.username = username
|
|
75
|
+
self.password = password
|
|
76
|
+
self.timeout = timeout
|
|
77
|
+
|
|
78
|
+
self.insecure: bool = optional_args.get("insecure", False)
|
|
79
|
+
self.skip_verify: bool = optional_args.get("skip_verify", False)
|
|
80
|
+
self.tls_ca: str = optional_args.get("tls_ca", "")
|
|
81
|
+
self.tls_cert_path: str = optional_args.get("tls_cert_path", "")
|
|
82
|
+
self.tls_key_path: str = optional_args.get("tls_key_path", "")
|
|
83
|
+
self.tls_key_password: str = optional_args.get("tls_key_password", "")
|
|
84
|
+
|
|
85
|
+
self.jsonrpc_port = determine_jsonrpc_port(optional_args)
|
|
86
|
+
self.jsonrpc_url = compose_jsonrpc_url(self.hostname, self.jsonrpc_port, self.insecure)
|
|
87
|
+
|
|
88
|
+
self.jsonrpc_client: httpx.Client | None = None
|
|
89
|
+
self._request_id = itertools.count(1)
|
|
90
|
+
|
|
91
|
+
if self.insecure and self.jsonrpc_port == 443:
|
|
92
|
+
logger.warning(
|
|
93
|
+
"insecure=True (plain http) with port 443 configured; "
|
|
94
|
+
"the JSON-RPC server normally serves https on 443"
|
|
95
|
+
)
|
|
96
|
+
if not self.insecure and self.jsonrpc_port == 80:
|
|
97
|
+
logger.warning(
|
|
98
|
+
"Port 80 configured without insecure=True; "
|
|
99
|
+
"the JSON-RPC server normally serves plain http on 80"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# ----------------------------------------------------------------- lifecycle
|
|
103
|
+
|
|
104
|
+
def open(self) -> None:
|
|
105
|
+
"""Create the HTTP client and verify the JSON-RPC endpoint is reachable."""
|
|
106
|
+
if self.jsonrpc_client is None:
|
|
107
|
+
self.jsonrpc_client = self._new_jsonrpc_client()
|
|
108
|
+
try:
|
|
109
|
+
self.jsonrpc_client.head(self.jsonrpc_url)
|
|
110
|
+
except httpx.HTTPError as exc:
|
|
111
|
+
raise ConnectionException(
|
|
112
|
+
f"Error opening http(s) connection to {self.jsonrpc_url}: {exc}"
|
|
113
|
+
) from exc
|
|
114
|
+
|
|
115
|
+
def close(self) -> None:
|
|
116
|
+
"""Close the HTTP client."""
|
|
117
|
+
if self.jsonrpc_client is not None:
|
|
118
|
+
self.jsonrpc_client.close()
|
|
119
|
+
self.jsonrpc_client = None
|
|
120
|
+
|
|
121
|
+
def is_alive(self) -> bool:
|
|
122
|
+
"""Return True when the JSON-RPC endpoint answers an HTTP HEAD request."""
|
|
123
|
+
if self.jsonrpc_client is None:
|
|
124
|
+
return False
|
|
125
|
+
try:
|
|
126
|
+
self.jsonrpc_client.head(self.jsonrpc_url)
|
|
127
|
+
return True
|
|
128
|
+
except httpx.HTTPError:
|
|
129
|
+
return False
|
|
130
|
+
|
|
131
|
+
# ----------------------------------------------------------------- RPC surface
|
|
132
|
+
|
|
133
|
+
def get_paths(self, paths: list[str], datastore: Datastore) -> list:
|
|
134
|
+
"""Get the subtrees for a list of YANG paths from a datastore.
|
|
135
|
+
|
|
136
|
+
Returns a list of results aligned with the requested paths.
|
|
137
|
+
"""
|
|
138
|
+
commands = [{"path": p, "datastore": datastore.value} for p in paths]
|
|
139
|
+
response = self._jsonrpc_request(self.RPCMethod.GET, {"commands": commands})
|
|
140
|
+
return response["result"]
|
|
141
|
+
|
|
142
|
+
def run_cli_commands(self, commands: list[str], output_format: str = "text") -> list:
|
|
143
|
+
"""Run CLI commands; returns a list of results aligned with the commands."""
|
|
144
|
+
response = self._jsonrpc_request(
|
|
145
|
+
self.RPCMethod.CLI,
|
|
146
|
+
{"commands": commands, "output-format": output_format},
|
|
147
|
+
)
|
|
148
|
+
return response["result"]
|
|
149
|
+
|
|
150
|
+
def set_paths(
|
|
151
|
+
self,
|
|
152
|
+
commands: list[dict],
|
|
153
|
+
datastore: Datastore = Datastore.CANDIDATE,
|
|
154
|
+
confirm_timeout: int | None = None,
|
|
155
|
+
) -> dict:
|
|
156
|
+
"""Apply configuration commands ({action, path, value}) via the set method.
|
|
157
|
+
|
|
158
|
+
A set request against the candidate datastore is transactional and
|
|
159
|
+
commits on success.
|
|
160
|
+
"""
|
|
161
|
+
params: dict[str, Any] = {"commands": commands, "datastore": datastore.value}
|
|
162
|
+
if confirm_timeout is not None:
|
|
163
|
+
params["confirm-timeout"] = confirm_timeout
|
|
164
|
+
return self._jsonrpc_request(self.RPCMethod.SET, params)
|
|
165
|
+
|
|
166
|
+
def validate_paths(self, commands: list[dict]) -> dict:
|
|
167
|
+
"""Validate configuration commands without applying them."""
|
|
168
|
+
return self._jsonrpc_request(self.RPCMethod.VALIDATE, {"commands": commands})
|
|
169
|
+
|
|
170
|
+
def diff_paths(self, commands: list[dict], output_format: str = "text") -> list:
|
|
171
|
+
"""Diff configuration commands against the running config."""
|
|
172
|
+
response = self._jsonrpc_request(
|
|
173
|
+
self.RPCMethod.DIFF,
|
|
174
|
+
{"commands": commands, "output-format": output_format},
|
|
175
|
+
)
|
|
176
|
+
return response.get("result", [])
|
|
177
|
+
|
|
178
|
+
# ----------------------------------------------------------------- internals
|
|
179
|
+
|
|
180
|
+
def _jsonrpc_request(self, method: RPCMethod, params: dict) -> dict:
|
|
181
|
+
"""POST a JSON-RPC request and return the response body.
|
|
182
|
+
|
|
183
|
+
Raises ConnectionException for authentication/transport problems and
|
|
184
|
+
CommandErrorException when the server reports a JSON-RPC error.
|
|
185
|
+
"""
|
|
186
|
+
if self.jsonrpc_client is None:
|
|
187
|
+
raise ConnectionException("Device connection is not open; call open() first")
|
|
188
|
+
|
|
189
|
+
request_data = {
|
|
190
|
+
"jsonrpc": "2.0",
|
|
191
|
+
"id": next(self._request_id),
|
|
192
|
+
"method": method.value,
|
|
193
|
+
"params": params,
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
response = self.jsonrpc_client.post(
|
|
198
|
+
self.jsonrpc_url,
|
|
199
|
+
json=request_data,
|
|
200
|
+
timeout=self.timeout,
|
|
201
|
+
)
|
|
202
|
+
except httpx.HTTPError as exc:
|
|
203
|
+
raise ConnectionException(f"JSON-RPC request failed: {exc}") from exc
|
|
204
|
+
|
|
205
|
+
if response.status_code in (401, 403):
|
|
206
|
+
raise ConnectionException(
|
|
207
|
+
f"Authentication failed (HTTP {response.status_code})"
|
|
208
|
+
)
|
|
209
|
+
if not response.is_success:
|
|
210
|
+
raise CommandErrorException(
|
|
211
|
+
f"JSON-RPC request returned HTTP {response.status_code}: {response.text}"
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
body = response.json()
|
|
215
|
+
if body.get("error"):
|
|
216
|
+
error = body["error"]
|
|
217
|
+
raise CommandErrorException(
|
|
218
|
+
f"JSON-RPC error {error.get('code')}: {error.get('message')}"
|
|
219
|
+
)
|
|
220
|
+
return body
|
|
221
|
+
|
|
222
|
+
def _build_verify(self) -> bool | ssl.SSLContext:
|
|
223
|
+
"""Determine the TLS verification setting for the HTTP client."""
|
|
224
|
+
if self.insecure:
|
|
225
|
+
# plain http; verify is irrelevant but must not block anything
|
|
226
|
+
return False
|
|
227
|
+
if self.skip_verify:
|
|
228
|
+
ctx = ssl.create_default_context()
|
|
229
|
+
ctx.check_hostname = False
|
|
230
|
+
ctx.verify_mode = ssl.CERT_NONE
|
|
231
|
+
return ctx
|
|
232
|
+
if self.tls_ca:
|
|
233
|
+
return ssl.create_default_context(cafile=self.tls_ca)
|
|
234
|
+
return True
|
|
235
|
+
|
|
236
|
+
def _build_cert(self) -> tuple | None:
|
|
237
|
+
"""Build the client certificate tuple; None unless both cert and key are set."""
|
|
238
|
+
if not (self.tls_cert_path and self.tls_key_path):
|
|
239
|
+
return None
|
|
240
|
+
if self.tls_key_password:
|
|
241
|
+
return (self.tls_cert_path, self.tls_key_path, self.tls_key_password)
|
|
242
|
+
return (self.tls_cert_path, self.tls_key_path)
|
|
243
|
+
|
|
244
|
+
def _new_jsonrpc_client(self) -> httpx.Client:
|
|
245
|
+
"""Create the HTTP client, configured for the requested TLS mode."""
|
|
246
|
+
opts: dict[str, Any] = {
|
|
247
|
+
"verify": self._build_verify(),
|
|
248
|
+
"auth": httpx.BasicAuth(self.username, self.password),
|
|
249
|
+
"headers": {
|
|
250
|
+
"Content-Type": "application/json",
|
|
251
|
+
"Accept": "application/json",
|
|
252
|
+
},
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
cert = self._build_cert()
|
|
256
|
+
if cert:
|
|
257
|
+
opts["cert"] = cert
|
|
258
|
+
|
|
259
|
+
return httpx.Client(**opts)
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# Copyright 2024 Nokia. All rights reserved.
|
|
2
|
+
#
|
|
3
|
+
# The contents of this file are licensed under the Apache License, Version 2.0
|
|
4
|
+
# (the "License"); you may not use this file except in compliance with the
|
|
5
|
+
# License. You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
|
11
|
+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
|
12
|
+
# License for the specific language governing permissions and limitations under
|
|
13
|
+
# the License.
|
|
14
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
15
|
+
|
|
16
|
+
"""Pure helper functions for the SR Linux NAPALM driver.
|
|
17
|
+
|
|
18
|
+
Everything in this module is free of I/O so it can be unit tested without a
|
|
19
|
+
device or any mocking.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import datetime
|
|
23
|
+
import re
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
# SR Linux timestamps, e.g. "2024-08-24T09:36:31.000Z"
|
|
27
|
+
SRL_TIME_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ"
|
|
28
|
+
|
|
29
|
+
# port-speed leaf value -> Mbit/s (NAPALM interface speed unit)
|
|
30
|
+
PORT_SPEED_MBITS = {
|
|
31
|
+
"10M": 10.0,
|
|
32
|
+
"100M": 100.0,
|
|
33
|
+
"1G": 1000.0,
|
|
34
|
+
"10G": 10_000.0,
|
|
35
|
+
"25G": 25_000.0,
|
|
36
|
+
"40G": 40_000.0,
|
|
37
|
+
"50G": 50_000.0,
|
|
38
|
+
"100G": 100_000.0,
|
|
39
|
+
"200G": 200_000.0,
|
|
40
|
+
"400G": 400_000.0,
|
|
41
|
+
"800G": 800_000.0,
|
|
42
|
+
"1T": 1_000_000.0,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def determine_jsonrpc_port(optional_args: dict | None) -> int:
|
|
47
|
+
"""Determine the JSON-RPC port from the driver's optional arguments.
|
|
48
|
+
|
|
49
|
+
An explicitly configured ``jsonrpc_port`` always wins; otherwise port 80
|
|
50
|
+
is used for ``insecure`` (plain http) connections and 443 for https.
|
|
51
|
+
"""
|
|
52
|
+
optional_args = optional_args or {}
|
|
53
|
+
|
|
54
|
+
if optional_args.get("jsonrpc_port"):
|
|
55
|
+
return int(optional_args["jsonrpc_port"])
|
|
56
|
+
if optional_args.get("insecure"):
|
|
57
|
+
return 80
|
|
58
|
+
return 443
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def compose_jsonrpc_url(hostname: str, port: int, insecure: bool = False) -> str:
|
|
62
|
+
"""Compose the JSON-RPC endpoint URL."""
|
|
63
|
+
proto = "http" if insecure else "https"
|
|
64
|
+
return f"{proto}://{hostname}:{port}/jsonrpc"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def strip_module_prefix(name: str) -> str:
|
|
68
|
+
"""Strip a YANG module prefix from a value, e.g. "srl_nokia-common:ipv4-unicast" -> "ipv4-unicast"."""
|
|
69
|
+
return name.split(":", 1)[-1]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def value_at(obj: Any, *keys: str | int, default: Any = None) -> Any:
|
|
73
|
+
"""Traverse nested dicts/lists, returning ``default`` when any step is missing.
|
|
74
|
+
|
|
75
|
+
Keys with a YANG module prefix are matched both verbatim and by their
|
|
76
|
+
unprefixed name, so ``value_at(d, "interface")`` finds
|
|
77
|
+
``d["srl_nokia-interfaces:interface"]`` too.
|
|
78
|
+
"""
|
|
79
|
+
cur = obj
|
|
80
|
+
for key in keys:
|
|
81
|
+
if isinstance(cur, list):
|
|
82
|
+
if not isinstance(key, int) or key >= len(cur):
|
|
83
|
+
return default
|
|
84
|
+
cur = cur[key]
|
|
85
|
+
elif isinstance(cur, dict):
|
|
86
|
+
if key in cur:
|
|
87
|
+
cur = cur[key]
|
|
88
|
+
else:
|
|
89
|
+
match = [v for k, v in cur.items() if strip_module_prefix(k) == key]
|
|
90
|
+
if not match:
|
|
91
|
+
return default
|
|
92
|
+
cur = match[0]
|
|
93
|
+
else:
|
|
94
|
+
return default
|
|
95
|
+
return cur if cur is not None else default
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def parse_srl_time(timestamp: str) -> datetime.datetime:
|
|
99
|
+
"""Parse an SR Linux timestamp string."""
|
|
100
|
+
return datetime.datetime.strptime(timestamp, SRL_TIME_FORMAT)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def seconds_between(system_time: str, reference_time: str) -> float:
|
|
104
|
+
"""Seconds elapsed between a (past) reference timestamp and the system time."""
|
|
105
|
+
return (parse_srl_time(system_time) - parse_srl_time(reference_time)).total_seconds()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def port_speed_to_mbits(port_speed: str | None) -> float:
|
|
109
|
+
"""Convert an SR Linux port-speed leaf (e.g. "100G") to Mbit/s."""
|
|
110
|
+
return PORT_SPEED_MBITS.get(port_speed, 0.0)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
_PING_STATS_RE = re.compile(
|
|
114
|
+
r"(\d+) packets transmitted, (\d+) received, (\d*\.?\d*)% packet loss"
|
|
115
|
+
r"(?:.*?rtt min/avg/max/mdev = (\d*\.?\d*)/(\d*\.?\d*)/(\d*\.?\d*)/(\d*\.?\d*))?",
|
|
116
|
+
re.DOTALL,
|
|
117
|
+
)
|
|
118
|
+
_PING_PROBE_RE = re.compile(
|
|
119
|
+
r"from (?:(\S+) \()?((?:\d{1,3}\.){3}\d{1,3}|[0-9a-fA-F:]+)\)?: "
|
|
120
|
+
r"icmp_seq=\d+ ttl=\d+ time=(\d+\.?\d*) ms"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def parse_ping_output(text: str) -> dict:
|
|
125
|
+
"""Parse SR Linux ping CLI output into the NAPALM ping dictionary."""
|
|
126
|
+
stats = _PING_STATS_RE.search(text)
|
|
127
|
+
if not stats:
|
|
128
|
+
return {"error": "Unable to parse ping output"}
|
|
129
|
+
|
|
130
|
+
sent = int(stats.group(1))
|
|
131
|
+
received = int(stats.group(2))
|
|
132
|
+
has_rtt = stats.group(4) is not None
|
|
133
|
+
|
|
134
|
+
results = [
|
|
135
|
+
{"ip_address": m.group(2), "rtt": float(m.group(3))}
|
|
136
|
+
for m in _PING_PROBE_RE.finditer(text)
|
|
137
|
+
]
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
"success": {
|
|
141
|
+
"probes_sent": sent,
|
|
142
|
+
"packet_loss": sent - received,
|
|
143
|
+
"rtt_min": float(stats.group(4)) if has_rtt else -1.0,
|
|
144
|
+
"rtt_avg": float(stats.group(5)) if has_rtt else -1.0,
|
|
145
|
+
"rtt_max": float(stats.group(6)) if has_rtt else -1.0,
|
|
146
|
+
"rtt_stddev": float(stats.group(7)) if has_rtt else -1.0,
|
|
147
|
+
"results": results,
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
_TRACEROUTE_HOP_RE = re.compile(
|
|
153
|
+
r"^\s*(\d+)\s+(.*)$",
|
|
154
|
+
)
|
|
155
|
+
_TRACEROUTE_PROBE_RE = re.compile(
|
|
156
|
+
r"(?:(\S+)\s+\(((?:\d{1,3}\.){3}\d{1,3}|[0-9a-fA-F:]+)\)\s+)?(\d+\.?\d*)\s*ms"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def parse_traceroute_output(text: str) -> dict:
|
|
161
|
+
"""Parse SR Linux traceroute CLI output into the NAPALM traceroute dictionary."""
|
|
162
|
+
hops: dict[int, dict] = {}
|
|
163
|
+
|
|
164
|
+
for line in text.splitlines():
|
|
165
|
+
hop_match = _TRACEROUTE_HOP_RE.match(line)
|
|
166
|
+
if not hop_match or "traceroute to" in line:
|
|
167
|
+
continue
|
|
168
|
+
hop_index = int(hop_match.group(1))
|
|
169
|
+
remainder = hop_match.group(2)
|
|
170
|
+
|
|
171
|
+
probes: dict[int, dict] = {}
|
|
172
|
+
probe_index = 0
|
|
173
|
+
last_host, last_ip = "", ""
|
|
174
|
+
for probe in _TRACEROUTE_PROBE_RE.finditer(remainder):
|
|
175
|
+
probe_index += 1
|
|
176
|
+
host, ip, rtt = probe.group(1), probe.group(2), probe.group(3)
|
|
177
|
+
if host:
|
|
178
|
+
last_host, last_ip = host, ip
|
|
179
|
+
probes[probe_index] = {
|
|
180
|
+
"rtt": float(rtt),
|
|
181
|
+
"ip_address": last_ip,
|
|
182
|
+
"host_name": last_host,
|
|
183
|
+
}
|
|
184
|
+
if "*" in remainder and not probes:
|
|
185
|
+
probes[1] = {"rtt": -1.0, "ip_address": "*", "host_name": "*"}
|
|
186
|
+
if probes:
|
|
187
|
+
hops[hop_index] = {"probes": probes}
|
|
188
|
+
|
|
189
|
+
if not hops:
|
|
190
|
+
return {"error": "Unable to parse traceroute output"}
|
|
191
|
+
return {"success": hops}
|