Skip to content

Commit cacc60c

Browse files
committed
Update NXOS so that both nx-api and ssh can co-exist
1 parent b4a3f78 commit cacc60c

2 files changed

Lines changed: 155 additions & 5 deletions

File tree

pyntc/devices/nxos_device.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,17 @@ class NXOSDevice(BaseDevice):
3434
vendor = "cisco"
3535

3636
# pylint: disable=too-many-arguments, too-many-positional-arguments
37-
def __init__(self, host, username, password, transport="http", timeout=30, port=None, verify=True, **kwargs): # noqa: D403
37+
def __init__(
38+
self, host, username, password, transport="http", api_port=80, timeout=30, port=None, verify=True, **kwargs
39+
): # noqa: D403
3840
"""PyNTC Device implementation for Cisco IOS.
3941
4042
Args:
4143
host (str): The address of the network device.
4244
username (str): The username to authenticate with the device.
4345
password (str): The password to authenticate with the device.
4446
transport (str, optional): Transport protocol to connect to device. Defaults to "http".
47+
api_port (str, optional): Port used by nx-api to connect to device. Defaults to 80.
4548
timeout (int, optional): Timeout in seconds. Defaults to 30.
4649
port (int, optional): Port used to connect to device. Defaults to None.
4750
verify (bool, optional): SSL verification.
@@ -55,7 +58,7 @@ def __init__(self, host, username, password, transport="http", timeout=30, port=
5558
self.verify = verify
5659
# Use self.native for NXAPI
5760
self.native = NXOSNative(
58-
host, username, password, transport=transport, timeout=timeout, port=port, verify=verify
61+
host, username, password, transport=transport, timeout=timeout, port=api_port, verify=verify
5962
)
6063
# Use self.native_ssh for Netmiko SSH
6164
self.native_ssh = None
@@ -371,7 +374,7 @@ def _build_url_copy_command_simple(self, src, file_system, dest):
371374
"""Build copy command for simple URL-based transfers (TFTP, HTTP, HTTPS without credentials)."""
372375
netloc = self._netloc(src)
373376
path = self._source_path(src, dest)
374-
return f"copy {src.scheme}://{netloc}{path} {file_system}", False
377+
return f"copy {src.scheme}://{netloc}{path} {file_system}"
375378

376379
def _build_url_copy_command_with_creds(self, src, file_system, dest):
377380
"""Build copy command for URL-based transfers with credentials (HTTP/HTTPS/SCP/FTP/SFTP)."""
@@ -540,6 +543,7 @@ def remote_file_copy(self, src: FileCopyModel, dest=None, file_system=None, **kw
540543
r"Source username": src.username or "",
541544
r"yes/no|Are you sure you want to continue connecting": "yes",
542545
r"(confirm|Address or name of remote host|Source filename|Destination filename)": "",
546+
r"Enter vrf.*:": src.vrf or "",
543547
}
544548
keys = list(prompt_answers.keys()) + [current_prompt]
545549
expect_regex = f"({'|'.join(keys)})"

tests/unit/test_devices/test_nxos_device.py

Lines changed: 148 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -398,10 +398,14 @@ def test_remote_file_copy_transfer_success(self):
398398
timeout=30,
399399
)
400400
self.device.native_ssh.find_prompt.return_value = "host#"
401-
self.device.native_ssh.send_command.return_value = "Copy complete"
401+
# Mock send_command to return success message that includes the prompt
402+
self.device.native_ssh.send_command.return_value = "Copy complete\nhost#"
402403
with mock.patch.object(NXOSDevice, "verify_file", side_effect=[False, True]):
403404
self.device.remote_file_copy(src, file_system="bootflash:")
405+
# Verify send_command was called with expect_string parameter
404406
self.device.native_ssh.send_command.assert_called_once()
407+
call_args = self.device.native_ssh.send_command.call_args
408+
self.assertIn("expect_string", call_args.kwargs)
405409

406410
def test_remote_file_copy_transfer_fails_verification(self):
407411
src = FileCopyModel(
@@ -412,7 +416,8 @@ def test_remote_file_copy_transfer_fails_verification(self):
412416
timeout=30,
413417
)
414418
self.device.native_ssh.find_prompt.return_value = "host#"
415-
self.device.native_ssh.send_command.return_value = "Copy complete"
419+
# Mock send_command to return success message that includes the prompt
420+
self.device.native_ssh.send_command.return_value = "Copy complete\nhost#"
416421
with mock.patch.object(NXOSDevice, "verify_file", side_effect=[False, False]):
417422
with self.assertRaises(FileTransferError):
418423
self.device.remote_file_copy(src, file_system="bootflash:")
@@ -434,6 +439,58 @@ def test_remote_file_copy_raises_not_enough_free_space(self, mock_get_free_space
434439
self.device.remote_file_copy(src, file_system="bootflash:")
435440
self.device.native_ssh.send_command.assert_not_called()
436441

442+
def test_remote_file_copy_with_vrf_prompt_handling(self):
443+
"""Test remote_file_copy handles VRF prompts correctly."""
444+
src = FileCopyModel(
445+
download_url="ftp://example.com/nxos.bin",
446+
checksum="abc123",
447+
file_name="nxos.bin",
448+
hashing_algorithm="md5",
449+
timeout=30,
450+
username="testuser",
451+
token="testpass",
452+
vrf="management", # VRF specified for prompt response
453+
)
454+
self.device.native_ssh.find_prompt.return_value = "host#"
455+
# Mock send_command to return success message that includes the prompt
456+
self.device.native_ssh.send_command.return_value = "Copy complete\nhost#"
457+
with mock.patch.object(NXOSDevice, "verify_file", side_effect=[False, True]):
458+
self.device.remote_file_copy(src, file_system="bootflash:")
459+
460+
# Verify send_command was called with VRF prompt handling
461+
self.device.native_ssh.send_command.assert_called_once()
462+
call_args = self.device.native_ssh.send_command.call_args
463+
self.assertIn("expect_string", call_args.kwargs)
464+
# Verify the expect_string contains VRF prompt pattern
465+
expect_string = call_args.kwargs["expect_string"]
466+
self.assertIn("Enter vrf", expect_string)
467+
468+
def test_remote_file_copy_with_no_vrf_specified(self):
469+
"""Test remote_file_copy handles VRF prompts when no VRF is specified."""
470+
src = FileCopyModel(
471+
download_url="ftp://example.com/nxos.bin",
472+
checksum="abc123",
473+
file_name="nxos.bin",
474+
hashing_algorithm="md5",
475+
timeout=30,
476+
username="testuser",
477+
token="testpass",
478+
# No VRF specified - should respond with empty string to VRF prompt
479+
)
480+
self.device.native_ssh.find_prompt.return_value = "host#"
481+
# Mock send_command to return success message that includes the prompt
482+
self.device.native_ssh.send_command.return_value = "Copy complete\nhost#"
483+
with mock.patch.object(NXOSDevice, "verify_file", side_effect=[False, True]):
484+
self.device.remote_file_copy(src, file_system="bootflash:")
485+
486+
# Verify send_command was called with VRF prompt handling
487+
self.device.native_ssh.send_command.assert_called_once()
488+
call_args = self.device.native_ssh.send_command.call_args
489+
self.assertIn("expect_string", call_args.kwargs)
490+
# Verify the expect_string contains VRF prompt pattern
491+
expect_string = call_args.kwargs["expect_string"]
492+
self.assertIn("Enter vrf", expect_string)
493+
437494
def test_remote_file_copy_invalid_scheme(self):
438495
src = FileCopyModel(
439496
download_url="smtp://example.com/nxos.bin",
@@ -493,6 +550,95 @@ def test_remote_file_copy_uses_ssh_for_filesystem_detection(self, scheme, hostna
493550
any("dir" in str(call) for call in ssh_calls), "Expected SSH 'dir' command for filesystem detection"
494551
)
495552

553+
@mock.patch("pyntc.devices.nxos_device.ConnectHandler", create=True)
554+
@mock.patch("pyntc.devices.nxos_device.NXOSNative", autospec=True)
555+
def test_api_port_default(self, mock_device, mock_connect_handler):
556+
"""Test that api_port defaults to 80."""
557+
_ = NXOSDevice("host", "user", "pass")
558+
559+
# Verify NXOSNative was called with default api_port (80)
560+
mock_device.assert_called_with(
561+
"host",
562+
"user",
563+
"pass",
564+
transport="http",
565+
timeout=30,
566+
port=80, # Default api_port
567+
verify=True,
568+
)
569+
570+
@mock.patch("pyntc.devices.nxos_device.ConnectHandler", create=True)
571+
@mock.patch("pyntc.devices.nxos_device.NXOSNative", autospec=True)
572+
def test_api_port_custom(self, mock_device, mock_connect_handler):
573+
"""Test that custom api_port is passed to NXOSNative."""
574+
_ = NXOSDevice("host", "user", "pass", api_port=8080)
575+
576+
# Verify NXOSNative was called with custom api_port
577+
mock_device.assert_called_with(
578+
"host",
579+
"user",
580+
"pass",
581+
transport="http",
582+
timeout=30,
583+
port=8080, # Custom api_port
584+
verify=True,
585+
)
586+
587+
@mock.patch("pyntc.devices.nxos_device.ConnectHandler", create=True)
588+
@mock.patch("pyntc.devices.nxos_device.NXOSNative", autospec=True)
589+
def test_api_port_with_https(self, mock_device, mock_connect_handler):
590+
"""Test that api_port works with HTTPS transport."""
591+
_ = NXOSDevice("host", "user", "pass", transport="https", api_port=8443)
592+
593+
# Verify NXOSNative was called with HTTPS and custom api_port
594+
mock_device.assert_called_with(
595+
"host",
596+
"user",
597+
"pass",
598+
transport="https",
599+
timeout=30,
600+
port=8443, # Custom HTTPS api_port
601+
verify=True,
602+
)
603+
604+
@mock.patch("pyntc.devices.nxos_device.ConnectHandler", create=True)
605+
@mock.patch("pyntc.devices.nxos_device.NXOSNative", autospec=True)
606+
def test_port_parameter_preserved(self, mock_device, mock_connect_handler):
607+
"""Test that the port parameter is preserved for future SSH port customization."""
608+
device = NXOSDevice("host", "user", "pass", api_port=8080, port=2222)
609+
610+
# Verify api_port is used for NXOSNative (NX-API)
611+
mock_device.assert_called_with(
612+
"host",
613+
"user",
614+
"pass",
615+
transport="http",
616+
timeout=30,
617+
port=8080, # api_port for NX-API
618+
verify=True,
619+
)
620+
621+
# Verify port parameter is stored for future SSH use
622+
self.assertEqual(device.port, 2222)
623+
624+
@mock.patch("pyntc.devices.nxos_device.ConnectHandler", create=True)
625+
@mock.patch("pyntc.devices.nxos_device.NXOSNative", autospec=True)
626+
def test_backward_compatibility_no_api_port(self, mock_device, mock_connect_handler):
627+
"""Test backward compatibility when api_port is not specified."""
628+
# Create device without specifying api_port
629+
_ = NXOSDevice("host", "user", "pass", transport="http")
630+
631+
# Should default to port 80 for HTTP
632+
mock_device.assert_called_with(
633+
"host",
634+
"user",
635+
"pass",
636+
transport="http",
637+
timeout=30,
638+
port=80, # Default api_port for HTTP
639+
verify=True,
640+
)
641+
496642

497643
if __name__ == "__main__":
498644
unittest.main()

0 commit comments

Comments
 (0)