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,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
|