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,1606 @@
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
+ """NAPALM driver for Nokia SR Linux, using the JSON-RPC management interface.
17
+
18
+ Read https://napalm.readthedocs.io for more information.
19
+ """
20
+
21
+ # annotations must stay lazy: some napalm.base.models names differ across the
22
+ # supported napalm range (e.g. BGPConfigGroupDict arrived in 5.1.0)
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import logging
27
+ import uuid
28
+
29
+ from napalm.base import NetworkDriver, models
30
+ from napalm.base.exceptions import (
31
+ CommandErrorException,
32
+ CommitConfirmException,
33
+ CommitError,
34
+ ConnectionException,
35
+ MergeConfigException,
36
+ ReplaceConfigException,
37
+ )
38
+ from napalm.base.helpers import as_number, convert
39
+
40
+ from napalm_srlinux import helpers
41
+ from napalm_srlinux.device import SRLinuxDevice
42
+
43
+ logger = logging.getLogger(__name__)
44
+
45
+ Datastore = SRLinuxDevice.Datastore
46
+ RPCAction = SRLinuxDevice.RPCAction
47
+
48
+
49
+ class NokiaSRLinuxDriver(NetworkDriver):
50
+ """NAPALM driver for Nokia SR Linux."""
51
+
52
+ platform = "srlinux"
53
+
54
+ def __init__(self, hostname, username, password, timeout=60, optional_args=None):
55
+ """Constructor."""
56
+ optional_args = optional_args or {}
57
+
58
+ self.hostname = hostname
59
+ self.username = username
60
+ self.password = password
61
+ self.timeout = timeout
62
+
63
+ # 'json' (default) or 'cli' formatted running config in get_config()
64
+ self.running_format = optional_args.get("running_format", "json")
65
+ # commit with 'save' persists the config to startup
66
+ self.commit_mode = "save" if optional_args.get("commit_save") else "now"
67
+
68
+ # client-side candidate configuration, see load_*_candidate()
69
+ self._candidate: dict | None = None
70
+ # checkpoints and named candidates persist on the device across driver
71
+ # instances, so both need a per-instance component: otherwise instances
72
+ # overwrite each other's rollback anchors, and a candidate left open by
73
+ # one session (e.g. by 'commit confirmed') is silently reused by the
74
+ # next, which then commits against a stale baseline
75
+ session = uuid.uuid4().hex[:8]
76
+ self._checkpoint_prefix = f"NAPALM-{session}"
77
+ self._candidate_name = f"napalm-{session}"
78
+ self._checkpoint_id = 0
79
+ self._last_checkpoint: str | None = None
80
+ # the named candidate held open by an unconfirmed CLI-mode commit
81
+ self._pending_cli_candidate: str | None = None
82
+ # informational only: pending confirms live device-side and survive
83
+ # across (stateless) JSON-RPC sessions, so behavior always consults
84
+ # has_pending_commit() rather than this flag
85
+ self._pending_confirm = False
86
+
87
+ self.device = SRLinuxDevice(
88
+ hostname, username, password, timeout=timeout, optional_args=optional_args
89
+ )
90
+
91
+ # ------------------------------------------------------------------ lifecycle
92
+
93
+ def open(self) -> None:
94
+ self.device.open()
95
+
96
+ def close(self) -> None:
97
+ self.device.close()
98
+
99
+ def is_alive(self) -> models.AliveDict:
100
+ """Tests if the JSON-RPC endpoint of the device is reachable."""
101
+ return {"is_alive": self.device.is_alive()}
102
+
103
+ # ------------------------------------------------------------------ getters
104
+
105
+ def get_facts(self) -> models.FactsDict:
106
+ """
107
+ Returns a dictionary containing the following information:
108
+ uptime - Uptime of the device in seconds.
109
+ vendor - Manufacturer of the device.
110
+ model - Device model.
111
+ hostname - Hostname of the device
112
+ fqdn - Fqdn of the device
113
+ os_version - String with the OS version running on the device.
114
+ serial_number - Serial number of the device
115
+ interface_list - List of the interfaces of the device
116
+ """
117
+ chassis, information, hostname_data, interfaces = self.device.get_paths(
118
+ [
119
+ "/platform/chassis",
120
+ "/system/information",
121
+ "/system/name/host-name",
122
+ "/interface[name=*]",
123
+ ],
124
+ Datastore.STATE,
125
+ )
126
+
127
+ # /system/information exposes the boot time as a timestamp
128
+ uptime = -1.0
129
+ current_time = information.get("current-datetime")
130
+ boot_time = information.get("last-booted") or information.get("uptime")
131
+ if current_time and boot_time:
132
+ uptime = helpers.seconds_between(current_time, boot_time)
133
+
134
+ hostname = hostname_data if isinstance(hostname_data, str) else ""
135
+ interface_list = [
136
+ i["name"] for i in helpers.value_at(interfaces, "interface", default=[])
137
+ ]
138
+
139
+ return {
140
+ "uptime": uptime,
141
+ "vendor": "Nokia",
142
+ "model": chassis.get("type", ""),
143
+ "hostname": hostname,
144
+ "fqdn": hostname,
145
+ "os_version": information.get("version", ""),
146
+ "serial_number": chassis.get("serial-number", ""),
147
+ "interface_list": interface_list,
148
+ }
149
+
150
+ def get_interfaces(self) -> dict[str, models.InterfaceDict]:
151
+ """
152
+ Returns a dictionary of dictionaries.
153
+ The keys for the first dictionary will be the interfaces in the devices.
154
+ """
155
+ interfaces_data, information = self.device.get_paths(
156
+ ["/interface[name=*]", "/system/information"],
157
+ Datastore.STATE,
158
+ )
159
+
160
+ current_time = information.get("current-datetime")
161
+
162
+ interfaces = {}
163
+ for interface in helpers.value_at(interfaces_data, "interface", default=[]):
164
+ last_flapped = -1.0
165
+ if current_time and interface.get("last-change"):
166
+ last_flapped = helpers.seconds_between(current_time, interface["last-change"])
167
+
168
+ interfaces[interface["name"]] = {
169
+ "is_up": interface.get("oper-state") == "up",
170
+ "is_enabled": interface.get("admin-state") == "enable",
171
+ "description": interface.get("description", ""),
172
+ "last_flapped": last_flapped,
173
+ "speed": helpers.port_speed_to_mbits(
174
+ helpers.value_at(interface, "ethernet", "port-speed")
175
+ ),
176
+ "mtu": interface.get("mtu", -1),
177
+ "mac_address": helpers.value_at(
178
+ interface, "ethernet", "hw-mac-address", default=""
179
+ ),
180
+ }
181
+ return interfaces
182
+
183
+ def get_interfaces_counters(self) -> dict[str, models.InterfaceCounterDict]:
184
+ """
185
+ Returns a dictionary of dictionaries keyed by subinterface name with the
186
+ standard NAPALM counters. Octet/error/discard counters are taken per
187
+ subinterface; unicast/multicast/broadcast packet counters are only
188
+ available at the parent interface level.
189
+ """
190
+ (interfaces_data,) = self.device.get_paths(["/interface[name=*]"], Datastore.STATE)
191
+
192
+ counters = {}
193
+ for interface in helpers.value_at(interfaces_data, "interface", default=[]):
194
+ if_stats = interface.get("statistics", {})
195
+ for subinterface in interface.get("subinterface", []):
196
+ sub_stats = subinterface.get("statistics", {})
197
+ counters[subinterface["name"]] = {
198
+ "tx_errors": convert(int, sub_stats.get("out-error-packets"), default=-1),
199
+ "rx_errors": convert(int, sub_stats.get("in-error-packets"), default=-1),
200
+ "tx_discards": convert(
201
+ int, sub_stats.get("out-discarded-packets"), default=-1
202
+ ),
203
+ "rx_discards": convert(
204
+ int, sub_stats.get("in-discarded-packets"), default=-1
205
+ ),
206
+ "tx_octets": convert(int, sub_stats.get("out-octets"), default=-1),
207
+ "rx_octets": convert(int, sub_stats.get("in-octets"), default=-1),
208
+ "tx_unicast_packets": convert(
209
+ int, if_stats.get("out-unicast-packets"), default=-1
210
+ ),
211
+ "rx_unicast_packets": convert(
212
+ int, if_stats.get("in-unicast-packets"), default=-1
213
+ ),
214
+ "tx_multicast_packets": convert(
215
+ int, if_stats.get("out-multicast-packets"), default=-1
216
+ ),
217
+ "rx_multicast_packets": convert(
218
+ int, if_stats.get("in-multicast-packets"), default=-1
219
+ ),
220
+ "tx_broadcast_packets": convert(
221
+ int, if_stats.get("out-broadcast-packets"), default=-1
222
+ ),
223
+ "rx_broadcast_packets": convert(
224
+ int, if_stats.get("in-broadcast-packets"), default=-1
225
+ ),
226
+ }
227
+ return counters
228
+
229
+ def get_interfaces_ip(self) -> dict[str, models.InterfacesIPDict]:
230
+ """
231
+ Returns all configured IP addresses on all subinterfaces as a dictionary
232
+ of dictionaries keyed by subinterface name.
233
+ """
234
+ (interfaces_data,) = self.device.get_paths(
235
+ ["/interface[name=*]/subinterface"], Datastore.STATE
236
+ )
237
+
238
+ interfaces_ip = {}
239
+ for interface in helpers.value_at(interfaces_data, "interface", default=[]):
240
+ for subinterface in interface.get("subinterface", []):
241
+ addresses: dict = {}
242
+ for version in ("ipv4", "ipv6"):
243
+ for address in helpers.value_at(
244
+ subinterface, version, "address", default=[]
245
+ ):
246
+ ip, prefix_length = address["ip-prefix"].split("/")
247
+ addresses.setdefault(version, {})[ip] = {
248
+ "prefix_length": int(prefix_length)
249
+ }
250
+ interfaces_ip[subinterface["name"]] = addresses
251
+ return interfaces_ip
252
+
253
+ def get_arp_table(self, vrf: str = "") -> list[models.ARPTableDict]:
254
+ """
255
+ Returns a list of dictionaries having the following set of keys:
256
+ interface (string)
257
+ mac (string)
258
+ ip (string)
259
+ age (float)
260
+ 'vrf' of null-string will default to all VRFs.
261
+ """
262
+ ni_path = f"/network-instance[name={vrf or '*'}]"
263
+ ni_data, interfaces_data = self.device.get_paths(
264
+ [ni_path, "/interface[name=*]/subinterface"], Datastore.STATE
265
+ )
266
+
267
+ # subinterfaces that are members of the selected network instance(s)
268
+ member_subinterfaces = {
269
+ member["name"]
270
+ for instance in self._network_instance_list(ni_data, vrf)
271
+ for member in instance.get("interface", [])
272
+ }
273
+
274
+ arp_table = []
275
+ for interface in helpers.value_at(interfaces_data, "interface", default=[]):
276
+ for subinterface in interface.get("subinterface", []):
277
+ name = subinterface.get("name")
278
+ if name not in member_subinterfaces:
279
+ continue
280
+
281
+ arp = helpers.value_at(subinterface, "ipv4", "arp", default={})
282
+ timeout = convert(float, arp.get("timeout"), default=-1.0)
283
+ for neighbor in arp.get("neighbor", []):
284
+ arp_table.append(
285
+ {
286
+ "interface": name,
287
+ "mac": neighbor.get("link-layer-address", ""),
288
+ "ip": neighbor.get("ipv4-address", ""),
289
+ "age": timeout,
290
+ }
291
+ )
292
+
293
+ nd = helpers.value_at(subinterface, "ipv6", "neighbor-discovery", default={})
294
+ reachable_time = convert(float, nd.get("reachable-time"), default=-1.0)
295
+ for neighbor in nd.get("neighbor", []):
296
+ arp_table.append(
297
+ {
298
+ "interface": name,
299
+ "mac": neighbor.get("link-layer-address", ""),
300
+ "ip": neighbor.get("ipv6-address", ""),
301
+ "age": reachable_time,
302
+ }
303
+ )
304
+ return arp_table
305
+
306
+ def get_ipv6_neighbors_table(self) -> list[models.IPV6NeighborDict]:
307
+ """
308
+ Get IPv6 neighbors table information.
309
+
310
+ Return a list of dictionaries having the following set of keys:
311
+ interface (string)
312
+ mac (string)
313
+ ip (string)
314
+ age (float) in seconds
315
+ state (string)
316
+ """
317
+ (interfaces_data,) = self.device.get_paths(
318
+ ["/interface[name=*]/subinterface"], Datastore.STATE
319
+ )
320
+
321
+ neighbors = []
322
+ for interface in helpers.value_at(interfaces_data, "interface", default=[]):
323
+ for subinterface in interface.get("subinterface", []):
324
+ nd = helpers.value_at(subinterface, "ipv6", "neighbor-discovery", default={})
325
+ for neighbor in nd.get("neighbor", []):
326
+ # SR Linux does not expose the entry age; like v1, report the
327
+ # next-state-time as an epoch timestamp instead.
328
+ age = -1.0
329
+ if neighbor.get("next-state-time"):
330
+ age = helpers.parse_srl_time(neighbor["next-state-time"]).timestamp()
331
+ neighbors.append(
332
+ {
333
+ "interface": subinterface.get("name", ""),
334
+ "mac": neighbor.get("link-layer-address", ""),
335
+ "ip": neighbor.get("ipv6-address", ""),
336
+ "age": age,
337
+ "state": neighbor.get("current-state", ""),
338
+ }
339
+ )
340
+ return neighbors
341
+
342
+ def get_bgp_neighbors(self) -> dict[str, models.BGPStateNeighborsPerVRFDict]:
343
+ """
344
+ Returns a dictionary of dictionaries. The keys for the first dictionary
345
+ will be the vrf (global if no vrf).
346
+ """
347
+ bgp_data, information = self.device.get_paths(
348
+ ["/network-instance[name=*]/protocols/bgp", "/system/information"],
349
+ Datastore.STATE,
350
+ )
351
+
352
+ current_time = information.get("current-datetime")
353
+ return_data: dict = {"global": {"router_id": "", "peers": {}}}
354
+
355
+ for instance in helpers.value_at(bgp_data, "network-instance", default=[]):
356
+ bgp = helpers.value_at(instance, "protocols", "bgp")
357
+ if not bgp:
358
+ continue
359
+
360
+ # the SR Linux global routing table is called "default"
361
+ instance_name = instance.get("name")
362
+ if instance_name == "default":
363
+ instance_name = "global"
364
+
365
+ global_asn = bgp.get("autonomous-system")
366
+ return_data[instance_name] = {
367
+ "router_id": bgp.get("router-id", ""),
368
+ "peers": {},
369
+ }
370
+
371
+ for neighbor in bgp.get("neighbor", []):
372
+ is_up = neighbor.get("session-state") == "established"
373
+
374
+ uptime = -1
375
+ if is_up and current_time and neighbor.get("last-established"):
376
+ uptime = int(
377
+ helpers.seconds_between(current_time, neighbor["last-established"])
378
+ )
379
+
380
+ address_family = {}
381
+ for afi_safi in neighbor.get("afi-safi", []):
382
+ afi_name = helpers.strip_module_prefix(afi_safi.get("afi-safi-name", ""))
383
+ if afi_name == "ipv4-unicast":
384
+ family = "ipv4"
385
+ elif afi_name == "ipv6-unicast":
386
+ family = "ipv6"
387
+ else:
388
+ continue
389
+ address_family[family] = {
390
+ "received_prefixes": convert(
391
+ int, afi_safi.get("received-routes"), default=-1
392
+ ),
393
+ "accepted_prefixes": convert(
394
+ int, afi_safi.get("active-routes"), default=-1
395
+ ),
396
+ "sent_prefixes": convert(int, afi_safi.get("sent-routes"), default=-1),
397
+ }
398
+
399
+ local_as = helpers.value_at(neighbor, "local-as", "as-number", default=global_asn)
400
+ return_data[instance_name]["peers"][neighbor.get("peer-address")] = {
401
+ "local_as": as_number(local_as) if local_as else -1,
402
+ "remote_as": as_number(neighbor.get("peer-as", global_asn or -1)),
403
+ "remote_id": neighbor.get("peer-remote-id", neighbor.get("peer-address", "")),
404
+ "is_up": is_up,
405
+ "is_enabled": neighbor.get("admin-state") == "enable",
406
+ "description": neighbor.get("description", ""),
407
+ "uptime": uptime,
408
+ "address_family": address_family,
409
+ }
410
+ return return_data
411
+
412
+ def get_bgp_neighbors_detail(
413
+ self, neighbor_address: str = ""
414
+ ) -> dict[str, dict[int, list[models.PeerDetailsDict]]]:
415
+ """
416
+ Returns a detailed view of the BGP neighbors as a dictionary of lists,
417
+ keyed by network instance and remote AS number.
418
+ """
419
+ (bgp_data,) = self.device.get_paths(
420
+ ["/network-instance[name=*]/protocols/bgp"], Datastore.STATE
421
+ )
422
+
423
+ details: dict = {}
424
+ for instance in helpers.value_at(bgp_data, "network-instance", default=[]):
425
+ bgp = helpers.value_at(instance, "protocols", "bgp")
426
+ if not bgp:
427
+ continue
428
+
429
+ instance_name = instance.get("name")
430
+ global_asn = bgp.get("autonomous-system")
431
+ router_id = bgp.get("router-id", "")
432
+ details[instance_name] = {}
433
+
434
+ for neighbor in bgp.get("neighbor", []):
435
+ peer_ip = neighbor.get("peer-address")
436
+ if not peer_ip or (neighbor_address and neighbor_address != peer_ip):
437
+ continue
438
+
439
+ local_as = helpers.value_at(neighbor, "local-as", "as-number", default=global_asn)
440
+ peer_as = neighbor.get("peer-as", global_asn)
441
+
442
+ transport = neighbor.get("transport", {})
443
+ timers = neighbor.get("timers", {})
444
+ sent = neighbor.get("sent-messages", {})
445
+ received = neighbor.get("received-messages", {})
446
+ local_address = transport.get("local-address", "")
447
+
448
+ # prefix counts: prefer ipv4-unicast, fall back to ipv6-unicast
449
+ ipv4 = helpers.value_at(neighbor, "ipv4-unicast", default={})
450
+ ipv6 = helpers.value_at(neighbor, "ipv6-unicast", default={})
451
+ afi = ipv4 if ipv4.get("received-routes") is not None else ipv6
452
+
453
+ peer_data = {
454
+ "up": neighbor.get("session-state") == "established",
455
+ "local_as": as_number(local_as) if local_as else -1,
456
+ "remote_as": as_number(peer_as) if peer_as else -1,
457
+ "router_id": router_id,
458
+ "local_address": local_address,
459
+ "routing_table": neighbor.get("peer-group", ""),
460
+ "local_address_configured": not local_address,
461
+ "local_port": convert(int, transport.get("local-port"), default=-1),
462
+ "remote_address": peer_ip,
463
+ "remote_port": convert(int, transport.get("remote-port"), default=-1),
464
+ "multihop": False, # not supported in SR Linux
465
+ "multipath": False, # not supported in SR Linux
466
+ "remove_private_as": False, # not supported in SR Linux
467
+ "import_policy": str(neighbor.get("import-policy", "")),
468
+ "export_policy": str(neighbor.get("export-policy", "")),
469
+ "input_messages": convert(int, received.get("total-messages"), default=-1),
470
+ "output_messages": convert(int, sent.get("total-messages"), default=-1),
471
+ "input_updates": convert(int, received.get("total-updates"), default=-1),
472
+ "output_updates": convert(int, sent.get("total-updates"), default=-1),
473
+ "messages_queued_out": convert(int, sent.get("queue-depth"), default=-1),
474
+ "connection_state": neighbor.get("session-state", ""),
475
+ "previous_connection_state": neighbor.get("last-state", ""),
476
+ "last_event": neighbor.get("last-event", ""),
477
+ "suppress_4byte_as": False, # not supported in SR Linux
478
+ "local_as_prepend": convert(
479
+ bool,
480
+ helpers.value_at(neighbor, "local-as", "prepend-local-as"),
481
+ default=False,
482
+ ),
483
+ "holdtime": convert(int, timers.get("hold-time"), default=-1),
484
+ "configured_holdtime": convert(
485
+ int, timers.get("negotiated-hold-time"), default=-1
486
+ ),
487
+ "keepalive": convert(int, timers.get("keepalive-interval"), default=-1),
488
+ "configured_keepalive": convert(
489
+ int, timers.get("negotiated-keepalive-interval"), default=-1
490
+ ),
491
+ "active_prefix_count": convert(int, afi.get("active-routes"), default=-1),
492
+ "received_prefix_count": convert(
493
+ int, afi.get("received-routes"), default=-1
494
+ ),
495
+ "accepted_prefix_count": convert(int, afi.get("active-routes"), default=-1),
496
+ "suppressed_prefix_count": convert(
497
+ int, afi.get("rejected-routes"), default=-1
498
+ ),
499
+ "advertised_prefix_count": convert(int, afi.get("sent-routes"), default=-1),
500
+ "flap_count": -1, # not supported in SR Linux
501
+ }
502
+
503
+ remote_as = peer_data["remote_as"]
504
+ details[instance_name].setdefault(remote_as, []).append(peer_data)
505
+ return details
506
+
507
+ def get_bgp_config(
508
+ self, group: str = "", neighbor: str = ""
509
+ ) -> dict[str, models.BGPConfigGroupDict]:
510
+ """
511
+ Returns a dictionary containing the BGP configuration. Can return either
512
+ the whole config, either the config only for a group or neighbor.
513
+ """
514
+
515
+ def prefix_limit(afi: dict) -> dict:
516
+ return {
517
+ "limit": helpers.value_at(
518
+ afi, "prefix-limit", "max-received-routes", default=-1
519
+ ),
520
+ "teardown": {
521
+ "threshold": helpers.value_at(
522
+ afi, "prefix-limit", "warning-threshold-pct", default=-1
523
+ ),
524
+ "timeout": -1,
525
+ },
526
+ }
527
+
528
+ (bgp_data,) = self.device.get_paths(
529
+ ["/network-instance[name=*]/protocols/bgp"], Datastore.STATE
530
+ )
531
+
532
+ groups_data: dict = {}
533
+ for instance in helpers.value_at(bgp_data, "network-instance", default=[]):
534
+ bgp = helpers.value_at(instance, "protocols", "bgp")
535
+ if not bgp:
536
+ continue
537
+
538
+ neighbors = bgp.get("neighbor", [])
539
+ multipath = bool(
540
+ helpers.value_at(bgp, "ipv4-unicast", "multipath", "allow-multiple-as")
541
+ )
542
+
543
+ for grp in bgp.get("group", []):
544
+ group_name = grp.get("group-name", "")
545
+ group_local_as = helpers.value_at(grp, "local-as", "as-number", default=-1)
546
+ group_remote_as = grp.get("peer-as", -1)
547
+
548
+ neighbors_data = {}
549
+ for nbr in (n for n in neighbors if n.get("peer-group") == group_name):
550
+ nbr_ipv4 = helpers.value_at(nbr, "ipv4-unicast", default={})
551
+ nbr_ipv6 = helpers.value_at(nbr, "ipv6-unicast", default={})
552
+ neighbors_data[nbr.get("peer-address", "")] = {
553
+ "description": nbr.get("description", ""),
554
+ "import_policy": str(nbr.get("import-policy", "")),
555
+ "export_policy": str(nbr.get("export-policy", "")),
556
+ "local_address": helpers.value_at(
557
+ nbr, "transport", "local-address", default=""
558
+ ),
559
+ "local_as": as_number(
560
+ helpers.value_at(nbr, "local-as", "as-number", default=-1)
561
+ ),
562
+ "remote_as": as_number(nbr.get("peer-as", -1)),
563
+ "authentication_key": "",
564
+ "prefix_limit": {
565
+ "inet": {"unicast": prefix_limit(nbr_ipv4)},
566
+ "inet6": {"unicast": prefix_limit(nbr_ipv6)},
567
+ },
568
+ "route_reflector_client": bool(
569
+ helpers.value_at(nbr, "route-reflector", "client", default=False)
570
+ ),
571
+ "nhs": bool(nbr.get("next-hop-self", False)),
572
+ }
573
+
574
+ group_data = {
575
+ group_name: {
576
+ "type": "internal" if group_local_as == group_remote_as else "external",
577
+ "description": grp.get("description", ""),
578
+ "apply_groups": [], # not supported
579
+ "multihop_ttl": -1, # not supported
580
+ "multipath": multipath,
581
+ "local_address": helpers.value_at(
582
+ grp, "transport", "local-address", default=""
583
+ ),
584
+ "local_as": as_number(group_local_as),
585
+ "remote_as": as_number(group_remote_as),
586
+ "import_policy": str(grp.get("import-policy", "")),
587
+ "export_policy": str(grp.get("export-policy", "")),
588
+ "remove_private_as": False, # not supported
589
+ "prefix_limit": {
590
+ "inet": {
591
+ "unicast": prefix_limit(
592
+ helpers.value_at(grp, "ipv4-unicast", default={})
593
+ )
594
+ },
595
+ "inet6": {
596
+ "unicast": prefix_limit(
597
+ helpers.value_at(grp, "ipv6-unicast", default={})
598
+ )
599
+ },
600
+ },
601
+ "neighbors": neighbors_data,
602
+ }
603
+ }
604
+
605
+ if group and group == group_name:
606
+ return group_data
607
+ if neighbor and neighbor in neighbors_data:
608
+ group_data[group_name]["neighbors"] = {neighbor: neighbors_data[neighbor]}
609
+ return group_data
610
+ groups_data.update(group_data)
611
+
612
+ return {} if group or neighbor else groups_data
613
+
614
+ def get_environment(self) -> models.EnvironmentDict:
615
+ """
616
+ Returns a dictionary with fans, temperature, power, cpu and memory data.
617
+ """
618
+ (platform,) = self.device.get_paths(["/platform"], Datastore.STATE)
619
+
620
+ environment: dict = {
621
+ "fans": {},
622
+ "power": {},
623
+ "temperature": {},
624
+ "memory": {"available_ram": -1, "used_ram": -1},
625
+ "cpu": {},
626
+ }
627
+
628
+ for control in helpers.value_at(platform, "control", default=[]):
629
+ slot = str(control.get("slot", ""))
630
+ if not slot:
631
+ continue
632
+
633
+ temperature = control.get("temperature")
634
+ if temperature:
635
+ environment["temperature"][slot] = {
636
+ "temperature": convert(float, temperature.get("instant"), default=-1.0),
637
+ "is_alert": convert(bool, temperature.get("alarm-status"), default=False),
638
+ "is_critical": False, # not supported in SR Linux
639
+ }
640
+
641
+ memory = helpers.value_at(control, "memory", default={})
642
+ if memory:
643
+ physical = convert(int, memory.get("physical"), default=-1)
644
+ free = convert(int, memory.get("free"), default=-1)
645
+ environment["memory"] = {
646
+ "available_ram": physical,
647
+ "used_ram": physical - free if physical > -1 and free > -1 else -1,
648
+ }
649
+
650
+ for cpu in helpers.value_at(control, "cpu", default=[]):
651
+ environment["cpu"][cpu.get("index")] = {
652
+ "%usage": convert(
653
+ float, helpers.value_at(cpu, "total", "instant"), default=-1.0
654
+ )
655
+ }
656
+
657
+ for power_supply in helpers.value_at(platform, "power-supply", default=[]):
658
+ environment["power"][str(power_supply.get("id", ""))] = {
659
+ "status": power_supply.get("oper-state") == "up",
660
+ "capacity": convert(float, power_supply.get("capacity"), default=-1.0),
661
+ "output": -1.0, # not supported in SR Linux
662
+ }
663
+
664
+ for fan_tray in helpers.value_at(platform, "fan-tray", default=[]):
665
+ environment["fans"][str(fan_tray.get("id", ""))] = {
666
+ "status": fan_tray.get("oper-state") == "up"
667
+ }
668
+
669
+ return environment
670
+
671
+ def get_lldp_neighbors(self) -> dict[str, list[models.LLDPNeighborDict]]:
672
+ """
673
+ Returns a dictionary where the keys are local ports and the value is a list
674
+ of dictionaries with hostname and port of the neighbor.
675
+ """
676
+ (lldp_data,) = self.device.get_paths(["/system/lldp"], Datastore.STATE)
677
+
678
+ lldp_neighbors = {}
679
+ for interface in helpers.value_at(lldp_data, "interface", default=[]):
680
+ neighbors = [
681
+ {
682
+ "hostname": neighbor.get("system-name", ""),
683
+ "port": neighbor.get("port-id", ""),
684
+ }
685
+ for neighbor in interface.get("neighbor", [])
686
+ ]
687
+ if neighbors:
688
+ lldp_neighbors[interface.get("name")] = neighbors
689
+ return lldp_neighbors
690
+
691
+ def get_lldp_neighbors_detail(self, interface: str = "") -> models.LLDPNeighborsDetailDict:
692
+ """
693
+ Returns a detailed view of the LLDP neighbors as a dictionary containing
694
+ lists of dictionaries for each interface.
695
+ """
696
+ (lldp_data,) = self.device.get_paths(["/system/lldp"], Datastore.STATE)
697
+
698
+ lldp_neighbors = {}
699
+ for lldp_interface in helpers.value_at(lldp_data, "interface", default=[]):
700
+ interface_name = lldp_interface.get("name")
701
+ if interface and interface_name != interface:
702
+ continue
703
+
704
+ neighbors = []
705
+ for neighbor in lldp_interface.get("neighbor", []):
706
+ capabilities = []
707
+ enabled_capabilities = []
708
+ for capability in neighbor.get("capability", []):
709
+ name = helpers.strip_module_prefix(capability.get("name", "")).lower()
710
+ capabilities.append(name)
711
+ if capability.get("enabled") is True:
712
+ enabled_capabilities.append(name)
713
+
714
+ neighbors.append(
715
+ {
716
+ "parent_interface": interface_name,
717
+ "remote_port": neighbor.get("port-id", ""),
718
+ "remote_port_description": neighbor.get("port-description", ""),
719
+ "remote_chassis_id": neighbor.get("chassis-id", ""),
720
+ "remote_system_name": neighbor.get("system-name", ""),
721
+ "remote_system_description": neighbor.get("system-description", ""),
722
+ "remote_system_capab": capabilities,
723
+ "remote_system_enable_capab": enabled_capabilities,
724
+ }
725
+ )
726
+ if neighbors:
727
+ lldp_neighbors[interface_name] = neighbors
728
+ return lldp_neighbors
729
+
730
+ def get_network_instances(self, name: str = "") -> dict[str, models.NetworkInstanceDict]:
731
+ """
732
+ Return a dictionary of network instances (VRFs) configured, including
733
+ default/global.
734
+ """
735
+ (ni_data,) = self.device.get_paths(
736
+ [f"/network-instance[name={name or '*'}]"], Datastore.STATE
737
+ )
738
+
739
+ network_instances = {}
740
+ for instance in self._network_instance_list(ni_data, name):
741
+ instance_name = instance.get("name", "")
742
+ network_instances[instance_name] = {
743
+ "name": instance_name,
744
+ "type": helpers.strip_module_prefix(instance.get("type", "")),
745
+ "state": {
746
+ "route_distinguisher": "", # not supported in SR Linux
747
+ },
748
+ "interfaces": {
749
+ "interface": {
750
+ member.get("name", ""): {}
751
+ for member in instance.get("interface", [])
752
+ }
753
+ },
754
+ }
755
+ return network_instances
756
+
757
+ @staticmethod
758
+ def _network_instance_list(ni_data, requested_name: str = "") -> list[dict]:
759
+ """Normalize a network-instance query result to a list of instances.
760
+
761
+ A wildcard path (`[name=*]`) returns `{"...:network-instance": [...]}`,
762
+ while an exact-name path returns the contents of that single instance
763
+ directly (without its `name` key).
764
+ """
765
+ wrapped = helpers.value_at(ni_data, "network-instance")
766
+ if wrapped is not None:
767
+ return wrapped
768
+ if isinstance(ni_data, dict) and ni_data:
769
+ instance = dict(ni_data)
770
+ instance.setdefault("name", requested_name)
771
+ return [instance]
772
+ return []
773
+
774
+ def get_vlans(self) -> dict[int, models.VlanDict]:
775
+ """
776
+ Returns a dictionary of VLANs keyed by VLAN ID.
777
+
778
+ SR Linux has no global VLAN table; VLANs exist as single-tagged
779
+ encapsulation on bridged subinterfaces attached to mac-vrf network
780
+ instances. The VLAN name is the name of the (first) mac-vrf carrying
781
+ that VLAN ID and the interfaces are the member subinterfaces. Untagged
782
+ bridged subinterfaces and 'vlan-id any' have no VLAN identity and are
783
+ not reported.
784
+ """
785
+ ni_data, interfaces_data = self.device.get_paths(
786
+ ["/network-instance[name=*]", "/interface[name=*]/subinterface"],
787
+ Datastore.STATE,
788
+ )
789
+
790
+ # "<interface>.<subinterface-index>" -> [vlan ids]
791
+ vlan_map: dict[str, list[int]] = {}
792
+ for interface in helpers.value_at(interfaces_data, "interface", default=[]):
793
+ for subinterface in interface.get("subinterface", []):
794
+ encap = helpers.value_at(subinterface, "vlan", "encap", default={})
795
+ vlan_ids = []
796
+ single = helpers.value_at(encap, "single-tagged", "vlan-id")
797
+ if single is not None:
798
+ vlan_id = convert(int, single, default=None) # None for "any"
799
+ if vlan_id is not None:
800
+ vlan_ids.append(vlan_id)
801
+ ranges = helpers.value_at(encap, "single-tagged-range", "low-vlan-id", default=[])
802
+ for entry in ranges:
803
+ low = convert(int, entry.get("range-low-vlan-id"), default=None)
804
+ high = convert(int, entry.get("high-vlan-id"), default=low)
805
+ if low is None or high is None or not 0 < high - low + 1 <= 4094:
806
+ continue
807
+ vlan_ids.extend(range(low, high + 1))
808
+ if vlan_ids:
809
+ vlan_map[subinterface.get("name", "")] = vlan_ids
810
+
811
+ vlans: dict[int, models.VlanDict] = {}
812
+ for instance in self._network_instance_list(ni_data):
813
+ if helpers.strip_module_prefix(instance.get("type", "")) != "mac-vrf":
814
+ continue
815
+ for member in instance.get("interface", []):
816
+ subif_name = member.get("name", "")
817
+ for vlan_id in vlan_map.get(subif_name, []):
818
+ vlan = vlans.setdefault(
819
+ vlan_id, {"name": instance.get("name", ""), "interfaces": []}
820
+ )
821
+ if subif_name not in vlan["interfaces"]:
822
+ vlan["interfaces"].append(subif_name)
823
+ return vlans
824
+
825
+ def get_users(self) -> dict[str, models.UsersDict]:
826
+ """
827
+ Returns a dictionary with the configured users.
828
+ """
829
+ admin_user, users_data = self.device.get_paths(
830
+ [
831
+ "/system/aaa/authentication/admin-user",
832
+ "/system/aaa/authentication/user[username=*]",
833
+ ],
834
+ Datastore.STATE,
835
+ )
836
+
837
+ users_dict = {
838
+ "admin": {
839
+ "level": 15, # built-in admin user has full access
840
+ "password": admin_user.get("password", ""),
841
+ "sshkeys": list(admin_user.get("ssh-key", [])),
842
+ }
843
+ }
844
+
845
+ for user in helpers.value_at(users_data, "user", default=[]):
846
+ roles = user.get("role", [])
847
+ users_dict[user.get("username")] = {
848
+ "level": 15 if any("admin" in str(role) for role in roles) else 0,
849
+ "password": user.get("password", ""),
850
+ "sshkeys": list(user.get("ssh-key", [])),
851
+ }
852
+ return users_dict
853
+
854
+ def get_snmp_information(self) -> models.SNMPDict:
855
+ """
856
+ Returns a dict containing SNMP configuration.
857
+ """
858
+ (information,) = self.device.get_paths(["/system/information"], Datastore.STATE)
859
+
860
+ return {
861
+ "chassis_id": "", # not exposed via the SNMP config
862
+ "community": {}, # SR Linux configures SNMP access via access-groups
863
+ "contact": information.get("contact", ""),
864
+ "location": information.get("location", ""),
865
+ }
866
+
867
+ def get_config(
868
+ self,
869
+ retrieve: str = "all",
870
+ full: bool = False,
871
+ sanitized: bool = False,
872
+ format: str = "text",
873
+ ) -> models.ConfigDict:
874
+ """
875
+ Return the running configuration of the device.
876
+
877
+ The candidate configuration only exists client-side in this driver (see
878
+ load_merge_candidate) and the startup config is not retrievable via
879
+ JSON-RPC, so both are always returned as empty strings.
880
+ """
881
+ config = {"running": "", "candidate": "", "startup": ""}
882
+
883
+ if retrieve not in ("all", "running"):
884
+ return config
885
+
886
+ if format == "cli" or self.running_format == "cli":
887
+ if sanitized:
888
+ raise NotImplementedError("sanitized=True is not implemented with CLI format")
889
+ result = self.device.run_cli_commands(["info flat"])
890
+ config["running"] = (
891
+ result[0].get("text", "") if isinstance(result[0], dict) else str(result[0])
892
+ )
893
+ return config
894
+
895
+ (running,) = self.device.get_paths(["/"], Datastore.RUNNING)
896
+ if sanitized:
897
+ system = helpers.value_at(running, "system", default={})
898
+ for key in list(system):
899
+ if helpers.strip_module_prefix(key) in ("aaa", "tls"):
900
+ del system[key]
901
+ config["running"] = json.dumps(running)
902
+ return config
903
+
904
+ def get_ntp_servers(self) -> dict[str, models.NTPServerDict]:
905
+ """
906
+ Returns the NTP servers configuration as dictionary, keyed by server address.
907
+ """
908
+ (ntp,) = self.device.get_paths(["/system/ntp"], Datastore.STATE)
909
+
910
+ return {
911
+ server["address"]: {}
912
+ for server in (ntp or {}).get("server", [])
913
+ if "address" in server
914
+ }
915
+
916
+ def get_ntp_stats(self) -> list[models.NTPStats]:
917
+ """
918
+ Returns a list of NTP synchronization statistics for preferred servers.
919
+ """
920
+ (ntp,) = self.device.get_paths(["/system/ntp"], Datastore.STATE)
921
+ ntp = ntp or {}
922
+
923
+ synchronized = str(ntp.get("synchronized", "")).lower() in (
924
+ "synchronized",
925
+ "synchronised",
926
+ )
927
+
928
+ stats = []
929
+ for server in ntp.get("server", []):
930
+ if not server.get("prefer"):
931
+ continue
932
+ stats.append(
933
+ {
934
+ "remote": server.get("address", ""),
935
+ "referenceid": "",
936
+ "synchronized": synchronized,
937
+ "stratum": convert(int, server.get("stratum"), default=-1),
938
+ "type": "",
939
+ "when": "",
940
+ "hostpoll": convert(int, server.get("poll-interval"), default=-1),
941
+ "reachability": -1,
942
+ "delay": -1.0,
943
+ "offset": convert(float, server.get("offset"), default=-1.0),
944
+ "jitter": convert(float, server.get("jitter"), default=-1.0),
945
+ }
946
+ )
947
+ return stats
948
+
949
+ def get_optics(self) -> dict[str, models.OpticsDict]:
950
+ """
951
+ Fetches the power usage on the various transceivers installed on the
952
+ device (in dBm).
953
+ """
954
+ (interfaces_data,) = self.device.get_paths(["/interface[name=*]"], Datastore.STATE)
955
+
956
+ def channel_state(channel: dict, leaf: str) -> dict:
957
+ return {
958
+ "instant": convert(
959
+ float, helpers.value_at(channel, leaf, "latest-value"), default=-1.0
960
+ ),
961
+ "avg": -1.0,
962
+ "min": -1.0,
963
+ "max": -1.0,
964
+ }
965
+
966
+ optics = {}
967
+ for interface in helpers.value_at(interfaces_data, "interface", default=[]):
968
+ channels = helpers.value_at(interface, "transceiver", "channel", default=[])
969
+ if not channels:
970
+ continue
971
+ optics[interface["name"]] = {
972
+ "physical_channels": {
973
+ "channel": [
974
+ {
975
+ "index": convert(int, channel.get("index"), default=-1),
976
+ "state": {
977
+ "input_power": channel_state(channel, "input-power"),
978
+ "output_power": channel_state(channel, "output-power"),
979
+ "laser_bias_current": channel_state(
980
+ channel, "laser-bias-current"
981
+ ),
982
+ },
983
+ }
984
+ for channel in channels
985
+ ]
986
+ }
987
+ }
988
+ return optics
989
+
990
+ def get_mac_address_table(self) -> list[models.MACAdressTable]:
991
+ """
992
+ Returns a list of dictionaries, each representing an entry in the MAC
993
+ address table of bridged network instances.
994
+ """
995
+ mac_data, interfaces_data = self.device.get_paths(
996
+ [
997
+ "/network-instance[name=*]/bridge-table/mac-table/mac",
998
+ "/interface[name=*]/subinterface",
999
+ ],
1000
+ Datastore.STATE,
1001
+ )
1002
+
1003
+ # "<interface>.<subinterface-index>" -> vlan-id
1004
+ vlan_map = {}
1005
+ for interface in helpers.value_at(interfaces_data, "interface", default=[]):
1006
+ for subinterface in interface.get("subinterface", []):
1007
+ vlan_id = helpers.value_at(
1008
+ subinterface, "vlan", "encap", "single-tagged", "vlan-id"
1009
+ )
1010
+ if vlan_id is not None:
1011
+ vlan_map[subinterface["name"]] = convert(int, vlan_id, default=-1)
1012
+
1013
+ mac_table = []
1014
+ for instance in helpers.value_at(mac_data, "network-instance", default=[]):
1015
+ macs = helpers.value_at(instance, "bridge-table", "mac-table", "mac", default=[])
1016
+ for mac in macs:
1017
+ destination = str(mac.get("destination", ""))
1018
+ mac_type = mac.get("type", "")
1019
+ mac_table.append(
1020
+ {
1021
+ "mac": mac.get("address", ""),
1022
+ "interface": destination,
1023
+ "vlan": vlan_map.get(destination, -1),
1024
+ "active": True,
1025
+ "static": bool(mac_type) and mac_type != "learnt",
1026
+ "moves": -1,
1027
+ "last_move": -1.0,
1028
+ }
1029
+ )
1030
+ return mac_table
1031
+
1032
+ def get_route_to(
1033
+ self, destination: str = "", protocol: str = "", longer: bool = False
1034
+ ) -> dict[str, models.RouteDict]:
1035
+ """
1036
+ Returns a dictionary of dictionaries containing details of all available
1037
+ routes to a destination.
1038
+ """
1039
+ if longer:
1040
+ raise NotImplementedError("'longer' option is not supported")
1041
+
1042
+ route_tables, information = self.device.get_paths(
1043
+ ["/network-instance[name=*]/route-table", "/system/information"],
1044
+ Datastore.STATE,
1045
+ )
1046
+
1047
+ current_time = information.get("current-datetime")
1048
+ instances = helpers.value_at(route_tables, "network-instance", default=[])
1049
+
1050
+ def route_protocol(route: dict) -> str:
1051
+ # newer releases use route-type, older ones owner (e.g. "srl_nokia-common:bgp")
1052
+ return str(route.get("route-type") or route.get("owner") or "")
1053
+
1054
+ # only fetch protocol details (BGP RIB, ISIS) when needed
1055
+ protocol_details: dict[str, dict] = {}
1056
+ needs_details = any(
1057
+ "bgp" in route_protocol(route) or "isis" in route_protocol(route)
1058
+ for instance in instances
1059
+ for route in helpers.value_at(
1060
+ instance, "route-table", "ipv4-unicast", "route", default=[]
1061
+ )
1062
+ )
1063
+ if needs_details:
1064
+ protocols_data, rib_data = self.device.get_paths(
1065
+ [
1066
+ "/network-instance[name=*]/protocols",
1067
+ "/network-instance[name=*]/bgp-rib",
1068
+ ],
1069
+ Datastore.STATE,
1070
+ )
1071
+ for instance in helpers.value_at(protocols_data, "network-instance", default=[]):
1072
+ protocol_details.setdefault(instance.get("name"), {})["protocols"] = (
1073
+ instance.get("protocols", {})
1074
+ )
1075
+ for instance in helpers.value_at(rib_data, "network-instance", default=[]):
1076
+ protocol_details.setdefault(instance.get("name"), {})["bgp-rib"] = (
1077
+ helpers.value_at(instance, "bgp-rib", default={})
1078
+ )
1079
+
1080
+ route_data: dict = {}
1081
+ for instance in instances:
1082
+ instance_name = instance.get("name")
1083
+ route_table = helpers.value_at(instance, "route-table", default={})
1084
+ routes = helpers.value_at(route_table, "ipv4-unicast", "route", default=[])
1085
+ next_hop_groups = helpers.value_at(route_table, "next-hop-group", default=[])
1086
+ next_hops = helpers.value_at(route_table, "next-hop", default=[])
1087
+
1088
+ for route in routes:
1089
+ if "next-hop-group" not in route:
1090
+ continue
1091
+
1092
+ prefix = route.get("ipv4-prefix", "")
1093
+ owner = route_protocol(route)
1094
+
1095
+ age = -1
1096
+ if current_time and route.get("last-app-update"):
1097
+ age = int(
1098
+ helpers.seconds_between(current_time, route["last-app-update"])
1099
+ )
1100
+
1101
+ group = next(
1102
+ (g for g in next_hop_groups if g.get("index") == route["next-hop-group"]),
1103
+ {},
1104
+ )
1105
+ next_hop_ids = [n.get("next-hop") for n in group.get("next-hop", [])]
1106
+ route_next_hops = [n for n in next_hops if n.get("index") in next_hop_ids]
1107
+
1108
+ entries = []
1109
+ for next_hop in route_next_hops:
1110
+ ip_address = next_hop.get("ip-address", "")
1111
+ entry = {
1112
+ "protocol": helpers.strip_module_prefix(owner),
1113
+ "current_active": route.get("active", False),
1114
+ "last_active": False,
1115
+ "age": age,
1116
+ "next_hop": ip_address,
1117
+ "outgoing_interface": next_hop.get("subinterface", ""),
1118
+ "selected_next_hop": bool(ip_address),
1119
+ "preference": convert(int, route.get("preference"), default=-1),
1120
+ "inactive_reason": "",
1121
+ "routing_table": instance_name,
1122
+ }
1123
+
1124
+ details = protocol_details.get(instance_name, {})
1125
+ if "bgp" in owner:
1126
+ attributes = self._bgp_route_attributes(
1127
+ details, prefix, ip_address
1128
+ )
1129
+ if attributes:
1130
+ attributes["metric"] = convert(
1131
+ int, route.get("metric"), default=-1
1132
+ )
1133
+ entry["protocol_attributes"] = attributes
1134
+ elif "isis" in owner:
1135
+ level = helpers.value_at(
1136
+ details,
1137
+ "protocols",
1138
+ "isis",
1139
+ "instance",
1140
+ 0,
1141
+ "level",
1142
+ 0,
1143
+ "level-number",
1144
+ default=-1,
1145
+ )
1146
+ entry["protocol_attributes"] = {"level": level}
1147
+
1148
+ entries.append(entry)
1149
+
1150
+ if destination and destination in (prefix, prefix.split("/")[0]):
1151
+ return {prefix: entries}
1152
+ route_data[prefix] = entries
1153
+
1154
+ if protocol:
1155
+ return {
1156
+ prefix: matching
1157
+ for prefix, hops in route_data.items()
1158
+ if (matching := [h for h in hops if h["protocol"] == protocol])
1159
+ }
1160
+ if destination:
1161
+ # a matching destination would have returned from the loop already
1162
+ return {}
1163
+ return route_data
1164
+
1165
+ @staticmethod
1166
+ def _bgp_route_attributes(details: dict, prefix: str, next_hop_ip: str) -> dict:
1167
+ """Extract BGP protocol attributes for a route from the local RIB."""
1168
+ bgp = helpers.value_at(details, "protocols", "bgp", default={})
1169
+ rib = details.get("bgp-rib", {})
1170
+
1171
+ neighbor = next(
1172
+ (n for n in bgp.get("neighbor", []) if n.get("peer-address") == next_hop_ip),
1173
+ None,
1174
+ )
1175
+
1176
+ # newer releases nest the per-AFI RIBs in an afi-safi list, older ones
1177
+ # have ipv4-unicast at the top level of bgp-rib
1178
+ ipv4_rib = next(
1179
+ (
1180
+ helpers.value_at(afi, "ipv4-unicast", default={})
1181
+ for afi in rib.get("afi-safi", [])
1182
+ if helpers.strip_module_prefix(afi.get("afi-safi-name", "")) == "ipv4-unicast"
1183
+ ),
1184
+ helpers.value_at(rib, "ipv4-unicast", default={}),
1185
+ )
1186
+ rib_routes = helpers.value_at(ipv4_rib, "local-rib", "route", default=None)
1187
+ if rib_routes is None:
1188
+ rib_routes = helpers.value_at(ipv4_rib, "local-rib", "routes", default=[])
1189
+ rib_route = next(
1190
+ (
1191
+ r
1192
+ for r in rib_routes
1193
+ if r.get("prefix") == prefix
1194
+ and r.get("neighbor") == next_hop_ip
1195
+ and helpers.strip_module_prefix(str(r.get("origin-protocol", ""))) == "bgp"
1196
+ ),
1197
+ None,
1198
+ )
1199
+ if not neighbor or not rib_route:
1200
+ return {}
1201
+
1202
+ attr_sets = helpers.value_at(rib, "attr-sets", "attr-set", default=[])
1203
+ attr_set = next(
1204
+ (a for a in attr_sets if a.get("index") == rib_route.get("attr-id")), {}
1205
+ )
1206
+
1207
+ return {
1208
+ "local_as": bgp.get("autonomous-system", -1),
1209
+ "remote_as": neighbor.get("peer-as", -1),
1210
+ "peer_id": neighbor.get("peer-address", ""),
1211
+ "as_path": " ".join(
1212
+ str(member)
1213
+ for segment in helpers.value_at(attr_set, "as-path", "segment", default=[])
1214
+ for member in segment.get("member", [])
1215
+ ),
1216
+ "communities": helpers.value_at(attr_set, "communities", "community", default=[]),
1217
+ "local_preference": attr_set.get("local-pref", -1),
1218
+ "preference2": -1,
1219
+ "metric": -1,
1220
+ "metric2": -1,
1221
+ }
1222
+
1223
+ # ------------------------------------------------------------------ operations
1224
+
1225
+ def ping(
1226
+ self,
1227
+ destination: str,
1228
+ source: str = "",
1229
+ ttl: int = 255,
1230
+ timeout: int = 2,
1231
+ size: int = 100,
1232
+ count: int = 5,
1233
+ vrf: str = "",
1234
+ source_interface: str = "",
1235
+ ) -> models.PingResultDict:
1236
+ """
1237
+ Execute a ping against the provided destination from the device.
1238
+ """
1239
+ ping_source = source_interface or source
1240
+
1241
+ command_parts = [
1242
+ f"ping {destination}",
1243
+ f"-I {ping_source}" if ping_source else "",
1244
+ f"-t {ttl}" if ttl else "",
1245
+ f"-W {timeout}" if timeout else "",
1246
+ f"-s {size}" if size else "",
1247
+ f"-c {count}" if count else "",
1248
+ f"network-instance {vrf or 'default'}",
1249
+ ]
1250
+ command = " ".join(part for part in command_parts if part)
1251
+
1252
+ try:
1253
+ result = self.device.run_cli_commands([command])
1254
+ except (CommandErrorException, ConnectionException) as exc:
1255
+ return {"error": str(exc)}
1256
+
1257
+ text = result[0].get("text", "") if isinstance(result[0], dict) else str(result[0])
1258
+ return helpers.parse_ping_output(text)
1259
+
1260
+ def traceroute(
1261
+ self,
1262
+ destination: str,
1263
+ source: str = "",
1264
+ ttl: int = 255,
1265
+ timeout: int = 2,
1266
+ vrf: str = "",
1267
+ ) -> models.TracerouteResultDict:
1268
+ """
1269
+ Execute a traceroute against the provided destination from the device.
1270
+
1271
+ Note: SR Linux traceroute does not support the source and timeout options;
1272
+ they are ignored.
1273
+ """
1274
+ command_parts = [
1275
+ f"traceroute {destination}",
1276
+ f"-m {ttl}" if ttl else "",
1277
+ f"network-instance {vrf or 'default'}",
1278
+ ]
1279
+ command = " ".join(part for part in command_parts if part)
1280
+
1281
+ try:
1282
+ result = self.device.run_cli_commands([command])
1283
+ except (CommandErrorException, ConnectionException) as exc:
1284
+ return {"error": str(exc)}
1285
+
1286
+ text = result[0].get("text", "") if isinstance(result[0], dict) else str(result[0])
1287
+ return helpers.parse_traceroute_output(text)
1288
+
1289
+ def cli(self, commands: list[str], encoding: str = "text") -> dict[str, str | dict]:
1290
+ """
1291
+ Execute a list of CLI commands and return the output of each one,
1292
+ as text or as the structured JSON-RPC result ('json' encoding).
1293
+
1294
+ The JSON-RPC cli method aggregates the output of all commands of one
1295
+ request into a single result, so each command is sent as its own request
1296
+ to preserve the per-command mapping.
1297
+ """
1298
+ if encoding not in ("text", "json"):
1299
+ raise NotImplementedError(f"{encoding} is not a supported encoding")
1300
+
1301
+ output = {}
1302
+ for command in commands:
1303
+ results = self.device.run_cli_commands([command], output_format=encoding)
1304
+ result = results[0] if results else ""
1305
+ if encoding == "json":
1306
+ output[command] = result
1307
+ else:
1308
+ output[command] = result.get("text", "") if isinstance(result, dict) else result
1309
+ return output
1310
+
1311
+ # ------------------------------------------------------------------ config management
1312
+ #
1313
+ # The SR Linux JSON-RPC interface has no persistent candidate datastore across
1314
+ # requests: a "set" request against the candidate datastore is transactional
1315
+ # and commits on success. The NAPALM candidate workflow is therefore emulated
1316
+ # client-side: load_*_candidate() stores the intended changes in the driver,
1317
+ # compare_config() uses the JSON-RPC "diff" method, and commit_config()
1318
+ # applies everything in a single transactional request after creating a
1319
+ # checkpoint that rollback() can restore.
1320
+
1321
+ def load_replace_candidate(self, filename=None, config=None) -> None:
1322
+ """
1323
+ Accepts either a native JSON formatted config, a gNMI-style JSON config
1324
+ containing only 'replaces', or SR Linux CLI commands.
1325
+ """
1326
+ try:
1327
+ self._load_candidate(filename, config, is_replace=True)
1328
+ except ReplaceConfigException:
1329
+ raise
1330
+ except Exception as exc:
1331
+ raise ReplaceConfigException(
1332
+ f"Error during load_replace_candidate operation: {exc}"
1333
+ ) from exc
1334
+
1335
+ def load_merge_candidate(self, filename=None, config=None) -> None:
1336
+ """
1337
+ Accepts either a native JSON formatted config (interpreted as 'update /'),
1338
+ a gNMI-style JSON config containing any number of 'deletes', 'replaces'
1339
+ and 'updates', or SR Linux CLI commands.
1340
+ """
1341
+ try:
1342
+ self._load_candidate(filename, config, is_replace=False)
1343
+ except MergeConfigException:
1344
+ raise
1345
+ except Exception as exc:
1346
+ raise MergeConfigException(
1347
+ f"Error during load_merge_candidate operation: {exc}"
1348
+ ) from exc
1349
+
1350
+ def _load_candidate(self, filename, config, is_replace: bool) -> None:
1351
+ exception = ReplaceConfigException if is_replace else MergeConfigException
1352
+
1353
+ if self._candidate is not None:
1354
+ raise exception("A candidate config is already loaded; discard it first")
1355
+
1356
+ if filename:
1357
+ with open(filename) as f:
1358
+ config = f.read()
1359
+ if not config:
1360
+ raise exception("Either 'filename' or 'config' argument must be provided")
1361
+
1362
+ try:
1363
+ cfg = json.loads(config)
1364
+ except json.JSONDecodeError:
1365
+ # not JSON: treat as CLI commands
1366
+ lines = [line.strip() for line in config.splitlines() if line.strip()]
1367
+ self._candidate = {"mode": "cli", "replace": is_replace, "lines": lines}
1368
+ return
1369
+
1370
+ if isinstance(cfg, dict) and ("deletes" in cfg or "replaces" in cfg or "updates" in cfg):
1371
+ if is_replace and ("deletes" in cfg or "updates" in cfg):
1372
+ raise exception("'load_replace_candidate' cannot contain 'deletes' or 'updates'")
1373
+ commands = [
1374
+ {"action": RPCAction.DELETE.value, "path": entry["path"]}
1375
+ for entry in cfg.get("deletes", [])
1376
+ ]
1377
+ commands += [
1378
+ {
1379
+ "action": RPCAction.REPLACE.value,
1380
+ "path": entry["path"],
1381
+ "value": entry["value"],
1382
+ }
1383
+ for entry in cfg.get("replaces", [])
1384
+ ]
1385
+ commands += [
1386
+ {
1387
+ "action": RPCAction.UPDATE.value,
1388
+ "path": entry["path"],
1389
+ "value": entry["value"],
1390
+ }
1391
+ for entry in cfg.get("updates", [])
1392
+ ]
1393
+ else:
1394
+ action = RPCAction.REPLACE if is_replace else RPCAction.UPDATE
1395
+ commands = [{"action": action.value, "path": "/", "value": cfg}]
1396
+
1397
+ # validate on the device without applying
1398
+ try:
1399
+ self.device.validate_paths(commands)
1400
+ except CommandErrorException as exc:
1401
+ raise exception(f"Candidate config failed validation: {exc}") from exc
1402
+
1403
+ self._candidate = {"mode": "json", "replace": is_replace, "commands": commands}
1404
+
1405
+ def compare_config(self) -> str:
1406
+ """
1407
+ Returns a string showing the difference between the running configuration
1408
+ and the loaded candidate configuration.
1409
+ """
1410
+ if self._candidate is None:
1411
+ return ""
1412
+
1413
+ if self._candidate["mode"] == "json":
1414
+ results = self.device.diff_paths(self._candidate["commands"])
1415
+ return "\n".join(
1416
+ r.get("text", "") if isinstance(r, dict) else str(r)
1417
+ for r in results
1418
+ if r
1419
+ ).strip()
1420
+
1421
+ # CLI mode: load the commands into a throwaway named candidate on the
1422
+ # device, diff it against running and discard it again. The named
1423
+ # candidate persists across JSON-RPC requests, so it is reset before and
1424
+ # discarded after; the cli method aggregates each request's output into
1425
+ # one blob, which for the middle request is exactly the diff text.
1426
+ enter = f"enter candidate private name {self._candidate_name}-diff"
1427
+ self._discard_named_candidate(enter)
1428
+ try:
1429
+ commands = [enter, "/"]
1430
+ if self._candidate["replace"]:
1431
+ commands.append("delete /")
1432
+ commands += self._candidate["lines"]
1433
+ commands.append("diff")
1434
+ results = self.device.run_cli_commands(commands)
1435
+ finally:
1436
+ self._discard_named_candidate(enter)
1437
+
1438
+ diff_result = results[0] if results else ""
1439
+ return (
1440
+ diff_result.get("text", "") if isinstance(diff_result, dict) else str(diff_result)
1441
+ ).strip()
1442
+
1443
+ def _discard_named_candidate(self, enter_command: str) -> None:
1444
+ """Discard any (possibly stale) content of a named candidate."""
1445
+ try:
1446
+ self.device.run_cli_commands([enter_command, "discard now"])
1447
+ except CommandErrorException:
1448
+ pass
1449
+
1450
+ def commit_config(self, message: str = "", revert_in: int | None = None) -> None:
1451
+ """
1452
+ Commits the loaded candidate configuration.
1453
+
1454
+ A named checkpoint (NAPALM-<session>-<n>) is created before the change
1455
+ so that rollback() can restore the previous state.
1456
+
1457
+ When revert_in is given, the commit is confirmed: the device starts a
1458
+ revert timer of that many seconds and reverts the change automatically
1459
+ unless confirm_commit() is called in time. With commit_save mode the
1460
+ 'save startup' is deferred until the commit is confirmed, so startup
1461
+ never holds a config that may still auto-revert.
1462
+ """
1463
+ if revert_in is not None and (not isinstance(revert_in, int) or revert_in <= 0):
1464
+ raise CommitConfirmException("'revert_in' must be a positive number of seconds")
1465
+ if self._candidate is None:
1466
+ raise CommitError("No candidate config loaded; nothing to commit")
1467
+ if self.has_pending_commit():
1468
+ raise CommitError("Pending commit confirm already in process!")
1469
+
1470
+ # checkpoint the pre-change state as the rollback anchor
1471
+ self._checkpoint_id += 1
1472
+ checkpoint_name = f"{self._checkpoint_prefix}-{self._checkpoint_id}"
1473
+ checkpoint_cmd = f"/tools system configuration generate-checkpoint name {checkpoint_name}"
1474
+ if message:
1475
+ checkpoint_cmd += f' comment "{message}"'
1476
+
1477
+ try:
1478
+ self.device.run_cli_commands([checkpoint_cmd])
1479
+ self._last_checkpoint = checkpoint_name
1480
+
1481
+ if self._candidate["mode"] == "json":
1482
+ self.device.set_paths(
1483
+ self._candidate["commands"],
1484
+ Datastore.CANDIDATE,
1485
+ confirm_timeout=revert_in,
1486
+ )
1487
+ if revert_in is None and self.commit_mode == "save":
1488
+ self.device.run_cli_commands(["save startup"])
1489
+ else:
1490
+ # a named candidate: 'commit confirmed' keeps the candidate
1491
+ # session open on the device until accepted/rejected, and a
1492
+ # shared 'enter candidate private' session would be silently
1493
+ # reused (with its stale baseline) by later config operations
1494
+ enter = f"enter candidate private name {self._candidate_name}"
1495
+ self._discard_named_candidate(enter)
1496
+ commands = [enter, "/"]
1497
+ if self._candidate["replace"]:
1498
+ commands.append("delete /")
1499
+ commands += self._candidate["lines"]
1500
+ if revert_in is not None:
1501
+ # not combinable with save/comment; the message is already
1502
+ # recorded in the checkpoint comment above
1503
+ commands.append(f"commit confirmed timeout {revert_in}")
1504
+ else:
1505
+ commit = f"commit {self.commit_mode}"
1506
+ if message:
1507
+ commit += f' comment "{message}"'
1508
+ commands.append(commit)
1509
+ self.device.run_cli_commands(commands)
1510
+ if revert_in is not None:
1511
+ self._pending_cli_candidate = enter
1512
+ except CommandErrorException as exc:
1513
+ raise CommitError(f"Commit failed: {exc}") from exc
1514
+
1515
+ self._candidate = None
1516
+ self._pending_confirm = revert_in is not None
1517
+
1518
+ def has_pending_commit(self) -> bool:
1519
+ """
1520
+ Returns True when a confirmed commit is awaiting confirmation on the
1521
+ device (regardless of which session started it).
1522
+ """
1523
+ (data,) = self.device.get_paths(
1524
+ ["/system/configuration/commit[id=*]"], Datastore.STATE
1525
+ )
1526
+ commits = helpers.value_at(data, "commit", default=[])
1527
+ if isinstance(commits, dict):
1528
+ commits = [commits]
1529
+ return any(
1530
+ helpers.strip_module_prefix(str(entry.get("status", ""))) == "unconfirmed"
1531
+ for entry in commits
1532
+ if isinstance(entry, dict)
1533
+ )
1534
+
1535
+ def confirm_commit(self) -> None:
1536
+ """
1537
+ Confirms a pending confirmed commit, cancelling its revert timer. With
1538
+ commit_save mode the config is persisted to startup now (deferred from
1539
+ commit_config).
1540
+ """
1541
+ if not self.has_pending_commit():
1542
+ raise CommitError("No pending commit-confirm found")
1543
+ try:
1544
+ self.device.run_cli_commands(["/tools system configuration confirmed-accept"])
1545
+ if self.commit_mode == "save":
1546
+ self.device.run_cli_commands(["save startup"])
1547
+ except CommandErrorException as exc:
1548
+ raise CommitError(f"Confirm failed: {exc}") from exc
1549
+ self._close_pending_cli_candidate()
1550
+ self._pending_confirm = False
1551
+
1552
+ def _close_pending_cli_candidate(self) -> None:
1553
+ """Close the candidate session a CLI-mode confirmed commit left open."""
1554
+ if self._pending_cli_candidate:
1555
+ self._discard_named_candidate(self._pending_cli_candidate)
1556
+ self._pending_cli_candidate = None
1557
+
1558
+ def discard_config(self) -> None:
1559
+ """
1560
+ Discards the loaded candidate configuration. The candidate only exists
1561
+ client-side, so this never touches the device.
1562
+
1563
+ A pending confirmed commit is not affected; use rollback() to reject it
1564
+ or let its revert timer expire.
1565
+ """
1566
+ self._candidate = None
1567
+
1568
+ def rollback(self) -> None:
1569
+ """
1570
+ Reverts changes made by the most recent commit_config call.
1571
+
1572
+ A pending confirmed commit is rejected immediately (its revert timer is
1573
+ cancelled and the change reverted); otherwise the named checkpoint that
1574
+ commit_config created is loaded.
1575
+
1576
+ Caveat: checkpoints contain the entire system configuration tree and
1577
+ restore the system state to the point at which the checkpoint was created
1578
+ (i.e. the most recent commit_config call). Changes made to the config
1579
+ after that checkpoint was created will be reverted too.
1580
+ """
1581
+ if self.has_pending_commit():
1582
+ try:
1583
+ self.device.run_cli_commands(
1584
+ ["/tools system configuration confirmed-reject"]
1585
+ )
1586
+ except CommandErrorException as exc:
1587
+ raise CommitError(f"Rollback (confirmed-reject) failed: {exc}") from exc
1588
+ self._close_pending_cli_candidate()
1589
+ self._pending_confirm = False
1590
+ return
1591
+
1592
+ if not self._last_checkpoint:
1593
+ raise CommitError("No checkpoint recorded; nothing to roll back to")
1594
+
1595
+ enter = f"enter candidate private name {self._candidate_name}"
1596
+ self._discard_named_candidate(enter)
1597
+ try:
1598
+ self.device.run_cli_commands(
1599
+ [
1600
+ enter,
1601
+ f"load checkpoint name {self._last_checkpoint}",
1602
+ f"commit {self.commit_mode}",
1603
+ ]
1604
+ )
1605
+ except CommandErrorException as exc:
1606
+ raise CommitError(f"Rollback failed: {exc}") from exc