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.
@@ -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",)
@@ -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}