Skip to content

Commit 41e9c77

Browse files
committed
Begin pynxos deprecation work
1 parent 85879f2 commit 41e9c77

4 files changed

Lines changed: 202 additions & 88 deletions

File tree

changes/388.changed

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Migrated additional `NXOSDevice` methods from NX-API to Netmiko SSH: `running_config`, `set_timeout`, `install_os` (the `terminal dont-ask` step), `reboot`, `backup_running_config`, `checkpoint`, `rollback`, `redundancy_state`, `file_copy_remote_exists`, and `file_copy`. `file_copy()` now uses Netmiko's `file_transfer()` (SCP over the existing SSH session) instead of pynxos. `reboot()` now catches `netmiko.exceptions.ReadTimeout` instead of `requests.exceptions.ReadTimeout` when the device drops the SSH session on reload. `redundancy_state` now falls back to `"active"` on any `netmiko.exceptions.NetmikoBaseException`. `file_copy_remote_exists` is now implemented on top of `verify_file()` (local checksum compared with the remote file's checksum) and no longer calls into pynxos. `refresh()` now also invalidates the cached `redundancy_state`. The remaining NX-API call sites are tracked for follow-up migration.

docs/user/lib_overview.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,14 @@ It's main purpose is to simplify the execution of common tasks including:
2424

2525
`NXOSDevice` is migrating from the pynxos (NX-API) transport to Netmiko SSH. The migration is being delivered in phases:
2626

27-
- The `save()` method has been reimplemented on Netmiko SSH.
27+
- The following methods have been reimplemented on Netmiko SSH: `save()`, `running_config`, `set_timeout()`, `install_os()` (the `terminal dont-ask` step), `reboot()`, `backup_running_config()`, `checkpoint()`, `rollback()`, `redundancy_state`, `file_copy_remote_exists()`, and `file_copy()`.
28+
- `file_copy()` now uses Netmiko's `file_transfer()` (SCP over the existing SSH session) instead of pynxos.
29+
- `reboot()` now catches `netmiko.exceptions.ReadTimeout` (raised by Netmiko when the SSH session drops during reload) instead of `requests.exceptions.ReadTimeout`.
30+
- `redundancy_state` now falls back to `"active"` when the underlying SSH command raises any `netmiko.exceptions.NetmikoBaseException`.
31+
- `file_copy_remote_exists()` now compares the local file's checksum against the remote file's checksum via `verify_file()`; it no longer delegates to pynxos.
32+
- `refresh()` now also invalidates the cached `redundancy_state`.
2833
- The constructor still accepts the `transport`, `verify`, and `port` kwargs for backwards compatibility, but supplying any of them now emits a `DeprecationWarning`. These kwargs will be removed in a future release and will be ignored once the migration completes.
29-
- The remaining NX-API call sites (`show`, `config`, `facts`-derived properties, `boot_options`, `reboot`, etc.) are still wired through pynxos and will be migrated in follow-up releases.
34+
- The remaining NX-API call sites (`show`, `config`, `facts`-derived properties such as `hostname`/`uptime`/`os_version`, `boot_options`, `set_boot_options`, etc.) are still wired through pynxos and will be migrated in follow-up releases.
3035

3136
### Behavioral differences to expect once migration completes
3237

pyntc/devices/nxos_device.py

Lines changed: 69 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,15 @@
55
import time
66
import warnings
77

8-
from netmiko import ConnectHandler
9-
from requests.exceptions import ConnectTimeout, ReadTimeout
8+
from netmiko import ConnectHandler, file_transfer
9+
from netmiko.exceptions import NetmikoBaseException, ReadTimeout
10+
from requests.exceptions import ConnectTimeout
11+
from requests.exceptions import ReadTimeout as RequestsReadTimeout
1012

1113
from pyntc import log
1214
from pyntc.devices.base_device import BaseDevice, RollbackError, fix_docs
1315
from pyntc.devices.pynxos.device import Device as NXOSNative
1416
from pyntc.devices.pynxos.errors import CLIError
15-
from pyntc.devices.pynxos.features.file_copy import FileTransferError as NXOSFileTransferError
1617
from pyntc.errors import (
1718
CommandError,
1819
CommandListError,
@@ -104,6 +105,7 @@ def refresh(self):
104105
"""Refresh caches on device instance."""
105106
if hasattr(self.native, "_facts"):
106107
delattr(self.native, "_facts")
108+
self._redundancy_state = None
107109
super().refresh()
108110

109111
def backup_running_config(self, filename):
@@ -112,8 +114,11 @@ def backup_running_config(self, filename):
112114
Args:
113115
filename (str): Name of backup file.
114116
"""
115-
self.native.backup_running_config(filename)
116-
log.debug("Host %s: Running config backed up.", self.host)
117+
self.open()
118+
output = self.native_ssh.send_command("show running-config")
119+
with open(filename, "w", encoding="utf-8") as backup_file:
120+
backup_file.write(output)
121+
log.debug("Host %s: Running config backed up to %s.", self.host, filename)
117122

118123
@property
119124
def boot_options(self):
@@ -131,9 +136,17 @@ def checkpoint(self, filename):
131136
132137
Args:
133138
filename (str): The filename to save the checkpoint on the remote device.
139+
140+
Raises:
141+
CommandError: If the device rejects the checkpoint command.
134142
"""
143+
self.open()
144+
command = f"checkpoint file {filename}"
135145
log.debug("Host %s: checkpoint is %s.", self.host, filename)
136-
return self.native.checkpoint(filename)
146+
output = self.native_ssh.send_command(command)
147+
if re.search(r"%\s*Error|ERROR:", output):
148+
log.error("Host %s: Checkpoint failed for %s: %s", self.host, filename, output)
149+
raise CommandError(command, output.strip())
137150

138151
def close(self):
139152
"""Disconnect from device."""
@@ -283,57 +296,59 @@ def serial_number(self):
283296
return self._serial_number
284297

285298
def file_copy(self, src, dest=None, file_system="bootflash:"):
286-
"""Send a local file to the device.
299+
"""Send a local file to the device via SCP over the SSH session.
287300
288301
Args:
289302
src (str): Path to the local file to send.
290303
dest (str, optional): The destination file path. Defaults to basename of source path.
291-
file_system (str, optional): [The file system for the remote file. Defaults to "bootflash:".
304+
file_system (str, optional): The file system for the remote file. Defaults to "bootflash:".
292305
293306
Raises:
294307
FileTransferError: Error if transfer of file cannot be verified.
295308
"""
309+
dest = dest or os.path.basename(src)
310+
if self.file_copy_remote_exists(src, dest, file_system):
311+
return
312+
self._check_free_space(os.path.getsize(src), file_system=file_system)
313+
self.open()
314+
try:
315+
file_transfer(
316+
self.native_ssh,
317+
source_file=src,
318+
dest_file=dest,
319+
file_system=file_system,
320+
direction="put",
321+
overwrite_file=True,
322+
)
323+
except Exception as err: # noqa: BLE001
324+
log.error("Host %s: SCP file transfer error %s", self.host, str(err))
325+
raise FileTransferError from err
326+
log.info("Host %s: File %s transferred successfully.", self.host, src)
296327
if not self.file_copy_remote_exists(src, dest, file_system):
297-
dest = dest or os.path.basename(src)
298-
self._check_free_space(os.path.getsize(src), file_system=file_system)
299-
try:
300-
file_copy = self.native.file_copy( # pylint: disable=assignment-from-no-return
301-
src, dest, file_system=file_system
302-
) # pylint: disable=assignment-from-no-return
303-
log.info("Host %s: File %s transferred successfully.", self.host, src)
304-
if not self.file_copy_remote_exists(src, dest, file_system):
305-
log.error(
306-
"Host %s: Attempted file copy, but could not validate file existed after transfer %s",
307-
self.host,
308-
FileTransferError.default_message,
309-
)
310-
raise FileTransferError
311-
return file_copy
312-
313-
except NXOSFileTransferError as err:
314-
log.error("Host %s: NXOS file transfer error %s", self.host, str(err))
315-
raise FileTransferError
328+
log.error(
329+
"Host %s: Attempted file copy, but could not validate file existed after transfer %s",
330+
self.host,
331+
FileTransferError.default_message,
332+
)
333+
raise FileTransferError
316334

317335
# TODO: Make this an internal method since exposing file_copy should be sufficient
318336
def file_copy_remote_exists(self, src, dest=None, file_system="bootflash:"):
319-
"""Check if a remote file exists.
337+
"""Check if a remote file exists and matches the local file's checksum.
320338
321339
Args:
322340
src (str): Path to the local file to send.
323341
dest (str, optional): The destination file path to be saved on remote device. Defaults to basename of source path.
324342
file_system (str, optional): The file system for the remote file. Defaults to "bootflash:".
325343
326344
Returns:
327-
(bool): True if the remote file exists. Otherwise, false.
345+
(bool): True if the remote file exists and its checksum matches the local file. Otherwise, false.
328346
"""
329347
dest = dest or os.path.basename(src)
330-
log.debug(
331-
"Host %s: File %s exists on remote %s.",
332-
self.host,
333-
src,
334-
self.native.file_copy_remote_exists(src, dest, file_system=file_system),
335-
)
336-
return self.native.file_copy_remote_exists(src, dest, file_system=file_system)
348+
local_checksum = self.get_local_checksum(src)
349+
result = self.verify_file(local_checksum, dest, file_system=file_system)
350+
log.debug("Host %s: File %s exists on remote and matches local: %s", self.host, dest, result)
351+
return result
337352

338353
def _get_file_system(self):
339354
"""Determine the default file system or directory for device.
@@ -647,7 +662,7 @@ def install_os(self, image_name, reboot=True, **vendor_specifics):
647662
Returns:
648663
(bool): True if new image is boot option on device. Otherwise, false.
649664
"""
650-
self.native.show("terminal dont-ask")
665+
self.native_ssh.send_command("terminal dont-ask")
651666
timeout = vendor_specifics.get("timeout", 3600)
652667
if not self._image_booted(image_name):
653668
log.info("Host %s: Setting Image %s in boot options.", self.host, image_name)
@@ -689,17 +704,17 @@ def redundancy_state(self):
689704
"""
690705
if self._redundancy_state is None:
691706
try:
692-
output = self.native.show("show redundancy state", raw_text=True)
693-
# Parse the redundancy state from output
707+
self.open()
708+
output = self.native_ssh.send_command("show redundancy state")
694709
# Example output: "Redundancy state = active"
695710
match = re.search(r"Redundancy\s+state\s*=\s*(\w+)", output, re.IGNORECASE)
696711
if match:
697712
self._redundancy_state = match.group(1).lower()
698713
else:
699714
# If no redundancy info, device may not support HA
700715
self._redundancy_state = "active"
701-
except CLIError:
702-
# If command fails, assume active (non-HA or error condition)
716+
except NetmikoBaseException:
717+
# If the SSH command fails, assume active (non-HA or error condition)
703718
self._redundancy_state = "active"
704719

705720
return self._redundancy_state
@@ -764,9 +779,8 @@ def reboot(self, wait_for_reload=False, **kwargs):
764779
log.warning("Passing 'confirm' to reboot method is deprecated.")
765780
raise DeprecationWarning("Passing 'confirm' to reboot method is deprecated.")
766781
try:
767-
self.native.show_list(["terminal dont-ask", "reload"])
768-
# The native reboot is not always properly disabling confirmation. Above is more consistent.
769-
# self.native.reboot(confirm=True)
782+
self.native_ssh.send_command("terminal dont-ask")
783+
self.native_ssh.send_command("reload")
770784
except ReadTimeout as expected_exception:
771785
log.info("Host %s: Device rebooted.", self.host)
772786
log.info("Hit expected exception during reload: %s", expected_exception.__class__)
@@ -784,12 +798,13 @@ def rollback(self, filename):
784798
Raises:
785799
RollbackError: Error if rollback command is unsuccesfull.
786800
"""
787-
try:
788-
self.native.rollback(filename)
789-
log.info("Host %s: Rollback to %s.", self.host, filename)
790-
except CLIError:
791-
log.error("Host %s: Rollback unsuccessful. %s may not exist.", self.host, filename)
801+
self.open()
802+
command = f"rollback running-config file {filename}"
803+
output = self.native_ssh.send_command(command)
804+
if re.search(r"%\s*Error|ERROR:|Rollback failed|does not exist", output, re.IGNORECASE):
805+
log.error("Host %s: Rollback unsuccessful. %s may not exist. Output: %s", self.host, filename, output)
792806
raise RollbackError(f"Rollback unsuccessful, {filename} may not exist.")
807+
log.info("Host %s: Rollback to %s.", self.host, filename)
793808

794809
@property
795810
def running_config(self):
@@ -799,7 +814,8 @@ def running_config(self):
799814
(str): Running configuration of device.
800815
"""
801816
log.debug("Host %s: Show running config.", self.host)
802-
return self.native.running_config
817+
self.open()
818+
return self.native_ssh.send_command("show running-config")
803819

804820
def save(self, filename="startup-config"):
805821
"""Save a device's running configuration.
@@ -849,7 +865,7 @@ def set_boot_options(self, image_name, kickstart=None, reboot=True, **vendor_spe
849865
image_name = file_system + image_name
850866
try:
851867
self.native.set_boot_options(image_name, kickstart=kickstart, reboot=reboot)
852-
except (ReadTimeout, ConnectTimeout):
868+
except (RequestsReadTimeout, ConnectTimeout):
853869
pass
854870
log.info("Host %s: boot options have been set to %s", self.host, image_name)
855871

@@ -860,7 +876,8 @@ def set_timeout(self, timeout):
860876
timeout (int): Timeout value.
861877
"""
862878
log.debug("Host %s: Timeout set to %s.", self.host, timeout)
863-
self.native.timeout = timeout
879+
self.timeout = timeout
880+
self.native_ssh.timeout = timeout
864881

865882
def show(self, command, raw_text=False):
866883
"""Send a non-configuration command.

0 commit comments

Comments
 (0)