From fc6614b957e7baafde9c20136fa01ef795f9fe6e Mon Sep 17 00:00:00 2001 From: Gary Snider <75227981+gsnider2195@users.noreply.github.com> Date: Wed, 8 Apr 2026 11:00:35 -0700 Subject: [PATCH 1/9] bump version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 722ff8cc..697b00a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "pyntc" -version = "2.2.1" +version = "2.2.2a0" description = "Python library focused on tasks related to device level and OS management." authors = ["Network to Code, LLC "] readme = "README.md" From b21fb64f885d1724bee5c55fb968299e883f9477 Mon Sep 17 00:00:00 2001 From: Matt Miller Date: Fri, 10 Apr 2026 11:28:20 -0400 Subject: [PATCH 2/9] Removed unneeded warning filter and logging (#364) --- changes/364.removed | 2 ++ pyntc/__init__.py | 4 ---- pyntc/devices/iosxewlc_device.py | 2 -- 3 files changed, 2 insertions(+), 6 deletions(-) create mode 100644 changes/364.removed diff --git a/changes/364.removed b/changes/364.removed new file mode 100644 index 00000000..37f67239 --- /dev/null +++ b/changes/364.removed @@ -0,0 +1,2 @@ +Removed log.init from iosxewlc device. +Removed warning filter for logging. \ No newline at end of file diff --git a/pyntc/__init__.py b/pyntc/__init__.py index 6bc3e2ac..42901b78 100644 --- a/pyntc/__init__.py +++ b/pyntc/__init__.py @@ -1,7 +1,6 @@ """Kickoff functions for getting instance of device objects.""" import os -import warnings from importlib import metadata from .devices import supported_devices @@ -23,9 +22,6 @@ LIB_PATH_DEFAULT = "~/.ntc.conf" -warnings.simplefilter("default") - - def ntc_device(device_type, *args, **kwargs): """ Instantiate an instance of a ``pyntc.devices.BaseDevice`` by ``device_type``. diff --git a/pyntc/devices/iosxewlc_device.py b/pyntc/devices/iosxewlc_device.py index af16275d..7f783799 100644 --- a/pyntc/devices/iosxewlc_device.py +++ b/pyntc/devices/iosxewlc_device.py @@ -12,8 +12,6 @@ class IOSXEWLCDevice(IOSDevice): """Cisco IOSXE WLC Device Implementation.""" - log.init() - def _wait_for_device_start_reboot(self, timeout=600): start = time.time() while time.time() - start < timeout: From 78b734f0ca49bd304c7452154b8da53211dda917 Mon Sep 17 00:00:00 2001 From: Matt Miller Date: Mon, 13 Apr 2026 17:08:41 -0400 Subject: [PATCH 3/9] Add Arista EOS remote file copy (#365) * Update EOS remote file copy commands --- changes/365.added | 2 + docs/user/lib_getting_started.md | 2 +- poetry.lock | 50 +- pyntc/devices/eos_device.py | 272 ++++++ pyntc/log.py | 2 +- pyproject.toml | 1 + tests/unit/test_devices/test_eos_device.py | 997 +++++++++++++++++++++ 7 files changed, 1322 insertions(+), 4 deletions(-) create mode 100644 changes/365.added diff --git a/changes/365.added b/changes/365.added new file mode 100644 index 00000000..b3d9ecf0 --- /dev/null +++ b/changes/365.added @@ -0,0 +1,2 @@ +Added the remote file copy feature to Arista EOS devices. +Added unittests for remote file copy on Arista EOS devices. \ No newline at end of file diff --git a/docs/user/lib_getting_started.md b/docs/user/lib_getting_started.md index a5d302a3..2bf2bc4e 100644 --- a/docs/user/lib_getting_started.md +++ b/docs/user/lib_getting_started.md @@ -252,7 +252,7 @@ interface GigabitEthernet1 #### Remote File Copy (Download to Device) -Some devices support copying files directly from a URL to the device. This is useful for larger files like OS images. To do this, you need to use the `FileCopyModel` data model to specify the source file information and then pass that to the `remote_file_copy` method. Currently only supported on Cisco IOS and Juniper Junos devices. Tested with ftp, http, https, sftp, and tftp urls. +Some devices support copying files directly from a URL to the device. This is useful for large files like OS images. To do this, you need to use the `FileCopyModel` data model to specify the source file information and then pass that to the `remote_file_copy` method. The model is currently supported on Cisco IOS, Juniper Junos, and Arista EOS devices. It has been tested with ftp, http, https, sftp, and tftp urls. - `remote_file_copy` method diff --git a/poetry.lock b/poetry.lock index 7f27b716..a83e0374 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.3.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. [[package]] name = "astroid" @@ -736,6 +736,40 @@ files = [ {file = "hjson-3.1.0.tar.gz", hash = "sha256:55af475a27cf83a7969c808399d7bccdec8fb836a07ddbd574587593b9cdcf75"}, ] +[[package]] +name = "hypothesis" +version = "6.151.12" +description = "The property-based testing library for Python" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "hypothesis-6.151.12-py3-none-any.whl", hash = "sha256:37d4f3a768365c30571b11dfd7a6857a12173d933010b2c4ab65619f1b5952c5"}, + {file = "hypothesis-6.151.12.tar.gz", hash = "sha256:be485f503979af4c3dfa19e3fc2b967d0458e7f8c4e28128d7e215e0a55102e0"}, +] + +[package.dependencies] +exceptiongroup = {version = ">=1.0.0", markers = "python_version < \"3.11\""} +sortedcontainers = ">=2.1.0,<3.0.0" + +[package.extras] +all = ["black (>=20.8b0)", "click (>=7.0)", "crosshair-tool (>=0.0.102)", "django (>=4.2)", "dpcontracts (>=0.4)", "hypothesis-crosshair (>=0.0.27)", "lark (>=0.10.1)", "libcst (>=0.3.16)", "numpy (>=1.21.6)", "pandas (>=1.1)", "pytest (>=4.6)", "python-dateutil (>=1.4)", "pytz (>=2014.1)", "redis (>=3.0.0)", "rich (>=9.0.0)", "tzdata (>=2025.3) ; sys_platform == \"win32\" or sys_platform == \"emscripten\"", "watchdog (>=4.0.0)"] +cli = ["black (>=20.8b0)", "click (>=7.0)", "rich (>=9.0.0)"] +codemods = ["libcst (>=0.3.16)"] +crosshair = ["crosshair-tool (>=0.0.102)", "hypothesis-crosshair (>=0.0.27)"] +dateutil = ["python-dateutil (>=1.4)"] +django = ["django (>=4.2)"] +dpcontracts = ["dpcontracts (>=0.4)"] +ghostwriter = ["black (>=20.8b0)"] +lark = ["lark (>=0.10.1)"] +numpy = ["numpy (>=1.21.6)"] +pandas = ["pandas (>=1.1)"] +pytest = ["pytest (>=4.6)"] +pytz = ["pytz (>=2014.1)"] +redis = ["redis (>=3.0.0)"] +watchdog = ["watchdog (>=4.0.0)"] +zoneinfo = ["tzdata (>=2025.3) ; sys_platform == \"win32\" or sys_platform == \"emscripten\""] + [[package]] name = "idna" version = "3.11" @@ -2065,6 +2099,18 @@ files = [ {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, ] +[[package]] +name = "sortedcontainers" +version = "2.4.0" +description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set" +optional = false +python-versions = "*" +groups = ["dev"] +files = [ + {file = "sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"}, + {file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"}, +] + [[package]] name = "super-collections" version = "0.6.2" @@ -2362,4 +2408,4 @@ test = ["coverage", "hypothesis", "pytest"] [metadata] lock-version = "2.1" python-versions = ">=3.10,<3.15" -content-hash = "170dc2e3c4ba7d06456e66881cbdd2520cc015960cfc4f6224063913b3093050" +content-hash = "2f28ec257d147660458dc4b655c06cfc92fc9cb6f7437f66ae061e7b8cac69d0" diff --git a/pyntc/devices/eos_device.py b/pyntc/devices/eos_device.py index 51a41bdc..2727eec3 100644 --- a/pyntc/devices/eos_device.py +++ b/pyntc/devices/eos_device.py @@ -3,6 +3,7 @@ import os import re import time +from urllib.parse import urlparse from netmiko import ConnectHandler, FileTransfer from pyeapi import connect as eos_connect @@ -23,6 +24,7 @@ RebootTimeoutError, ) from pyntc.utils import convert_list_by_key +from pyntc.utils.models import FileCopyModel BASIC_FACTS_KM = {"model": "modelName", "os_version": "internalVersion", "serial_number": "serialNumber"} INTERFACES_KM = { @@ -421,6 +423,276 @@ def file_copy_remote_exists(self, src, dest=None, file_system=None): log.debug("Host %s: File %s does not already exist on remote.", self.host, src) return False + def check_file_exists(self, filename, file_system=None): + """Check if a remote file exists by filename. + + Args: + filename (str): The name of the file to check for on the remote device. + file_system (str): Supported only for Arista. The file system for the + remote file. If no file_system is provided, then the `get_file_system` + method is used to determine the correct file system to use. + + Returns: + (bool): True if the remote file exists, False if it doesn't. + + Raises: + CommandError: If there is an error in executing the command to check if the file exists. + """ + exists = False + + file_system = file_system or self._get_file_system() + command = f"dir {file_system}/{filename}" + result = self.native_ssh.send_command(command, read_timeout=30) + + log.debug( + "Host %s: Checking if file %s exists on remote with command '%s' and result: %s", + self.host, + filename, + command, + result, + ) + + # Check for error patterns + if re.search(r"% Error listing directory|No such file|No files found|Path does not exist", result): + log.debug("Host %s: File %s does not exist on remote.", self.host, filename) + exists = False + elif re.search(rf"Directory of .*{filename}", result): + log.debug("Host %s: File %s exists on remote.", self.host, filename) + exists = True + else: + raise CommandError(command, f"Unable to determine if file {filename} exists on remote: {result}") + + return exists + + def get_remote_checksum(self, filename, hashing_algorithm="md5", **kwargs): + """Get the checksum of a remote file on Arista EOS device using netmiko SSH. + + Uses Arista's 'verify' command via SSH to compute file checksums. + Note, Netmiko FileTransfer only supports `verify /md5` + + Args: + filename (str): The name of the file to check for on the remote device. + hashing_algorithm (str): The hashing algorithm to use (default: "md5"). + **kwargs (Any): Passible parameters such as file_system. + + Returns: + (str): The checksum of the remote file. + + Raises: + CommandError: If the verify command fails (but not if file doesn't exist). + """ + file_system = kwargs.get("file_system") + if file_system is None: + file_system = self._get_file_system() + + # Normalize file_system to Arista format (e.g., "flash:" or "/mnt/flash") + if not file_system.startswith("/") and not file_system.endswith(":"): + file_system = f"{file_system}:" + + # Build the path + if file_system.endswith(":"): + path = f"{file_system}{filename}" + else: + path = f"{file_system}/{filename}" + + # Use Arista's verify command to get the checksum + # Example: verify /sha512 flash:nautobot.png + command = f"verify /{hashing_algorithm} {path}" + + try: + result = self.native_ssh.send_command(command, read_timeout=30) + + log.debug( + "Host %s: Verify command '%s' returned: %s", + self.host, + command, + result, + ) + + # Parse the checksum from the output + # Expected format: verify /sha512 (flash:nautobot.png) = + match = re.search(r"=\s*([a-fA-F0-9]+)", result) + if match: + remote_checksum = match.group(1).lower() + log.debug("Host %s: Remote checksum for %s: %s", self.host, filename, remote_checksum) + return remote_checksum + + log.error("Host %s: Could not parse checksum from verify output: %s", self.host, result) + raise CommandError(command, f"Could not parse checksum from verify output: {result}") + + except Exception as e: + log.error("Host %s: Error getting remote checksum: %s", self.host, str(e)) + raise CommandError(command, f"Error getting remote checksum: {str(e)}") + + def _build_url_copy_command_simple(self, src, file_system): + """Build copy command for simple URL-based transfers (TFTP, HTTP, HTTPS without credentials).""" + return f"copy {src.download_url} {file_system}", False + + def _build_url_copy_command_with_creds(self, src, file_system): + """Build copy command for URL-based transfers with credentials (HTTP/HTTPS/SCP/FTP/SFTP).""" + parsed = urlparse(src.download_url) + hostname = parsed.hostname + path = parsed.path + + # Determine port based on scheme + if parsed.port: + port = parsed.port + elif src.scheme == "https": + port = "443" + elif src.scheme in ["http"]: + port = "80" + else: + port = "" + + port_str = f":{port}" if port else "" + + # For HTTP/HTTPS, include both username and token + if src.scheme in ["http", "https"]: + command = f"copy {src.scheme}://{src.username}:{src.token}@{hostname}{port_str}{path} {file_system}" + detect_prompt = False + # For SCP/FTP/SFTP, include only username (password via prompt) + else: + command = f"copy {src.scheme}://{src.username}@{hostname}{port_str}{path} {file_system}" + detect_prompt = True + + return command, detect_prompt + + def remote_file_copy(self, src: FileCopyModel, dest=None, file_system=None, include_username=False, **kwargs): + """Copy a file from remote source to device. + + Args: + src (FileCopyModel): The source file model with transfer parameters. + dest (str): Destination filename (defaults to src.file_name). + file_system (str): Device filesystem (auto-detected if not provided). + include_username (bool): Whether to include username in the copy command. Defaults to False. + **kwargs (Any): Passible parameters such as file_system. + + Raises: + TypeError: If src is not a FileCopyModel. + FileTransferError: If transfer or verification fails. + FileSystemNotFoundError: If filesystem cannot be determined. + """ + # Validate input + if not isinstance(src, FileCopyModel): + raise TypeError("src must be an instance of FileCopyModel") + + # Determine file system + if file_system is None: + file_system = self._get_file_system() + + # Determine destination + if dest is None: + dest = src.file_name + + log.debug("Host %s: Starting remote file copy for %s to %s/%s", self.host, src.file_name, file_system, dest) + + # Open SSH connection and enable + self.open() + self.enable() + + # Validate scheme + supported_schemes = ["http", "https", "scp", "ftp", "sftp", "tftp"] + if src.scheme not in supported_schemes: + raise ValueError(f"Unsupported scheme: {src.scheme}") + + # Build command based on scheme and credentials + command_builders = { + ("tftp", False): lambda: self._build_url_copy_command_simple(src, file_system), + ("http", False): lambda: self._build_url_copy_command_simple(src, file_system), + ("https", False): lambda: self._build_url_copy_command_simple(src, file_system), + ("http", True): lambda: self._build_url_copy_command_with_creds(src, file_system), + ("https", True): lambda: self._build_url_copy_command_with_creds(src, file_system), + ("scp", False): lambda: self._build_url_copy_command_with_creds(src, file_system), + ("scp", True): lambda: self._build_url_copy_command_with_creds(src, file_system), + ("ftp", False): lambda: self._build_url_copy_command_with_creds(src, file_system), + ("ftp", True): lambda: self._build_url_copy_command_with_creds(src, file_system), + ("sftp", False): lambda: self._build_url_copy_command_with_creds(src, file_system), + ("sftp", True): lambda: self._build_url_copy_command_with_creds(src, file_system), + } + + builder_key = (src.scheme, include_username and src.username is not None) + if builder_key not in command_builders: + raise ValueError(f"Unable to construct copy command for scheme {src.scheme} with provided credentials") + + command, detect_prompt = command_builders[builder_key]() + log.debug("Host %s: Preparing copy command for %s", self.host, src.scheme) + + # Execute copy command + if detect_prompt and src.token: + # Use send_command_timing for interactive password prompt + output = self.native_ssh.send_command_timing(command, read_timeout=src.timeout, cmd_verify=False) + log.debug("Host %s: Copy command (with timing) output: %s", self.host, output) + + if "password:" in output.lower(): + self.native_ssh.write_channel(src.token + "\n") + # Read the response after sending password + output += self.native_ssh.read_channel() + log.debug("Host %s: Output after password entry: %s", self.host, output) + elif any(error in output.lower() for error in ["error", "invalid", "failed"]): + log.error("Host %s: Error detected in copy command output: %s", self.host, output) + raise FileTransferError(f"Error detected in copy command output: {output}") + else: + # Use regular send_command for non-interactive transfers + output = self.native_ssh.send_command(command, read_timeout=src.timeout) + log.debug("Host %s: Copy command output: %s", self.host, output) + + if any(error in output.lower() for error in ["error", "invalid", "failed"]): + log.error("Host %s: Error detected in copy command output: %s", self.host, output) + raise FileTransferError(f"Error detected in copy command output: {output}") + + # Verify transfer success + verification_result = self.verify_file( + src.checksum, dest, hashing_algorithm=src.hashing_algorithm, file_system=file_system + ) + log.debug( + "Host %s: File verification result for %s - Checksum: %s, Algorithm: %s, Result: %s", + self.host, + dest, + src.checksum, + src.hashing_algorithm, + verification_result, + ) + + if not verification_result: + log.error( + "Host %s: File verification failed for %s - Expected checksum: %s", + self.host, + dest, + src.checksum, + ) + raise FileTransferError + + log.info("Host %s: File %s transferred and verified successfully", self.host, dest) + + def verify_file(self, checksum, filename, hashing_algorithm="md5", **kwargs): + """Verify a file on the remote device by confirming the file exists and validate the checksum. + + Args: + checksum (str): The checksum of the file. + filename (str): The name of the file to check for on the remote device. + hashing_algorithm (str): The hashing algorithm to use (default: "md5"). + **kwargs (Any): Passible parameters such as file_system. + + Returns: + (bool): True if the file is verified successfully, False otherwise. + """ + exists = self.check_file_exists(filename, **kwargs) + device_checksum = ( + self.get_remote_checksum(filename, hashing_algorithm=hashing_algorithm, **kwargs) if exists else None + ) + if checksum == device_checksum: + log.debug("Host %s: Checksum verification successful for file %s", self.host, filename) + return True + + log.debug( + "Host %s: Checksum verification failed for file %s - Expected: %s, Actual: %s", + self.host, + filename, + checksum, + device_checksum, + ) + return False + def install_os(self, image_name, **vendor_specifics): """Install new OS on device. diff --git a/pyntc/log.py b/pyntc/log.py index 15e31f53..79b2f423 100644 --- a/pyntc/log.py +++ b/pyntc/log.py @@ -54,7 +54,7 @@ def init(**kwargs): logging.basicConfig(**kwargs) # info is defined at the end of the file - info("Logging initialized for host %s.", kwargs.get("host")) + info("Logging initialized for host %s.", kwargs.pop("host", None)) def logger(level): diff --git a/pyproject.toml b/pyproject.toml index 697b00a9..9451d4bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,7 @@ attrs = "^23.2.0" towncrier = ">=23.6.0,<=24.8.0" ruff = "*" Markdown = "*" +hypothesis = "*" [tool.poetry.group.docs.dependencies] # Rendering docs to HTML diff --git a/tests/unit/test_devices/test_eos_device.py b/tests/unit/test_devices/test_eos_device.py index 2d6bcdc2..9848cf5a 100644 --- a/tests/unit/test_devices/test_eos_device.py +++ b/tests/unit/test_devices/test_eos_device.py @@ -10,6 +10,7 @@ from pyntc.devices.eos_device import FileTransferError from pyntc.devices.system_features.vlans.eos_vlans import EOSVlans from pyntc.errors import CommandError, CommandListError +from pyntc.utils.models import FileCopyModel from .device_mocks.eos import config, enable, send_command, send_command_expect @@ -278,6 +279,8 @@ def test_file_copy_fail(self, mock_open, mock_close, mock_ssh, mock_ft): with self.assertRaises(FileTransferError): self.device.file_copy("source_file") + # TODO: unit test for remote_file_copy + def test_reboot(self): self.device.reboot() self.device.native.enable.assert_called_with(["reload now"], encoding="json") @@ -433,3 +436,997 @@ def test_init_pass_port_and_timeout(mock_eos_connect): mock_eos_connect.assert_called_with( host="host", username="username", password="password", transport="http", port=8080, timeout=30 ) + + +# Property-based tests for file system normalization +try: + from hypothesis import given + from hypothesis import strategies as st +except ImportError: + # Create dummy decorators if hypothesis is not available + def given(*args, **kwargs): + def decorator(func): + return func + + return decorator + + class _ST: + @staticmethod + def just(value): + return value + + @staticmethod + def one_of(*args): + return args[0] + + st = _ST() + + +@given( + src=st.just("not_a_filecopymodel"), +) +def test_property_type_validation(src): + """Feature: arista-remote-file-copy, Property 1: Type Validation. + + For any non-FileCopyModel object passed as `src`, the `remote_file_copy()` + method should raise a `TypeError`. + + Validates: Requirements 1.2, 15.1 + """ + device = EOSDevice("host", "user", "pass") + + with pytest.raises(TypeError) as exc_info: + device.remote_file_copy(src) + + assert "src must be an instance of FileCopyModel" in str(exc_info.value) + + +@mock.patch.object(EOSDevice, "_get_file_system") +def test_property_file_system_auto_detection(mock_get_fs): + """Feature: arista-remote-file-copy, Property 26: File System Auto-Detection. + + For any `remote_file_copy()` call without an explicit `file_system` parameter, + the method should call `_get_file_system()` to determine the default file system. + + Validates: Requirements 11.1 + """ + mock_get_fs.return_value = "/mnt/flash" + device = EOSDevice("host", "user", "pass") + + src = FileCopyModel( + download_url="http://server.example.com/file.bin", + checksum="abc123", + file_name="file.bin", + ) + + # Call remote_file_copy without file_system parameter + try: + device.remote_file_copy(src) + except Exception: + # We expect it to fail later, but we just want to verify _get_file_system was called + pass + + # Verify _get_file_system was called + mock_get_fs.assert_called() + + +@mock.patch.object(EOSDevice, "_get_file_system") +def test_property_explicit_file_system_usage(mock_get_fs): + """Feature: arista-remote-file-copy, Property 27: Explicit File System Usage. + + For any `remote_file_copy()` call with an explicit `file_system` parameter, + that value should be used instead of auto-detection. + + Validates: Requirements 11.2 + """ + device = EOSDevice("host", "user", "pass") + + src = FileCopyModel( + download_url="http://server.example.com/file.bin", + checksum="abc123", + file_name="file.bin", + ) + + # Call remote_file_copy with explicit file_system parameter + try: + device.remote_file_copy(src, file_system="/mnt/flash") + except Exception: + # We expect it to fail later, but we just want to verify _get_file_system was NOT called + pass + + # Verify _get_file_system was NOT called + mock_get_fs.assert_not_called() + + +@mock.patch.object(EOSDevice, "verify_file") +@mock.patch.object(EOSDevice, "enable") +@mock.patch.object(EOSDevice, "open") +@mock.patch.object(EOSDevice, "_get_file_system") +def test_property_default_destination_from_filecopymodel(mock_get_fs, mock_open, mock_enable, mock_verify): + """Feature: arista-remote-file-copy, Property 28: Default Destination from FileCopyModel. + + For any `remote_file_copy()` call without an explicit `dest` parameter, + the destination should default to `src.file_name`. + + Validates: Requirements 12.1 + """ + mock_get_fs.return_value = "/mnt/flash" + mock_verify.return_value = True + + device = EOSDevice("host", "user", "pass") + device.native_ssh = mock.MagicMock() + device.native_ssh.send_command.return_value = "Copy completed successfully" + + src = FileCopyModel( + download_url="http://server.example.com/myfile.bin", + checksum="abc123", + file_name="myfile.bin", + ) + + # Call remote_file_copy without explicit dest + device.remote_file_copy(src) + + # Verify verify_file was called with the default destination + mock_verify.assert_called() + call_args = mock_verify.call_args + assert call_args[0][1] == "myfile.bin" # dest should be file_name + + +@mock.patch.object(EOSDevice, "verify_file") +@mock.patch.object(EOSDevice, "enable") +@mock.patch.object(EOSDevice, "open") +@mock.patch.object(EOSDevice, "_get_file_system") +def test_property_explicit_destination_usage(mock_get_fs, mock_open, mock_enable, mock_verify): + """Feature: arista-remote-file-copy, Property 29: Explicit Destination Usage. + + For any `remote_file_copy()` call with an explicit `dest` parameter, + that value should be used as the destination filename. + + Validates: Requirements 12.2 + """ + mock_get_fs.return_value = "/mnt/flash" + mock_verify.return_value = True + + device = EOSDevice("host", "user", "pass") + device.native_ssh = mock.MagicMock() + device.native_ssh.send_command.return_value = "Copy completed successfully" + + src = FileCopyModel( + download_url="http://server.example.com/myfile.bin", + checksum="abc123", + file_name="myfile.bin", + ) + + # Call remote_file_copy with explicit dest + device.remote_file_copy(src, dest="different_name.bin") + + # Verify verify_file was called with the explicit destination + mock_verify.assert_called() + call_args = mock_verify.call_args + assert call_args[0][1] == "different_name.bin" # dest should be the explicit value + + +class TestRemoteFileCopy(unittest.TestCase): + """Tests for remote_file_copy method.""" + + @mock.patch("pyeapi.client.Node", autospec=True) + def setUp(self, mock_node): + self.device = EOSDevice("host", "user", "pass") + self.maxDiff = None + mock_node.enable.side_effect = enable + mock_node.config.side_effect = config + self.device.native = mock_node + + def tearDown(self): + self.device.native.reset_mock() + + def test_remote_file_copy_invalid_src_type(self): + """Test remote_file_copy raises TypeError for invalid src type.""" + with pytest.raises(TypeError) as exc_info: + self.device.remote_file_copy("not_a_model") + assert "src must be an instance of FileCopyModel" in str(exc_info.value) + + @mock.patch.object(EOSDevice, "verify_file") + @mock.patch.object(EOSDevice, "enable") + @mock.patch.object(EOSDevice, "open") + @mock.patch.object(EOSDevice, "_get_file_system") + def test_remote_file_copy_skip_transfer_on_checksum_match(self, mock_get_fs, mock_open, mock_enable, mock_verify): + """Test remote_file_copy skips transfer when file exists with matching checksum.""" + from pyntc.utils.models import FileCopyModel + + mock_get_fs.return_value = "flash:" + mock_verify.return_value = True + + # Mock netmiko connection + mock_ssh = mock.MagicMock() + mock_ssh.send_command.return_value = "Copy completed successfully" + self.device.native_ssh = mock_ssh + + src = FileCopyModel( + download_url="http://example.com/file.bin", + checksum="abc123", + file_name="file.bin", + ) + + # Should return without raising exception + self.device.remote_file_copy(src) + + # Verify that verify_file was called + mock_verify.assert_called() + + @mock.patch.object(EOSDevice, "verify_file") + @mock.patch.object(EOSDevice, "enable") + @mock.patch.object(EOSDevice, "open") + @mock.patch.object(EOSDevice, "_get_file_system") + def test_remote_file_copy_http_transfer(self, mock_get_fs, mock_open, mock_enable, mock_verify): + """Test remote_file_copy executes HTTP transfer correctly.""" + from pyntc.utils.models import FileCopyModel + + mock_get_fs.return_value = "flash:" + mock_verify.return_value = True # Verification passes + + # Mock netmiko connection + mock_ssh = mock.MagicMock() + mock_ssh.send_command.return_value = "Copy completed successfully" + self.device.native_ssh = mock_ssh + + src = FileCopyModel( + download_url="http://example.com/file.bin", + checksum="abc123", + file_name="file.bin", + ) + + # Should not raise exception + self.device.remote_file_copy(src) + + # Verify open and enable were called + mock_open.assert_called_once() + mock_enable.assert_called_once() + + # Verify send_command was called with correct command + mock_ssh.send_command.assert_called() + + @mock.patch.object(EOSDevice, "verify_file") + @mock.patch.object(EOSDevice, "enable") + @mock.patch.object(EOSDevice, "open") + @mock.patch.object(EOSDevice, "_get_file_system") + def test_remote_file_copy_verification_failure(self, mock_get_fs, mock_open, mock_enable, mock_verify): + """Test remote_file_copy raises FileTransferError when verification fails.""" + from pyntc.utils.models import FileCopyModel + + mock_get_fs.return_value = "flash:" + mock_verify.return_value = False # Verification fails + + # Mock netmiko connection + mock_ssh = mock.MagicMock() + mock_ssh.send_command.return_value = "Copy completed successfully" + self.device.native_ssh = mock_ssh + + src = FileCopyModel( + download_url="http://example.com/file.bin", + checksum="abc123", + file_name="file.bin", + ) + + # Should raise FileTransferError + with pytest.raises(FileTransferError): + self.device.remote_file_copy(src) + + @mock.patch.object(EOSDevice, "verify_file") + @mock.patch.object(EOSDevice, "enable") + @mock.patch.object(EOSDevice, "open") + @mock.patch.object(EOSDevice, "_get_file_system") + def test_remote_file_copy_with_explicit_dest(self, mock_get_fs, mock_open, mock_enable, mock_verify): + """Test remote_file_copy uses explicit dest parameter.""" + from pyntc.utils.models import FileCopyModel + + mock_get_fs.return_value = "flash:" + mock_verify.return_value = True + + # Mock netmiko connection + mock_ssh = mock.MagicMock() + mock_ssh.send_command.return_value = "Copy completed successfully" + self.device.native_ssh = mock_ssh + + src = FileCopyModel( + download_url="http://example.com/file.bin", + checksum="abc123", + file_name="file.bin", + ) + + # Call with explicit dest + self.device.remote_file_copy(src, dest="custom_name.bin") + + # Verify verify_file was called with custom dest + call_args = mock_verify.call_args + assert call_args[0][1] == "custom_name.bin" + + @mock.patch.object(EOSDevice, "verify_file") + @mock.patch.object(EOSDevice, "enable") + @mock.patch.object(EOSDevice, "open") + @mock.patch.object(EOSDevice, "_get_file_system") + def test_remote_file_copy_with_explicit_file_system(self, mock_get_fs, mock_open, mock_enable, mock_verify): + """Test remote_file_copy uses explicit file_system parameter.""" + from pyntc.utils.models import FileCopyModel + + mock_verify.return_value = True + + # Mock netmiko connection + mock_ssh = mock.MagicMock() + mock_ssh.send_command.return_value = "Copy completed successfully" + self.device.native_ssh = mock_ssh + + src = FileCopyModel( + download_url="http://example.com/file.bin", + checksum="abc123", + file_name="file.bin", + ) + + # Call with explicit file_system + self.device.remote_file_copy(src, file_system="flash:") + + # Verify _get_file_system was NOT called + mock_get_fs.assert_not_called() + + # Verify send_command was called with correct file_system + call_args = mock_ssh.send_command.call_args + assert "flash:" in call_args[0][0] + + @mock.patch.object(EOSDevice, "verify_file") + @mock.patch.object(EOSDevice, "enable") + @mock.patch.object(EOSDevice, "open") + @mock.patch.object(EOSDevice, "_get_file_system") + def test_remote_file_copy_scp_with_credentials(self, mock_get_fs, mock_open, mock_enable, mock_verify): + """Test remote_file_copy constructs SCP command with username only.""" + from pyntc.utils.models import FileCopyModel + + mock_get_fs.return_value = "flash:" + mock_verify.return_value = True + + # Mock netmiko connection + mock_ssh = mock.MagicMock() + mock_ssh.send_command_timing.return_value = "Copy completed successfully" + self.device.native_ssh = mock_ssh + + src = FileCopyModel( + download_url="scp://user:pass@server.com/file.bin", + checksum="abc123", + file_name="file.bin", + ) + + self.device.remote_file_copy(src) + + # Verify send_command_timing was called with SCP command containing username only + # Token is provided at the Arista "Password:" prompt + call_args = mock_ssh.send_command_timing.call_args + command = call_args[0][0] + assert "scp://" in command + assert "user@" in command + assert "pass@" not in command # Password should not be in command + + @mock.patch.object(EOSDevice, "verify_file") + @mock.patch.object(EOSDevice, "enable") + @mock.patch.object(EOSDevice, "open") + @mock.patch.object(EOSDevice, "_get_file_system") + def test_remote_file_copy_timeout_applied(self, mock_get_fs, mock_open, mock_enable, mock_verify): + """Test remote_file_copy applies timeout to send_command.""" + from pyntc.utils.models import FileCopyModel + + mock_get_fs.return_value = "flash:" + mock_verify.return_value = True + + # Mock netmiko connection + mock_ssh = mock.MagicMock() + mock_ssh.send_command.return_value = "Copy completed successfully" + self.device.native_ssh = mock_ssh + + src = FileCopyModel( + download_url="http://example.com/file.bin", + checksum="abc123", + file_name="file.bin", + timeout=1800, + ) + + self.device.remote_file_copy(src) + + # Verify send_command was called with correct timeout + call_args = mock_ssh.send_command.call_args + assert call_args[1]["read_timeout"] == 1800 + + +# Property-based tests for Task 7: Pre-transfer verification + + +@mock.patch.object(EOSDevice, "verify_file") +@mock.patch.object(EOSDevice, "enable") +@mock.patch.object(EOSDevice, "open") +@mock.patch.object(EOSDevice, "_get_file_system") +def test_property_skip_transfer_on_checksum_match(mock_get_fs, mock_open, mock_enable, mock_verify): + """Feature: arista-remote-file-copy, Property 14: Skip Transfer on Checksum Match. + + For any file that already exists on the device with a matching checksum, + the `remote_file_copy()` method should return successfully after verification. + + Validates: Requirements 5.2 + """ + from pyntc.utils.models import FileCopyModel + + mock_get_fs.return_value = "/mnt/flash" + mock_verify.return_value = True # File exists with matching checksum + + device = EOSDevice("host", "user", "pass") + device.native_ssh = mock.MagicMock() + device.native_ssh.send_command.return_value = "Copy completed successfully" + + src = FileCopyModel( + download_url="http://server.example.com/file.bin", + checksum="abc123def456", + file_name="file.bin", + ) + + # Call remote_file_copy + device.remote_file_copy(src) + + # Verify that verify_file was called + mock_verify.assert_called() + + # Verify that send_command was called (transfer always occurs) + device.native_ssh.send_command.assert_called() + + +@mock.patch.object(EOSDevice, "verify_file") +@mock.patch.object(EOSDevice, "enable") +@mock.patch.object(EOSDevice, "open") +@mock.patch.object(EOSDevice, "_get_file_system") +def test_property_proceed_on_checksum_mismatch(mock_get_fs, mock_open, mock_enable, mock_verify): + """Feature: arista-remote-file-copy, Property 15: Proceed on Checksum Mismatch. + + For any file that exists on the device but has a mismatched checksum, + the `remote_file_copy()` method should proceed with the file transfer. + + Validates: Requirements 5.3 + """ + from pyntc.utils.models import FileCopyModel + + mock_get_fs.return_value = "/mnt/flash" + # Verification fails (file doesn't exist or checksum mismatches) + mock_verify.return_value = False + + device = EOSDevice("host", "user", "pass") + mock_ssh = mock.MagicMock() + mock_ssh.send_command.return_value = "Copy completed successfully" + device.native_ssh = mock_ssh + + src = FileCopyModel( + download_url="http://server.example.com/file.bin", + checksum="abc123def456", + file_name="file.bin", + ) + + # Call remote_file_copy - should raise FileTransferError because verification fails + with pytest.raises(FileTransferError): + device.remote_file_copy(src) + + # Verify that send_command was called with a copy command + mock_ssh.send_command.assert_called() + call_args = mock_ssh.send_command.call_args + assert "copy" in call_args[0][0].lower() + + +# Tests for Task 8: Command Execution + + +class TestRemoteFileCopyCommandExecution(unittest.TestCase): + """Tests for command execution flow in remote_file_copy.""" + + @mock.patch.object(EOSDevice, "verify_file") + @mock.patch.object(EOSDevice, "enable") + @mock.patch.object(EOSDevice, "open") + @mock.patch.object(EOSDevice, "_get_file_system") + def test_command_execution_with_http(self, mock_get_fs, mock_open, mock_enable, mock_verify): + """Test command execution for HTTP transfer.""" + from pyntc.utils.models import FileCopyModel + + mock_get_fs.return_value = "/mnt/flash" + mock_verify.return_value = True + + device = EOSDevice("host", "user", "pass") + mock_ssh = mock.MagicMock() + mock_ssh.send_command.return_value = "Copy completed successfully" + device.native_ssh = mock_ssh + + src = FileCopyModel( + download_url="http://server.example.com/file.bin", + checksum="abc123def456", + file_name="file.bin", + ) + + device.remote_file_copy(src) + + # Verify open() was called + mock_open.assert_called_once() + + # Verify enable() was called + mock_enable.assert_called_once() + + # Verify send_command was called with HTTP copy command + mock_ssh.send_command.assert_called() + call_args = mock_ssh.send_command.call_args + assert "copy http://" in call_args[0][0] + + @mock.patch.object(EOSDevice, "verify_file") + @mock.patch.object(EOSDevice, "enable") + @mock.patch.object(EOSDevice, "open") + @mock.patch.object(EOSDevice, "_get_file_system") + def test_command_execution_with_scp_credentials(self, mock_get_fs, mock_open, mock_enable, mock_verify): + """Test command execution for SCP transfer with username only.""" + from pyntc.utils.models import FileCopyModel + + mock_get_fs.return_value = "/mnt/flash" + mock_verify.return_value = True + + device = EOSDevice("host", "user", "pass") + mock_ssh = mock.MagicMock() + mock_ssh.send_command_timing.return_value = "Copy completed successfully" + device.native_ssh = mock_ssh + + src = FileCopyModel( + download_url="scp://admin:password@backup.example.com/configs/startup-config", + checksum="abc123def456", + file_name="startup-config", + username="admin", + token="password", + ) + + device.remote_file_copy(src) + + # Verify send_command_timing was called with SCP copy command including username only + # Token is provided at the Arista "Password:" prompt + mock_ssh.send_command_timing.assert_called() + call_args = mock_ssh.send_command_timing.call_args + assert "copy scp://" in call_args[0][0] + assert "admin@" in call_args[0][0] + assert "password@" not in call_args[0][0] # Password should not be in command + + @mock.patch.object(EOSDevice, "verify_file") + @mock.patch.object(EOSDevice, "enable") + @mock.patch.object(EOSDevice, "open") + @mock.patch.object(EOSDevice, "_get_file_system") + def test_timeout_applied_to_send_command(self, mock_get_fs, mock_open, mock_enable, mock_verify): + """Test that timeout is applied to send_command calls.""" + from pyntc.utils.models import FileCopyModel + + mock_get_fs.return_value = "/mnt/flash" + mock_verify.return_value = True + + device = EOSDevice("host", "user", "pass") + mock_ssh = mock.MagicMock() + mock_ssh.send_command.return_value = "Copy completed successfully" + device.native_ssh = mock_ssh + + src = FileCopyModel( + download_url="http://server.example.com/file.bin", + checksum="abc123def456", + file_name="file.bin", + timeout=600, + ) + + device.remote_file_copy(src) + + # Verify send_command was called with the specified timeout + mock_ssh.send_command.assert_called() + call_args = mock_ssh.send_command.call_args + assert call_args[1]["read_timeout"] == 600 + + +# Tests for Task 9: Post-transfer Verification + + +@pytest.mark.parametrize( + "checksum,algorithm", + [ + ("abc123def456", "md5"), + ("abc123def456789", "sha256"), + ], +) +def test_property_post_transfer_verification(checksum, algorithm): + """Feature: arista-remote-file-copy, Property 20: Post-Transfer Verification. + + For any completed file transfer, the method should verify the file exists + on the device and compute its checksum using the specified algorithm. + + Validates: Requirements 9.1, 9.2 + """ + from pyntc.utils.models import FileCopyModel + + device = EOSDevice("host", "user", "pass") + device.native_ssh = mock.MagicMock() + + with mock.patch.object(device, "verify_file") as mock_verify: + with mock.patch.object(device, "_get_file_system") as mock_get_fs: + with mock.patch.object(device, "open"): + with mock.patch.object(device, "enable"): + mock_get_fs.return_value = "/mnt/flash" + mock_verify.return_value = True + device.native_ssh.send_command.return_value = "Copy completed successfully" + + src = FileCopyModel( + download_url="http://server.example.com/file.bin", + checksum=checksum, + file_name="file.bin", + hashing_algorithm=algorithm, + ) + + device.remote_file_copy(src) + + # Verify that verify_file was called + mock_verify.assert_called() + + +@pytest.mark.parametrize( + "checksum,algorithm", + [ + ("abc123def456", "md5"), + ("abc123def456789", "sha256"), + ], +) +def test_property_checksum_match_verification(checksum, algorithm): + """Feature: arista-remote-file-copy, Property 21: Checksum Match Verification. + + For any transferred file where the computed checksum matches the expected checksum, + the method should consider the transfer successful. + + Validates: Requirements 9.3 + """ + from pyntc.utils.models import FileCopyModel + + device = EOSDevice("host", "user", "pass") + device.native_ssh = mock.MagicMock() + + with mock.patch.object(device, "verify_file") as mock_verify: + with mock.patch.object(device, "_get_file_system") as mock_get_fs: + with mock.patch.object(device, "open"): + with mock.patch.object(device, "enable"): + mock_get_fs.return_value = "/mnt/flash" + # Verification passes + mock_verify.return_value = True + device.native_ssh.send_command.return_value = "Copy completed successfully" + + src = FileCopyModel( + download_url="http://server.example.com/file.bin", + checksum=checksum, + file_name="file.bin", + hashing_algorithm=algorithm, + ) + + # Should not raise an exception + device.remote_file_copy(src) + + +def test_property_checksum_mismatch_error(): + """Feature: arista-remote-file-copy, Property 22: Checksum Mismatch Error. + + For any transferred file where the computed checksum does not match the expected checksum, + the method should raise a FileTransferError. + + Validates: Requirements 9.4 + """ + from pyntc.utils.models import FileCopyModel + + device = EOSDevice("host", "user", "pass") + device.native_ssh = mock.MagicMock() + + with mock.patch.object(device, "verify_file") as mock_verify: + with mock.patch.object(device, "_get_file_system") as mock_get_fs: + with mock.patch.object(device, "open"): + with mock.patch.object(device, "enable"): + mock_get_fs.return_value = "/mnt/flash" + # First call: file doesn't exist (False) + # Second call: checksum mismatch (False) + mock_verify.side_effect = [False, False] + device.native_ssh.send_command.return_value = "Copy completed successfully" + + src = FileCopyModel( + download_url="http://server.example.com/file.bin", + checksum="abc123def456", + file_name="file.bin", + ) + + # Should raise FileTransferError + with pytest.raises(FileTransferError): + device.remote_file_copy(src) + + +def test_property_missing_file_after_transfer_error(): + """Feature: arista-remote-file-copy, Property 23: Missing File After Transfer Error. + + For any transfer that completes but the file does not exist on the device afterward, + the method should raise a FileTransferError. + + Validates: Requirements 9.5 + """ + from pyntc.utils.models import FileCopyModel + + device = EOSDevice("host", "user", "pass") + device.native_ssh = mock.MagicMock() + + with mock.patch.object(device, "verify_file") as mock_verify: + with mock.patch.object(device, "_get_file_system") as mock_get_fs: + with mock.patch.object(device, "open"): + with mock.patch.object(device, "enable"): + mock_get_fs.return_value = "/mnt/flash" + # First call: file doesn't exist (False) + # Second call: file still doesn't exist (False) + mock_verify.side_effect = [False, False] + device.native_ssh.send_command.return_value = "Copy completed successfully" + + src = FileCopyModel( + download_url="http://server.example.com/file.bin", + checksum="abc123def456", + file_name="file.bin", + ) + + # Should raise FileTransferError + with pytest.raises(FileTransferError): + device.remote_file_copy(src) + + +# Tests for Task 10: Timeout and FTP Support + + +@pytest.mark.parametrize("timeout", [300, 600, 900, 1800]) +def test_property_timeout_application(timeout): + """Feature: arista-remote-file-copy, Property 24: Timeout Application. + + For any FileCopyModel with a specified timeout value, that timeout should be used + when sending commands to the device during transfer. + + Validates: Requirements 10.1, 10.3 + """ + from pyntc.utils.models import FileCopyModel + + device = EOSDevice("host", "user", "pass") + device.native_ssh = mock.MagicMock() + + with mock.patch.object(device, "verify_file") as mock_verify: + with mock.patch.object(device, "_get_file_system") as mock_get_fs: + with mock.patch.object(device, "open"): + with mock.patch.object(device, "enable"): + mock_get_fs.return_value = "/mnt/flash" + mock_verify.return_value = True + device.native_ssh.send_command.return_value = "Copy completed successfully" + + src = FileCopyModel( + download_url="http://server.example.com/file.bin", + checksum="abc123def456", + file_name="file.bin", + timeout=timeout, + ) + + device.remote_file_copy(src) + + # Verify send_command was called with the correct timeout + call_args = device.native_ssh.send_command.call_args + assert call_args[1]["read_timeout"] == timeout + + +def test_property_default_timeout_value(): + """Feature: arista-remote-file-copy, Property 25: Default Timeout Value. + + For any FileCopyModel without an explicit timeout, the default timeout should be 900 seconds. + + Validates: Requirements 10.2 + """ + from pyntc.utils.models import FileCopyModel + + src = FileCopyModel( + download_url="http://server.example.com/file.bin", + checksum="abc123def456", + file_name="file.bin", + ) + + # Verify default timeout is 900 + assert src.timeout == 900 + + +@pytest.mark.parametrize("ftp_passive", [True, False]) +def test_property_ftp_passive_mode_configuration(ftp_passive): + """Feature: arista-remote-file-copy, Property 30/31: FTP Passive Mode Configuration. + + For any FileCopyModel with ftp_passive flag, the FTP transfer should use the specified mode. + + Validates: Requirements 19.1, 19.2 + """ + from pyntc.utils.models import FileCopyModel + + src = FileCopyModel( + download_url="ftp://admin:password@ftp.example.com/images/eos.swi", + checksum="abc123def456", + file_name="eos.swi", + ftp_passive=ftp_passive, + ) + + # Verify ftp_passive is set correctly + assert src.ftp_passive == ftp_passive + + +def test_property_default_ftp_passive_mode(): + """Feature: arista-remote-file-copy, Property 32: Default FTP Passive Mode. + + For any FileCopyModel without an explicit ftp_passive parameter, the default should be True. + + Validates: Requirements 19.3 + """ + from pyntc.utils.models import FileCopyModel + + src = FileCopyModel( + download_url="ftp://admin:password@ftp.example.com/images/eos.swi", + checksum="abc123def456", + file_name="eos.swi", + ) + + # Verify default ftp_passive is True + assert src.ftp_passive is True + + +# Tests for Task 11: Error Handling and Logging + + +class TestRemoteFileCopyErrorHandling(unittest.TestCase): + """Tests for error handling in remote_file_copy.""" + + def test_invalid_src_type_raises_typeerror(self): + """Test that invalid src type raises TypeError.""" + device = EOSDevice("host", "user", "pass") + + with pytest.raises(TypeError) as exc_info: + device.remote_file_copy("not a FileCopyModel") + + assert "src must be an instance of FileCopyModel" in str(exc_info.value) + + @mock.patch.object(EOSDevice, "verify_file") + @mock.patch.object(EOSDevice, "enable") + @mock.patch.object(EOSDevice, "open") + @mock.patch.object(EOSDevice, "_get_file_system") + def test_transfer_failure_raises_filetransfererror(self, mock_get_fs, mock_open, mock_enable, mock_verify): + """Test that transfer failure raises FileTransferError.""" + from pyntc.utils.models import FileCopyModel + + mock_get_fs.return_value = "/mnt/flash" + mock_verify.side_effect = [False, False] # Post-transfer verification fails + + device = EOSDevice("host", "user", "pass") + device.native_ssh = mock.MagicMock() + device.native_ssh.send_command.return_value = "Copy completed successfully" + + src = FileCopyModel( + download_url="http://server.example.com/file.bin", + checksum="abc123def456", + file_name="file.bin", + ) + + with pytest.raises(FileTransferError): + device.remote_file_copy(src) + + @mock.patch.object(EOSDevice, "verify_file") + @mock.patch.object(EOSDevice, "enable") + @mock.patch.object(EOSDevice, "open") + @mock.patch.object(EOSDevice, "_get_file_system") + def test_logging_on_transfer_success(self, mock_get_fs, mock_open, mock_enable, mock_verify): + """Test that transfer success is logged.""" + from pyntc.utils.models import FileCopyModel + + mock_get_fs.return_value = "/mnt/flash" + mock_verify.return_value = True + + device = EOSDevice("host", "user", "pass") + device.native_ssh = mock.MagicMock() + device.native_ssh.send_command.return_value = "Copy completed successfully" + + src = FileCopyModel( + download_url="http://server.example.com/file.bin", + checksum="abc123def456", + file_name="file.bin", + ) + + with mock.patch("pyntc.devices.eos_device.log") as mock_log: + device.remote_file_copy(src) + + # Verify that info log was called for successful transfer + assert any("transferred and verified successfully" in str(call) for call in mock_log.info.call_args_list) + + +# Tests for Task 12: FileCopyModel Validation + + +@pytest.mark.parametrize("algorithm", ["md5", "sha256", "sha512"]) +def test_property_hashing_algorithm_validation(algorithm): + """Feature: arista-remote-file-copy, Property 10: Hashing Algorithm Validation. + + For any unsupported hashing algorithm, FileCopyModel initialization should raise a ValueError. + + Validates: Requirements 6.3, 17.1, 17.2 + """ + from pyntc.utils.models import FileCopyModel + + # Should not raise for supported algorithms + src = FileCopyModel( + download_url="http://server.example.com/file.bin", + checksum="abc123def456", + file_name="file.bin", + hashing_algorithm=algorithm, + ) + + assert src.hashing_algorithm == algorithm + + +def test_property_case_insensitive_algorithm_validation(): + """Feature: arista-remote-file-copy, Property 11: Case-Insensitive Algorithm Validation. + + For any hashing algorithm specified in different cases, the FileCopyModel should accept it as valid. + + Validates: Requirements 17.3 + """ + from pyntc.utils.models import FileCopyModel + + # Should accept case-insensitive algorithms + for algorithm in ["MD5", "md5", "Md5", "SHA256", "sha256", "Sha256"]: + src = FileCopyModel( + download_url="http://server.example.com/file.bin", + checksum="abc123def456", + file_name="file.bin", + hashing_algorithm=algorithm, + ) + + # Verify it was accepted (no exception raised) + assert src.hashing_algorithm.lower() in ["md5", "sha256"] + + +@pytest.mark.parametrize( + "url,expected_username,expected_token", + [ + ("scp://admin:password@server.com/path", "admin", "password"), + ("ftp://user:pass123@ftp.example.com/file", "user", "pass123"), + ], +) +def test_property_url_credential_extraction(url, expected_username, expected_token): + """Feature: arista-remote-file-copy, Property 12: URL Credential Extraction. + + For any URL containing embedded credentials, FileCopyModel should extract username and password. + + Validates: Requirements 3.1, 16.1, 16.2, 16.3, 16.4, 16.5, 16.6 + """ + from pyntc.utils.models import FileCopyModel + + src = FileCopyModel( + download_url=url, + checksum="abc123def456", + file_name="file.bin", + ) + + # Verify credentials were extracted + assert src.username == expected_username + assert src.token == expected_token + + +def test_property_explicit_credentials_override(): + """Feature: arista-remote-file-copy, Property 13: Explicit Credentials Override. + + For any FileCopyModel where both URL-embedded credentials and explicit fields are provided, + the explicit fields should take precedence. + + Validates: Requirements 3.2 + """ + from pyntc.utils.models import FileCopyModel + + src = FileCopyModel( + download_url="scp://url_user:url_pass@server.com/path", + checksum="abc123def456", + file_name="file.bin", + username="explicit_user", + token="explicit_pass", + ) + + # Verify explicit credentials take precedence + assert src.username == "explicit_user" + assert src.token == "explicit_pass" From a04d8b452927c2adceafa43bca0a3613f3e45a1e Mon Sep 17 00:00:00 2001 From: James Williams Date: Tue, 14 Apr 2026 11:53:38 -0500 Subject: [PATCH 4/9] Added remote_file_copy support for ASADevice (FTP, TFTP, SCP, HTTP, HTTPS) (#366) * Added remote_file_copy support for ASADevice (FTP, TFTP, SCP, HTTP, HTTPS). Co-authored-by: Claude Sonnet 4.6 --- changes/366.added | 1 + changes/366.fixed | 3 + pyntc/devices/asa_device.py | 178 ++++++- tests/integration/test_asa_device.py | 186 ++++++++ tests/unit/test_devices/test_asa_device.py | 518 ++++++++++++++++++++- 5 files changed, 881 insertions(+), 5 deletions(-) create mode 100644 changes/366.added create mode 100644 changes/366.fixed create mode 100644 tests/integration/test_asa_device.py diff --git a/changes/366.added b/changes/366.added new file mode 100644 index 00000000..e3d75291 --- /dev/null +++ b/changes/366.added @@ -0,0 +1 @@ +Added ``remote_file_copy``, ``check_file_exists``, ``get_remote_checksum``, and ``verify_file`` support for ``ASADevice`` (FTP, TFTP, SCP, HTTP, HTTPS). diff --git a/changes/366.fixed b/changes/366.fixed new file mode 100644 index 00000000..ccfec3bb --- /dev/null +++ b/changes/366.fixed @@ -0,0 +1,3 @@ +Fixed ``ASADevice._get_file_system`` to use ``re.search`` instead of ``re.match`` so the filesystem label is correctly parsed regardless of leading whitespace in ``dir`` output. +Fixed ``ASADevice._send_command`` to anchor the ``%`` error pattern to the start of a line (``^% ``) to prevent false-positive ``CommandError`` raises during file copy operations. +Fixed ``ASADevice.active_redundancy_states`` to include ``"disabled"`` so standalone (non-failover) units are correctly treated as active. diff --git a/pyntc/devices/asa_device.py b/pyntc/devices/asa_device.py index 6524148d..8babeb0a 100644 --- a/pyntc/devices/asa_device.py +++ b/pyntc/devices/asa_device.py @@ -5,7 +5,8 @@ import time from collections import Counter from ipaddress import IPv4Address, IPv4Interface, IPv6Address, IPv6Interface, ip_address -from typing import Dict, Iterable, List, Optional, Union +from typing import Any, Dict, Iterable, List, Optional, Union +from urllib.parse import urlparse from netmiko import ConnectHandler from netmiko.cisco import CiscoAsaFileTransfer, CiscoAsaSSH @@ -24,7 +25,9 @@ RebootTimeoutError, ) from pyntc.utils import get_structured_data +from pyntc.utils.models import FileCopyModel +ASA_HASHING_ALGORITHM_MAP = {"md5": "md5", "sha512": "sha-512", "sha-512": "sha-512"} RE_SHOW_FAILOVER_GROUPS = re.compile(r"Group\s+\d+\s+State:\s+(.+?)\s*$", re.M) RE_SHOW_FAILOVER_STATE = re.compile(r"(?:Primary|Secondary)\s+-\s+(.+?)\s*$", re.M) RE_SHOW_IP_ADDRESS = re.compile(r"^\S+\s+(\S+)\s+((?:\d+.){3}\d+)\s+((?:\d+.){3}\d+)", re.M) @@ -36,7 +39,7 @@ class ASADevice(BaseDevice): """Cisco ASA Device Implementation.""" vendor = "cisco" - active_redundancy_states = {None, "active"} + active_redundancy_states = {None, "active", "disabled"} # pylint: disable=too-many-arguments, too-many-positional-arguments def __init__(self, host: str, username: str, password: str, secret="", port=None, **kwargs): # nosec @@ -125,7 +128,7 @@ def _get_file_system(self): """ raw_data = self.show("dir") try: - file_system = re.match(r"\s*.*?(\S+:)", raw_data).group(1) + file_system = re.search(r"(\S+:)", raw_data).group(1) except AttributeError: # TODO: Get proper hostname @@ -249,7 +252,7 @@ def _send_command(self, command, expect_string=None): else: response = self.native.send_command(command, expect_string=expect_string) - if "% " in response or "Error:" in response: + if re.search(r"^% ", response, re.MULTILINE) or "Error:" in response: log.error("Host %s: Error in %s with response: %s", self.host, command, response) raise CommandError(command, response) @@ -379,6 +382,30 @@ def boot_options(self): log.debug("Host %s: the boot options are %s", self.host, {"sys": boot_image}) return {"sys": boot_image} + def check_file_exists(self, filename, **kwargs: Any): + """Check whether a file exists on the device. + + Args: + filename (str): The name of the file to check for on the device. + **kwargs: Optional keyword arguments. + + Keyword Args: + file_system (str): The file system to check. Defaults to ``_get_file_system()``. + + Returns: + (bool): True if the file exists, False otherwise. + """ + file_system = kwargs.get("file_system") or self._get_file_system() + cmd = f"dir {file_system}{filename}" + result = self.native.send_command(cmd, read_timeout=30) + + if re.search(r"No such file or directory|ERROR:", result, re.IGNORECASE): + log.debug("Host %s: File %s does not exist on %s.", self.host, filename, file_system) + return False + + log.debug("Host %s: File %s exists on %s.", self.host, filename, file_system) + return bool(re.search(re.escape(filename), result)) + def checkpoint(self, checkpoint_file): """ Create a checkpoint file of the current config. @@ -582,6 +609,45 @@ def file_copy_remote_exists(self, src, dest=None, file_system=None): log.debug("Host %s: File %s does not already exist on remote.", self.host, src) return False + def get_remote_checksum(self, filename, hashing_algorithm="md5", **kwargs: Any): + """Get the checksum of a file on the device. + + Args: + filename (str): The name of the file on the device. + hashing_algorithm (str): The hashing algorithm to use. Valid choices are ``"md5"`` and + ``"sha512"`` (default: ``"md5"``). + **kwargs: Optional keyword arguments. + + Keyword Args: + file_system (str): The file system where the file resides. Defaults to ``_get_file_system()``. + + Returns: + (str): The checksum of the file. + + Raises: + ValueError: If an unsupported hashing algorithm is provided. + CommandError: If the checksum cannot be parsed from the device output. + """ + asa_algorithm = ASA_HASHING_ALGORITHM_MAP.get(hashing_algorithm) + + if not asa_algorithm: + raise ValueError( + f"hashing_algorithm must be 'md5', 'sha512' or 'sha-512' for Cisco ASA devices, got '{hashing_algorithm}'." + ) + + file_system = kwargs.get("file_system") or self._get_file_system() + cmd = f"verify /{asa_algorithm} {file_system}{filename}" + result = self.native.send_command_timing(cmd, read_timeout=300) + + if match := re.search(r"=\s+(\S+)", result): + log.debug( + "Host %s: Remote checksum for %s using %s is %s.", self.host, filename, hashing_algorithm, match[1] + ) + return match[1] + + log.error("Host %s: Unable to parse checksum for %s from output: %s", self.host, filename, result) + raise CommandError(cmd, f"Unable to parse checksum for {filename}") + def install_os(self, image_name, **vendor_specifics): """ Install OS on device. @@ -930,6 +996,90 @@ def reboot_standby(self, acceptable_states: Optional[Iterable[str]] = None, time log.debug("Host %s: reboot standby with timeout %s.", self.host, timeout) + def remote_file_copy(self, src: FileCopyModel = None, dest=None, **kwargs: Any): + """Copy a file from a remote server to the device. + + Pulls the file specified by ``src`` from a remote server using the protocol in + ``src.download_url`` (FTP, TFTP, SCP, HTTP, or HTTPS) and saves it to the device + filesystem. The file is verified after transfer using the checksum in ``src``. + + SFTP is not supported on Cisco ASA devices. + + Args: + src (FileCopyModel): Specification of the source file including URL, checksum, and + credentials. + dest (str): Filename to use on the device. Defaults to ``src.file_name``. + **kwargs: Optional keyword arguments. + + Keyword Args: + file_system (str): Destination file system on the device (e.g. ``"disk0:"``). + Defaults to ``_get_file_system()``. + + Raises: + TypeError: If ``src`` is not a ``FileCopyModel`` instance. + FileTransferError: If the transfer fails or the file cannot be verified afterwards. + """ + if not isinstance(src, FileCopyModel): + raise TypeError("src must be an instance of FileCopyModel") + + file_system = kwargs.get("file_system") or self._get_file_system() + + if not dest: + dest = src.file_name + + if not self.verify_file(src.checksum, dest, hashing_algorithm=src.hashing_algorithm, file_system=file_system): + current_prompt = self.native.find_prompt() + prompt_answers = { + r"Password": src.token or "", + r"Source username": src.username or "", + r"yes/no|Are you sure you want to continue connecting": "yes", + r"(confirm|Address or name of remote host|Source filename|Destination filename)": "", + } + keys = list(prompt_answers.keys()) + [re.escape(current_prompt)] + expect_regex = f"({'|'.join(keys)})" + src_url = src.clean_url + + if not urlparse(src.clean_url).path.strip("/"): + src_url = f"{src.clean_url.rstrip('/')}/{dest}" + + command = f"copy {src_url} {file_system}{dest}" + + if src.vrf and src.scheme not in {"http", "https"}: + command = f"{command} vrf {src.vrf}" + + # Bypass _send_command — copy output may contain "% " patterns that are not failures + output = self.native.send_command(command, expect_string=expect_regex, read_timeout=src.timeout) + + while current_prompt not in output: + if re.search(r"bytes copied in", output, re.IGNORECASE): + log.info("Host %s: File %s transferred successfully.", self.host, src.file_name) + break + + if re.search(r"(Error|Invalid|Failed|Aborted|denied)", output, re.IGNORECASE): + log.error("Host %s: File transfer error for %s: %s", self.host, src.file_name, output) + raise FileTransferError + + for prompt, answer in prompt_answers.items(): + if re.search(prompt, output, re.IGNORECASE): + output = self.native.send_command( + answer, + expect_string=expect_regex, + read_timeout=src.timeout, + cmd_verify="Password" not in prompt, + ) + break + else: + log.error( + "Host %s: Unexpected output during file transfer of %s: %s", self.host, src.file_name, output + ) + raise FileTransferError + + if not self.verify_file( + src.checksum, dest, hashing_algorithm=src.hashing_algorithm, file_system=file_system + ): + log.error("Host %s: File %s could not be verified after transfer.", self.host, src.file_name) + raise FileTransferError + @property def redundancy_mode(self): """ @@ -1118,6 +1268,26 @@ def startup_config(self): """ return self.show("show startup-config") + def verify_file(self, checksum, filename, hashing_algorithm="md5", **kwargs: Any): + """Verify a file on the device by comparing checksums. + + Args: + checksum (str): The expected checksum of the file. + filename (str): The name of the file on the device. + hashing_algorithm (str): The hashing algorithm to use (default: ``"md5"``). + **kwargs: Optional keyword arguments passed through to ``check_file_exists`` and + ``compare_file_checksum``. + + Keyword Args: + file_system (str): The file system where the file resides. Defaults to ``_get_file_system()``. + + Returns: + (bool): True if the file exists and the checksum matches, False otherwise. + """ + return self.check_file_exists(filename, **kwargs) and self.compare_file_checksum( + checksum, filename, hashing_algorithm, **kwargs + ) + @property def uptime(self): """Get uptime from device. diff --git a/tests/integration/test_asa_device.py b/tests/integration/test_asa_device.py new file mode 100644 index 00000000..b72a21de --- /dev/null +++ b/tests/integration/test_asa_device.py @@ -0,0 +1,186 @@ +"""Integration tests for ASADevice.remote_file_copy. + +These tests connect to an actual Cisco ASA device in the lab and are run manually. +They are NOT part of the CI unit test suite. + +Usage (from project root): + export ASA_HOST= + export ASA_USER= + export ASA_PASS= + export ASA_SECRET= + export FTP_URL=ftp://:@/ + export TFTP_URL=tftp:/// + export SCP_URL=scp://:@:2222/ + export HTTP_URL=http://:@:8081/ + export HTTPS_URL=https://:@:8443/ + export FILE_CHECKSUM= + poetry run pytest tests/integration/test_asa_device.py -v + +Set only the protocol URL vars for the servers you have available; each +protocol test will skip automatically if its URL is not set. + +Environment variables: + ASA_HOST - IP address or hostname of the lab ASA + ASA_USER - SSH username + ASA_PASS - SSH password + ASA_SECRET - Enable password (can be same as ASA_PASS if not set) + FTP_URL - FTP URL of the file to transfer + TFTP_URL - TFTP URL of the file to transfer + SCP_URL - SCP URL of the file to transfer + HTTP_URL - HTTP URL of the file to transfer + HTTPS_URL - HTTPS URL of the file to transfer + FILE_NAME - Destination filename on the device (default: basename of URL path) + FILE_CHECKSUM - Expected sha512 checksum of the file (shared across all protocols) +""" + +import os +import posixpath + +import pytest + +from pyntc.devices import ASADevice +from pyntc.utils.models import FileCopyModel + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_PROTOCOL_URL_VARS = { + "ftp": "FTP_URL", + "tftp": "TFTP_URL", + "scp": "SCP_URL", + "http": "HTTP_URL", + "https": "HTTPS_URL", +} + + +def _make_model(url_env_var): + """Build a FileCopyModel from a per-protocol URL env var. + + Calls pytest.skip if the URL or FILE_CHECKSUM is not set. + """ + url = os.environ.get(url_env_var) + checksum = os.environ.get("FILE_CHECKSUM") + file_name = os.environ.get("FILE_NAME") or (posixpath.basename(url.split("?")[0]) if url else None) + + if not all([url, checksum, file_name]): + pytest.skip(f"{url_env_var} / FILE_CHECKSUM environment variables not set") + + return FileCopyModel( + download_url=url, + checksum=checksum, + file_name=file_name, + hashing_algorithm="sha512", + timeout=900, + ) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def device(): + """Connect to the lab ASA. Skips all tests if credentials are not set.""" + host = os.environ.get("ASA_HOST") + user = os.environ.get("ASA_USER") + password = os.environ.get("ASA_PASS") + secret = os.environ.get("ASA_SECRET", password) + + if not all([host, user, password]): + pytest.skip("ASA_HOST / ASA_USER / ASA_PASS environment variables not set") + + dev = ASADevice(host, user, password, secret=secret) + yield dev + dev.close() + + +@pytest.fixture(scope="module") +def any_file_copy_model(): + """Return a FileCopyModel using the first available protocol URL. + + Used by tests that only need a file reference (existence checks, checksum + verification) without caring about the transfer protocol. Skips if no + protocol URL and FILE_CHECKSUM are set. + """ + checksum = os.environ.get("FILE_CHECKSUM") + for env_var in _PROTOCOL_URL_VARS.values(): + url = os.environ.get(env_var) + if url and checksum: + file_name = os.environ.get("FILE_NAME") or posixpath.basename(url.split("?")[0]) + return FileCopyModel( + download_url=url, + checksum=checksum, + file_name=file_name, + hashing_algorithm="sha512", + timeout=900, + ) + pytest.skip("No protocol URL / FILE_CHECKSUM environment variables not set") + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +def test_device_connects(device): + """Verify the device is reachable and in enable mode.""" + assert device.is_active() + + +def test_check_file_exists_false(device, any_file_copy_model): + """Before the copy, the file should not exist (or this test is a no-op if it does).""" + result = device.check_file_exists(any_file_copy_model.file_name) + # We just verify the method runs without error; state depends on lab environment + assert isinstance(result, bool) + + +def test_get_remote_checksum_after_exists(device, any_file_copy_model): + """If the file already exists, verify get_remote_checksum returns a non-empty string.""" + if not device.check_file_exists(any_file_copy_model.file_name): + pytest.skip("File does not exist on device; run test_remote_file_copy_* first") + checksum = device.get_remote_checksum(any_file_copy_model.file_name, hashing_algorithm="sha512") + assert checksum and len(checksum) > 0 + + +def test_remote_file_copy_ftp(device): + """Transfer the file using FTP and verify it exists on the device.""" + model = _make_model("FTP_URL") + device.remote_file_copy(model) + assert device.check_file_exists(model.file_name) + + +def test_remote_file_copy_tftp(device): + """Transfer the file using TFTP and verify it exists on the device.""" + model = _make_model("TFTP_URL") + device.remote_file_copy(model) + assert device.check_file_exists(model.file_name) + + +def test_remote_file_copy_scp(device): + """Transfer the file using SCP and verify it exists on the device.""" + model = _make_model("SCP_URL") + device.remote_file_copy(model) + assert device.check_file_exists(model.file_name) + + +def test_remote_file_copy_http(device): + """Transfer the file using HTTP and verify it exists on the device.""" + model = _make_model("HTTP_URL") + device.remote_file_copy(model) + assert device.check_file_exists(model.file_name) + + +def test_remote_file_copy_https(device): + """Transfer the file using HTTPS and verify it exists on the device.""" + model = _make_model("HTTPS_URL") + device.remote_file_copy(model) + assert device.check_file_exists(model.file_name) + + +def test_verify_file_after_copy(device, any_file_copy_model): + """After a successful copy the file should verify cleanly.""" + if not device.check_file_exists(any_file_copy_model.file_name): + pytest.skip("File does not exist on device; run a copy test first") + assert device.verify_file(any_file_copy_model.checksum, any_file_copy_model.file_name, hashing_algorithm="sha512") diff --git a/tests/unit/test_devices/test_asa_device.py b/tests/unit/test_devices/test_asa_device.py index f89ffb4b..2a3fa3c8 100644 --- a/tests/unit/test_devices/test_asa_device.py +++ b/tests/unit/test_devices/test_asa_device.py @@ -6,6 +6,8 @@ from pyntc.devices import ASADevice from pyntc.devices import asa_device as asa_module +from pyntc.errors import FileTransferError +from pyntc.utils.models import FileCopyModel from .device_mocks.asa import send_command @@ -165,6 +167,7 @@ def test_enable_from_config(asa_device): def test_config(asa_device): command = "hostname DATA-CENTER-FW" + asa_device.native.send_command_timing.return_value = "" result = asa_device.config(command) assert result is None @@ -183,6 +186,7 @@ def test_bad_config(asa_device): def test_config_list(asa_device): commands = ["crypto key generate rsa modulus 2048", "aaa authentication ssh console LOCAL"] + asa_device.native.send_command_timing.return_value = "" asa_device.config(commands) for cmd in commands: @@ -279,11 +283,13 @@ def test_checkpoint(asa_device): def test_running_config(asa_device): + asa_device.native.send_command_timing.return_value = "interface eth1" expected = asa_device.show("show running config") assert asa_device.running_config == expected def test_starting_config(asa_device): + asa_device.native.send_command_timing.return_value = "interface eth1" expected = asa_device.show("show startup-config") assert asa_device.startup_config == expected @@ -631,8 +637,9 @@ def test_ip_protocol(mock_ip_address, ip, ip_version, asa_device): (FAILED, False), (COLD_STANDBY, False), (None, True), + ("disabled", True), ), - ids=(ACTIVE, "standby_ready", NEGOTIATION, FAILED, "cold_standby", "unsupported"), + ids=(ACTIVE, "standby_ready", NEGOTIATION, FAILED, "cold_standby", "unsupported", "disabled"), ) def test_is_active(mock_redundancy_state, asa_device, redundancy_state, expected): mock_redundancy_state.return_value = redundancy_state @@ -899,3 +906,512 @@ def test_vlan(mock_get_vlans, asa_device): def test_port_none(patch): device = ASADevice("host", "user", "pass", port=None) assert device.port == 22 + + +# --------------------------------------------------------------------------- +# check_file_exists tests +# --------------------------------------------------------------------------- + + +@mock.patch.object(ASADevice, "_get_file_system", return_value="disk0:") +def test_check_file_exists_returns_true(mock_fs, asa_device): + asa_device.native.send_command.return_value = " -rwx 94038 Apr 13 2026 14:25 asa.bin\n" + result = asa_device.check_file_exists("asa.bin") + assert result is True + asa_device.native.send_command.assert_called_with("dir disk0:asa.bin", read_timeout=30) + + +@mock.patch.object(ASADevice, "_get_file_system", return_value="disk0:") +def test_check_file_exists_returns_false(mock_fs, asa_device): + asa_device.native.send_command.return_value = "ERROR: disk0:/asa.bin: No such file or directory" + result = asa_device.check_file_exists("asa.bin") + assert result is False + + +@mock.patch.object(ASADevice, "_get_file_system") +def test_check_file_exists_uses_provided_file_system(mock_fs, asa_device): + asa_device.native.send_command.return_value = " -rwx 94038 Apr 13 2026 14:25 asa.bin\n" + result = asa_device.check_file_exists("asa.bin", file_system="flash:") + assert result is True + asa_device.native.send_command.assert_called_with("dir flash:asa.bin", read_timeout=30) + mock_fs.assert_not_called() + + +# --------------------------------------------------------------------------- +# get_remote_checksum tests +# --------------------------------------------------------------------------- + +MD5_CHECKSUM = "aabbccdd11223344aabbccdd11223344" +SHA512_CHECKSUM = "90368777ae062ae6989272db08fa6c624601f841da5825b8ff1faaccd2c98b19ea4ca5" + + +@mock.patch.object(ASADevice, "_get_file_system", return_value="disk0:") +def test_get_remote_checksum_md5(mock_fs, asa_device): + asa_device.native.send_command_timing.return_value = ( + f"!!!!!!!!!!!!!!!!!!!!!!!!Done!\nverify /MD5 (disk0:/asa.bin) = {MD5_CHECKSUM}" + ) + result = asa_device.get_remote_checksum("asa.bin") + assert result == MD5_CHECKSUM + asa_device.native.send_command_timing.assert_called_with("verify /md5 disk0:asa.bin", read_timeout=300) + + +@mock.patch.object(ASADevice, "_get_file_system", return_value="disk0:") +def test_get_remote_checksum_sha512(mock_fs, asa_device): + asa_device.native.send_command_timing.return_value = ( + f"!!!!!!!!!!!!!!!!!!!!!!!!Done!\nverify /SHA-512 (disk0:/asa.bin) = {SHA512_CHECKSUM}" + ) + result = asa_device.get_remote_checksum("asa.bin", hashing_algorithm="sha512") + assert result == SHA512_CHECKSUM + asa_device.native.send_command_timing.assert_called_with("verify /sha-512 disk0:asa.bin", read_timeout=300) + + +@mock.patch.object(ASADevice, "_get_file_system", return_value="disk0:") +def test_get_remote_checksum_uses_provided_file_system(mock_fs, asa_device): + asa_device.native.send_command_timing.return_value = ( + f"!!!!!!!!!!!!!!!!!!!!!!!!Done!\nverify /MD5 (flash:/asa.bin) = {MD5_CHECKSUM}" + ) + result = asa_device.get_remote_checksum("asa.bin", file_system="flash:") + assert result == MD5_CHECKSUM + asa_device.native.send_command_timing.assert_called_with("verify /md5 flash:asa.bin", read_timeout=300) + mock_fs.assert_not_called() + + +def test_get_remote_checksum_invalid_algorithm(asa_device): + with pytest.raises(ValueError, match="hashing_algorithm must be"): + asa_device.get_remote_checksum("asa.bin", hashing_algorithm="sha256") + + +# --------------------------------------------------------------------------- +# verify_file tests +# --------------------------------------------------------------------------- + + +@mock.patch.object(ASADevice, "compare_file_checksum", return_value=True) +@mock.patch.object(ASADevice, "check_file_exists", return_value=True) +def test_verify_file_returns_true(mock_exists, mock_checksum, asa_device): + result = asa_device.verify_file(MD5_CHECKSUM, "asa.bin") + assert result is True + mock_exists.assert_called_once_with("asa.bin") + mock_checksum.assert_called_once_with(MD5_CHECKSUM, "asa.bin", "md5") + + +@mock.patch.object(ASADevice, "check_file_exists", return_value=False) +def test_verify_file_returns_false_not_exists(mock_exists, asa_device): + result = asa_device.verify_file(MD5_CHECKSUM, "asa.bin") + assert result is False + mock_exists.assert_called_once_with("asa.bin") + + +@mock.patch.object(ASADevice, "compare_file_checksum", return_value=False) +@mock.patch.object(ASADevice, "check_file_exists", return_value=True) +def test_verify_file_returns_false_checksum_mismatch(mock_exists, mock_checksum, asa_device): + result = asa_device.verify_file("wrongchecksum", "asa.bin") + assert result is False + + +# --------------------------------------------------------------------------- +# remote_file_copy tests +# --------------------------------------------------------------------------- + +FILE_COPY_MODEL_FTP = FileCopyModel( + download_url="ftp://example-user:example-password@192.0.2.1/asa.bin", + checksum=SHA512_CHECKSUM, + file_name="asa.bin", + hashing_algorithm="sha512", + timeout=900, +) +FILE_COPY_MODEL_TFTP = FileCopyModel( + download_url="tftp://192.0.2.1/asa.bin", + checksum=SHA512_CHECKSUM, + file_name="asa.bin", + hashing_algorithm="sha512", + timeout=900, +) +FILE_COPY_MODEL_SCP = FileCopyModel( + download_url="scp://example-user:example-password@192.0.2.1/asa.bin", + checksum=SHA512_CHECKSUM, + file_name="asa.bin", + hashing_algorithm="sha512", + timeout=900, +) +FILE_COPY_MODEL_HTTP = FileCopyModel( + download_url="http://example-user:example-password@192.0.2.1/asa.bin", + checksum=SHA512_CHECKSUM, + file_name="asa.bin", + hashing_algorithm="sha512", + timeout=900, +) +FILE_COPY_MODEL_HTTPS = FileCopyModel( + download_url="https://example-user:example-password@192.0.2.1/asa.bin", + checksum=SHA512_CHECKSUM, + file_name="asa.bin", + hashing_algorithm="sha512", + timeout=900, +) + + +@mock.patch.object(ASADevice, "_get_file_system", return_value="disk0:") +@mock.patch.object(ASADevice, "verify_file") +def test_remote_file_copy_type_error(mock_verify, mock_fs, asa_device): + with pytest.raises(TypeError): + asa_device.remote_file_copy("not_a_file_copy_model") + + +@mock.patch.object(ASADevice, "_get_file_system", return_value="disk0:") +@mock.patch.object(ASADevice, "verify_file", return_value=True) +def test_remote_file_copy_already_exists(mock_verify, mock_fs, asa_device): + asa_device.remote_file_copy(FILE_COPY_MODEL_FTP) + # verify_file is called once (pre-check passes); post-check is skipped since no copy was needed + assert mock_verify.call_count == 1 + mock_verify.assert_any_call(SHA512_CHECKSUM, "asa.bin", hashing_algorithm="sha512", file_system="disk0:") + asa_device.native.send_command.assert_not_called() + + +@mock.patch.object(ASADevice, "_get_file_system", return_value="disk0:") +@mock.patch.object(ASADevice, "verify_file", side_effect=[False, True]) +def test_remote_file_copy_success(mock_verify, mock_fs, asa_device): + asa_device.native.find_prompt.return_value = "asa5512#" + asa_device.native.send_command.side_effect = [ + "Address or name of remote host [192.0.2.1]?", + "Source username [example-user]?", + "Source filename [asa.bin]?", + "Destination filename [asa.bin]?", + "94038 bytes copied in 0.90 secs", + ] + asa_device.remote_file_copy(FILE_COPY_MODEL_FTP) + assert mock_verify.call_count == 2 + asa_device.native.send_command.assert_any_call( + "copy ftp://192.0.2.1/asa.bin disk0:asa.bin", + expect_string=mock.ANY, + read_timeout=900, + ) + + +@mock.patch.object(ASADevice, "_get_file_system", return_value="disk0:") +@mock.patch.object(ASADevice, "verify_file", side_effect=[False, True]) +def test_remote_file_copy_no_dest_defaults_to_file_name(mock_verify, mock_fs, asa_device): + asa_device.native.find_prompt.return_value = "asa5512#" + asa_device.native.send_command.side_effect = [ + "94038 bytes copied in 0.90 secs", + ] + asa_device.remote_file_copy(FILE_COPY_MODEL_FTP) + asa_device.native.send_command.assert_any_call( + "copy ftp://192.0.2.1/asa.bin disk0:asa.bin", + expect_string=mock.ANY, + read_timeout=900, + ) + + +@mock.patch.object(ASADevice, "_get_file_system", return_value="disk0:") +@mock.patch.object(ASADevice, "verify_file", side_effect=[False, True]) +def test_remote_file_copy_uses_provided_file_system(mock_verify, mock_fs, asa_device): + asa_device.native.find_prompt.return_value = "asa5512#" + asa_device.native.send_command.side_effect = [ + "94038 bytes copied in 0.90 secs", + ] + asa_device.remote_file_copy(FILE_COPY_MODEL_FTP, file_system="flash:") + mock_fs.assert_not_called() + asa_device.native.send_command.assert_any_call( + "copy ftp://192.0.2.1/asa.bin flash:asa.bin", + expect_string=mock.ANY, + read_timeout=900, + ) + + +@mock.patch.object(ASADevice, "_get_file_system", return_value="disk0:") +@mock.patch.object(ASADevice, "verify_file", return_value=False) +def test_remote_file_copy_error_in_output(mock_verify, mock_fs, asa_device): + asa_device.native.find_prompt.return_value = "asa5512#" + asa_device.native.send_command.return_value = "%Error opening ftp://192.0.2.1/asa.bin (Timed out)" + with pytest.raises(FileTransferError): + asa_device.remote_file_copy(FILE_COPY_MODEL_FTP) + + +@mock.patch.object(ASADevice, "_get_file_system", return_value="disk0:") +@mock.patch.object(ASADevice, "verify_file", side_effect=[False, False]) +def test_remote_file_copy_verify_fails_after_copy(mock_verify, mock_fs, asa_device): + asa_device.native.find_prompt.return_value = "asa5512#" + asa_device.native.send_command.side_effect = [ + "94038 bytes copied in 0.90 secs", + ] + with pytest.raises(FileTransferError): + asa_device.remote_file_copy(FILE_COPY_MODEL_FTP) + assert mock_verify.call_count == 2 + + +@mock.patch.object(ASADevice, "_get_file_system", return_value="disk0:") +@mock.patch.object(ASADevice, "verify_file", return_value=False) +def test_remote_file_copy_unmatched_output_raises(mock_verify, mock_fs, asa_device): + """Unexpected output that matches no known prompt or success/error pattern raises FileTransferError.""" + asa_device.native.find_prompt.return_value = "asa5512#" + asa_device.native.send_command.return_value = "!!!!!!!!!! some unexpected banner line !!!!!!!!!!" + with pytest.raises(FileTransferError): + asa_device.remote_file_copy(FILE_COPY_MODEL_FTP) + + +@mock.patch.object(ASADevice, "_get_file_system", return_value="disk0:") +@mock.patch.object(ASADevice, "verify_file", side_effect=[False, True]) +def test_remote_file_copy_password_prompt_uses_cmd_verify_false(mock_verify, mock_fs, asa_device): + asa_device.native.find_prompt.return_value = "asa5512#" + asa_device.native.send_command.side_effect = [ + "Password:", + "94038 bytes copied in 0.90 secs", + ] + asa_device.remote_file_copy(FILE_COPY_MODEL_FTP) + # The password response must be sent with cmd_verify=False + asa_device.native.send_command.assert_any_call( + FILE_COPY_MODEL_FTP.token, + expect_string=mock.ANY, + read_timeout=900, + cmd_verify=False, + ) + + +@mock.patch.object(ASADevice, "_get_file_system", return_value="disk0:") +@mock.patch.object(ASADevice, "verify_file", side_effect=[False, True]) +def test_remote_file_copy_confirm_prompt_uses_cmd_verify_true(mock_verify, mock_fs, asa_device): + asa_device.native.find_prompt.return_value = "asa5512#" + asa_device.native.send_command.side_effect = [ + "Address or name of remote host [192.0.2.1]?", + "94038 bytes copied in 0.90 secs", + ] + asa_device.remote_file_copy(FILE_COPY_MODEL_FTP) + asa_device.native.send_command.assert_any_call( + "", + expect_string=mock.ANY, + read_timeout=900, + cmd_verify=True, + ) + + +@mock.patch.object(ASADevice, "_get_file_system", return_value="disk0:") +@mock.patch.object(ASADevice, "verify_file", side_effect=[False, True]) +def test_remote_file_copy_clean_url_used_in_command(mock_verify, mock_fs, asa_device): + """Credentials must be stripped from the copy command (clean_url used, not download_url).""" + asa_device.native.find_prompt.return_value = "asa5512#" + asa_device.native.send_command.side_effect = [ + "94038 bytes copied in 0.90 secs", + ] + asa_device.remote_file_copy(FILE_COPY_MODEL_FTP) + # clean_url strips credentials; raw download_url should NOT appear in the command + call_args = asa_device.native.send_command.call_args_list[0][0][0] + assert "example-user:example-password" not in call_args + assert "ftp://192.0.2.1/asa.bin" in call_args + + +# --------------------------------------------------------------------------- +# remote_file_copy TFTP tests +# --------------------------------------------------------------------------- + + +@mock.patch.object(ASADevice, "_get_file_system", return_value="disk0:") +@mock.patch.object(ASADevice, "verify_file", side_effect=[False, True]) +def test_remote_file_copy_tftp_success(mock_verify, mock_fs, asa_device): + asa_device.native.find_prompt.return_value = "asa5512#" + asa_device.native.send_command.side_effect = [ + "94038 bytes copied in 0.90 secs", + ] + asa_device.remote_file_copy(FILE_COPY_MODEL_TFTP) + assert mock_verify.call_count == 2 + asa_device.native.send_command.assert_any_call( + "copy tftp://192.0.2.1/asa.bin disk0:asa.bin", + expect_string=mock.ANY, + read_timeout=900, + ) + + +@mock.patch.object(ASADevice, "_get_file_system", return_value="disk0:") +@mock.patch.object(ASADevice, "verify_file", side_effect=[False, True]) +def test_remote_file_copy_tftp_interactive_prompts(mock_verify, mock_fs, asa_device): + """TFTP has no credentials; confirmation prompts are answered with empty string.""" + asa_device.native.find_prompt.return_value = "asa5512#" + asa_device.native.send_command.side_effect = [ + "Address or name of remote host [192.0.2.1]?", + "Source filename [asa.bin]?", + "Destination filename [asa.bin]?", + "94038 bytes copied in 0.90 secs", + ] + asa_device.remote_file_copy(FILE_COPY_MODEL_TFTP) + asa_device.native.send_command.assert_any_call( + "", + expect_string=mock.ANY, + read_timeout=900, + cmd_verify=True, + ) + + +# --------------------------------------------------------------------------- +# remote_file_copy SCP tests +# --------------------------------------------------------------------------- + + +@mock.patch.object(ASADevice, "_get_file_system", return_value="disk0:") +@mock.patch.object(ASADevice, "verify_file", side_effect=[False, True]) +def test_remote_file_copy_scp_success(mock_verify, mock_fs, asa_device): + asa_device.native.find_prompt.return_value = "asa5512#" + asa_device.native.send_command.side_effect = [ + "94038 bytes copied in 0.90 secs", + ] + asa_device.remote_file_copy(FILE_COPY_MODEL_SCP) + assert mock_verify.call_count == 2 + asa_device.native.send_command.assert_any_call( + "copy scp://192.0.2.1/asa.bin disk0:asa.bin", + expect_string=mock.ANY, + read_timeout=900, + ) + + +@mock.patch.object(ASADevice, "_get_file_system", return_value="disk0:") +@mock.patch.object(ASADevice, "verify_file", side_effect=[False, True]) +def test_remote_file_copy_scp_ssh_host_key_prompt(mock_verify, mock_fs, asa_device): + """SCP SSH host-key verification prompt is answered with 'yes' and cmd_verify=True.""" + asa_device.native.find_prompt.return_value = "asa5512#" + asa_device.native.send_command.side_effect = [ + "Are you sure you want to continue connecting (yes/no)?", + "94038 bytes copied in 0.90 secs", + ] + asa_device.remote_file_copy(FILE_COPY_MODEL_SCP) + asa_device.native.send_command.assert_any_call( + "yes", + expect_string=mock.ANY, + read_timeout=900, + cmd_verify=True, + ) + + +@mock.patch.object(ASADevice, "_get_file_system", return_value="disk0:") +@mock.patch.object(ASADevice, "verify_file", side_effect=[False, True]) +def test_remote_file_copy_scp_password_prompt(mock_verify, mock_fs, asa_device): + """SCP password prompt sends token with cmd_verify=False.""" + asa_device.native.find_prompt.return_value = "asa5512#" + asa_device.native.send_command.side_effect = [ + "Password:", + "94038 bytes copied in 0.90 secs", + ] + asa_device.remote_file_copy(FILE_COPY_MODEL_SCP) + asa_device.native.send_command.assert_any_call( + FILE_COPY_MODEL_SCP.token, + expect_string=mock.ANY, + read_timeout=900, + cmd_verify=False, + ) + + +@mock.patch.object(ASADevice, "_get_file_system", return_value="disk0:") +@mock.patch.object(ASADevice, "verify_file", side_effect=[False, True]) +def test_remote_file_copy_scp_clean_url_used_in_command(mock_verify, mock_fs, asa_device): + """Credentials must be stripped from the SCP copy command.""" + asa_device.native.find_prompt.return_value = "asa5512#" + asa_device.native.send_command.side_effect = [ + "94038 bytes copied in 0.90 secs", + ] + asa_device.remote_file_copy(FILE_COPY_MODEL_SCP) + call_args = asa_device.native.send_command.call_args_list[0][0][0] + assert "example-user:example-password" not in call_args + assert "scp://192.0.2.1/asa.bin" in call_args + + +# --------------------------------------------------------------------------- +# remote_file_copy HTTP tests +# --------------------------------------------------------------------------- + + +@mock.patch.object(ASADevice, "_get_file_system", return_value="disk0:") +@mock.patch.object(ASADevice, "verify_file", side_effect=[False, True]) +def test_remote_file_copy_http_success(mock_verify, mock_fs, asa_device): + asa_device.native.find_prompt.return_value = "asa5512#" + asa_device.native.send_command.side_effect = [ + "94038 bytes copied in 0.90 secs", + ] + asa_device.remote_file_copy(FILE_COPY_MODEL_HTTP) + assert mock_verify.call_count == 2 + asa_device.native.send_command.assert_any_call( + "copy http://192.0.2.1/asa.bin disk0:asa.bin", + expect_string=mock.ANY, + read_timeout=900, + ) + + +@mock.patch.object(ASADevice, "_get_file_system", return_value="disk0:") +@mock.patch.object(ASADevice, "verify_file", side_effect=[False, True]) +def test_remote_file_copy_http_password_prompt(mock_verify, mock_fs, asa_device): + """HTTP password prompt sends token with cmd_verify=False.""" + asa_device.native.find_prompt.return_value = "asa5512#" + asa_device.native.send_command.side_effect = [ + "Password:", + "94038 bytes copied in 0.90 secs", + ] + asa_device.remote_file_copy(FILE_COPY_MODEL_HTTP) + asa_device.native.send_command.assert_any_call( + FILE_COPY_MODEL_HTTP.token, + expect_string=mock.ANY, + read_timeout=900, + cmd_verify=False, + ) + + +@mock.patch.object(ASADevice, "_get_file_system", return_value="disk0:") +@mock.patch.object(ASADevice, "verify_file", side_effect=[False, True]) +def test_remote_file_copy_http_clean_url_used_in_command(mock_verify, mock_fs, asa_device): + """Credentials must be stripped from the HTTP copy command.""" + asa_device.native.find_prompt.return_value = "asa5512#" + asa_device.native.send_command.side_effect = [ + "94038 bytes copied in 0.90 secs", + ] + asa_device.remote_file_copy(FILE_COPY_MODEL_HTTP) + call_args = asa_device.native.send_command.call_args_list[0][0][0] + assert "example-user:example-password" not in call_args + assert "http://192.0.2.1/asa.bin" in call_args + + +# --------------------------------------------------------------------------- +# remote_file_copy HTTPS tests +# --------------------------------------------------------------------------- + + +@mock.patch.object(ASADevice, "_get_file_system", return_value="disk0:") +@mock.patch.object(ASADevice, "verify_file", side_effect=[False, True]) +def test_remote_file_copy_https_success(mock_verify, mock_fs, asa_device): + asa_device.native.find_prompt.return_value = "asa5512#" + asa_device.native.send_command.side_effect = [ + "94038 bytes copied in 0.90 secs", + ] + asa_device.remote_file_copy(FILE_COPY_MODEL_HTTPS) + assert mock_verify.call_count == 2 + asa_device.native.send_command.assert_any_call( + "copy https://192.0.2.1/asa.bin disk0:asa.bin", + expect_string=mock.ANY, + read_timeout=900, + ) + + +@mock.patch.object(ASADevice, "_get_file_system", return_value="disk0:") +@mock.patch.object(ASADevice, "verify_file", side_effect=[False, True]) +def test_remote_file_copy_https_password_prompt(mock_verify, mock_fs, asa_device): + """HTTPS password prompt sends token with cmd_verify=False.""" + asa_device.native.find_prompt.return_value = "asa5512#" + asa_device.native.send_command.side_effect = [ + "Password:", + "94038 bytes copied in 0.90 secs", + ] + asa_device.remote_file_copy(FILE_COPY_MODEL_HTTPS) + asa_device.native.send_command.assert_any_call( + FILE_COPY_MODEL_HTTPS.token, + expect_string=mock.ANY, + read_timeout=900, + cmd_verify=False, + ) + + +@mock.patch.object(ASADevice, "_get_file_system", return_value="disk0:") +@mock.patch.object(ASADevice, "verify_file", side_effect=[False, True]) +def test_remote_file_copy_https_clean_url_used_in_command(mock_verify, mock_fs, asa_device): + """Credentials must be stripped from the HTTPS copy command.""" + asa_device.native.find_prompt.return_value = "asa5512#" + asa_device.native.send_command.side_effect = [ + "94038 bytes copied in 0.90 secs", + ] + asa_device.remote_file_copy(FILE_COPY_MODEL_HTTPS) + call_args = asa_device.native.send_command.call_args_list[0][0][0] + assert "example-user:example-password" not in call_args + assert "https://192.0.2.1/asa.bin" in call_args From 4a9bec4708263d59a3bd5af232c436ffd14419f4 Mon Sep 17 00:00:00 2001 From: James Williams Date: Tue, 14 Apr 2026 13:27:05 -0500 Subject: [PATCH 5/9] Updates to the EOS driver based on PR comments from #365 (#368) * Updates to the EOS driver based on PR comments from #365 Co-authored-by: Claude Opus 4.6 (1M context) --- changes/368.changed | 8 + changes/368.housekeeping | 3 + pyntc/devices/eos_device.py | 147 ++-- pyntc/utils/models.py | 20 +- tests/integration/test_eos_device.py | 193 +++++ tests/unit/conftest.py | 44 +- tests/unit/test_devices/test_eos_device.py | 960 +++++---------------- 7 files changed, 537 insertions(+), 838 deletions(-) create mode 100644 changes/368.changed create mode 100644 changes/368.housekeeping create mode 100644 tests/integration/test_eos_device.py diff --git a/changes/368.changed b/changes/368.changed new file mode 100644 index 00000000..494cf9b1 --- /dev/null +++ b/changes/368.changed @@ -0,0 +1,8 @@ +Improved EOS remote file copy to validate scheme and query strings before connecting, use `clean_url` to prevent credential leakage, and simplify credential routing. +Changed copy command builders to include the source file path in the URL and use `flash:` as the destination, matching EOS CLI conventions. +Fixed `_uptime_to_string` to use integer division, preventing `ValueError` on format specifiers. +Fixed `check_file_exists` and `get_remote_checksum` to open the SSH connection before use, preventing `AttributeError` when called standalone. +Fixed password-prompt handling in `remote_file_copy` to wait for the transfer to complete before proceeding to verification. +Simplified checksum parsing in `get_remote_checksum` to use string splitting instead of regex. +Changed `verify_file` to return early when file does not exist and use case-insensitive checksum comparison. +Removed `include_username` parameter from `remote_file_copy` in favor of automatic credential routing based on scheme and username presence. diff --git a/changes/368.housekeeping b/changes/368.housekeeping new file mode 100644 index 00000000..406ca955 --- /dev/null +++ b/changes/368.housekeeping @@ -0,0 +1,3 @@ +Converted EOS remote file copy tests from hypothesis/pytest standalone functions to unittest TestCase with `self.assertRaises` and `subTest` for consistency with the rest of the codebase. +Removed duplicate test class `TestRemoteFileCopyCommandExecution` and consolidated into `TestRemoteFileCopy`. +Added integration tests for EOS device connectivity and remote file copy across FTP, TFTP, SCP, HTTP, HTTPS, and SFTP protocols. diff --git a/pyntc/devices/eos_device.py b/pyntc/devices/eos_device.py index 2727eec3..1c3e74ab 100644 --- a/pyntc/devices/eos_device.py +++ b/pyntc/devices/eos_device.py @@ -3,7 +3,6 @@ import os import re import time -from urllib.parse import urlparse from netmiko import ConnectHandler, FileTransfer from pyeapi import connect as eos_connect @@ -26,6 +25,8 @@ from pyntc.utils import convert_list_by_key from pyntc.utils.models import FileCopyModel +EOS_SUPPORTED_HASHING_ALGORITHMS = {"md5", "sha1", "sha256", "sha512"} # Subset of HASHING_ALGORITHMS for EOS verify +EOS_SUPPORTED_SCHEMES = {"http", "https", "scp", "ftp", "sftp", "tftp"} BASIC_FACTS_KM = {"model": "modelName", "os_version": "internalVersion", "serial_number": "serialNumber"} INTERFACES_KM = { "speed": "bandwidth", @@ -135,13 +136,13 @@ def _parse_response(self, response, raw_text): return list(x["result"] for x in response) def _uptime_to_string(self, uptime): - days = uptime / (24 * 60 * 60) + days = uptime // (24 * 60 * 60) uptime = uptime % (24 * 60 * 60) - hours = uptime / (60 * 60) + hours = uptime // (60 * 60) uptime = uptime % (60 * 60) - mins = uptime / 60 + mins = uptime // 60 uptime = uptime % 60 seconds = uptime @@ -440,6 +441,7 @@ def check_file_exists(self, filename, file_system=None): """ exists = False + self.open() file_system = file_system or self._get_file_system() command = f"dir {file_system}/{filename}" result = self.native_ssh.send_command(command, read_timeout=30) @@ -481,6 +483,13 @@ def get_remote_checksum(self, filename, hashing_algorithm="md5", **kwargs): Raises: CommandError: If the verify command fails (but not if file doesn't exist). """ + if hashing_algorithm.lower() not in EOS_SUPPORTED_HASHING_ALGORITHMS: + raise ValueError( + f"Unsupported hashing algorithm '{hashing_algorithm}' for EOS. " + f"Supported algorithms: {sorted(EOS_SUPPORTED_HASHING_ALGORITHMS)}" + ) + + self.open() file_system = kwargs.get("file_system") if file_system is None: file_system = self._get_file_system() @@ -511,11 +520,11 @@ def get_remote_checksum(self, filename, hashing_algorithm="md5", **kwargs): # Parse the checksum from the output # Expected format: verify /sha512 (flash:nautobot.png) = - match = re.search(r"=\s*([a-fA-F0-9]+)", result) - if match: - remote_checksum = match.group(1).lower() - log.debug("Host %s: Remote checksum for %s: %s", self.host, filename, remote_checksum) - return remote_checksum + if "=" in result: + remote_checksum = result.split("=")[-1].strip().lower() + if remote_checksum: + log.debug("Host %s: Remote checksum for %s: %s", self.host, filename, remote_checksum) + return remote_checksum log.error("Host %s: Could not parse checksum from verify output: %s", self.host, result) raise CommandError(command, f"Could not parse checksum from verify output: {result}") @@ -524,123 +533,98 @@ def get_remote_checksum(self, filename, hashing_algorithm="md5", **kwargs): log.error("Host %s: Error getting remote checksum: %s", self.host, str(e)) raise CommandError(command, f"Error getting remote checksum: {str(e)}") - def _build_url_copy_command_simple(self, src, file_system): + @staticmethod + def _netloc(src: FileCopyModel) -> str: + """Return host:port or just host from a FileCopyModel.""" + return f"{src.hostname}:{src.port}" if src.port else src.hostname + + @staticmethod + def _source_path(src: FileCopyModel, dest: str) -> str: + """Return the file path from the URL, falling back to dest if empty.""" + return src.path if src.path and src.path != "/" else f"/{dest}" + + def _build_url_copy_command_simple(self, src, file_system, dest): """Build copy command for simple URL-based transfers (TFTP, HTTP, HTTPS without credentials).""" - return f"copy {src.download_url} {file_system}", False + netloc = self._netloc(src) + path = self._source_path(src, dest) + return f"copy {src.scheme}://{netloc}{path} {file_system}", False - def _build_url_copy_command_with_creds(self, src, file_system): + def _build_url_copy_command_with_creds(self, src, file_system, dest): """Build copy command for URL-based transfers with credentials (HTTP/HTTPS/SCP/FTP/SFTP).""" - parsed = urlparse(src.download_url) - hostname = parsed.hostname - path = parsed.path - - # Determine port based on scheme - if parsed.port: - port = parsed.port - elif src.scheme == "https": - port = "443" - elif src.scheme in ["http"]: - port = "80" - else: - port = "" - - port_str = f":{port}" if port else "" + netloc = self._netloc(src) + path = self._source_path(src, dest) - # For HTTP/HTTPS, include both username and token - if src.scheme in ["http", "https"]: - command = f"copy {src.scheme}://{src.username}:{src.token}@{hostname}{port_str}{path} {file_system}" + if src.scheme in ("http", "https"): + command = f"copy {src.scheme}://{src.username}:{src.token}@{netloc}{path} {file_system}" detect_prompt = False - # For SCP/FTP/SFTP, include only username (password via prompt) else: - command = f"copy {src.scheme}://{src.username}@{hostname}{port_str}{path} {file_system}" + # SCP/FTP/SFTP — password provided at the interactive prompt + command = f"copy {src.scheme}://{src.username}@{netloc}{path} {file_system}" detect_prompt = True return command, detect_prompt - def remote_file_copy(self, src: FileCopyModel, dest=None, file_system=None, include_username=False, **kwargs): + def _check_copy_output_for_errors(self, output): + """Raise FileTransferError if copy command output contains error indicators.""" + if any(error in output.lower() for error in ["error", "invalid", "failed"]): + log.error("Host %s: Error detected in copy command output: %s", self.host, output) + raise FileTransferError(f"Error detected in copy command output: {output}") + + def remote_file_copy(self, src: FileCopyModel, dest: str | None = None, file_system: str | None = None, **kwargs): """Copy a file from remote source to device. Args: src (FileCopyModel): The source file model with transfer parameters. dest (str): Destination filename (defaults to src.file_name). file_system (str): Device filesystem (auto-detected if not provided). - include_username (bool): Whether to include username in the copy command. Defaults to False. **kwargs (Any): Passible parameters such as file_system. Raises: TypeError: If src is not a FileCopyModel. + ValueError: If the URL scheme is unsupported or URL contains query strings. FileTransferError: If transfer or verification fails. FileSystemNotFoundError: If filesystem cannot be determined. """ - # Validate input if not isinstance(src, FileCopyModel): raise TypeError("src must be an instance of FileCopyModel") - # Determine file system + if src.scheme not in EOS_SUPPORTED_SCHEMES: + raise ValueError(f"Unsupported scheme: {src.scheme}") + + # EOS CLI cannot handle '?' in URLs + if "?" in src.clean_url: + raise ValueError(f"URLs with query strings are not supported on EOS: {src.download_url}") + if file_system is None: file_system = self._get_file_system() - # Determine destination if dest is None: dest = src.file_name log.debug("Host %s: Starting remote file copy for %s to %s/%s", self.host, src.file_name, file_system, dest) - # Open SSH connection and enable self.open() self.enable() - # Validate scheme - supported_schemes = ["http", "https", "scp", "ftp", "sftp", "tftp"] - if src.scheme not in supported_schemes: - raise ValueError(f"Unsupported scheme: {src.scheme}") - - # Build command based on scheme and credentials - command_builders = { - ("tftp", False): lambda: self._build_url_copy_command_simple(src, file_system), - ("http", False): lambda: self._build_url_copy_command_simple(src, file_system), - ("https", False): lambda: self._build_url_copy_command_simple(src, file_system), - ("http", True): lambda: self._build_url_copy_command_with_creds(src, file_system), - ("https", True): lambda: self._build_url_copy_command_with_creds(src, file_system), - ("scp", False): lambda: self._build_url_copy_command_with_creds(src, file_system), - ("scp", True): lambda: self._build_url_copy_command_with_creds(src, file_system), - ("ftp", False): lambda: self._build_url_copy_command_with_creds(src, file_system), - ("ftp", True): lambda: self._build_url_copy_command_with_creds(src, file_system), - ("sftp", False): lambda: self._build_url_copy_command_with_creds(src, file_system), - ("sftp", True): lambda: self._build_url_copy_command_with_creds(src, file_system), - } - - builder_key = (src.scheme, include_username and src.username is not None) - if builder_key not in command_builders: - raise ValueError(f"Unable to construct copy command for scheme {src.scheme} with provided credentials") - - command, detect_prompt = command_builders[builder_key]() + if src.scheme == "tftp" or src.username is None: + command, detect_prompt = self._build_url_copy_command_simple(src, file_system, dest) + else: + command, detect_prompt = self._build_url_copy_command_with_creds(src, file_system, dest) log.debug("Host %s: Preparing copy command for %s", self.host, src.scheme) - # Execute copy command if detect_prompt and src.token: - # Use send_command_timing for interactive password prompt output = self.native_ssh.send_command_timing(command, read_timeout=src.timeout, cmd_verify=False) log.debug("Host %s: Copy command (with timing) output: %s", self.host, output) if "password:" in output.lower(): - self.native_ssh.write_channel(src.token + "\n") - # Read the response after sending password - output += self.native_ssh.read_channel() + output = self.native_ssh.send_command_timing(src.token, read_timeout=src.timeout, cmd_verify=False) log.debug("Host %s: Output after password entry: %s", self.host, output) - elif any(error in output.lower() for error in ["error", "invalid", "failed"]): - log.error("Host %s: Error detected in copy command output: %s", self.host, output) - raise FileTransferError(f"Error detected in copy command output: {output}") else: - # Use regular send_command for non-interactive transfers output = self.native_ssh.send_command(command, read_timeout=src.timeout) log.debug("Host %s: Copy command output: %s", self.host, output) - if any(error in output.lower() for error in ["error", "invalid", "failed"]): - log.error("Host %s: Error detected in copy command output: %s", self.host, output) - raise FileTransferError(f"Error detected in copy command output: {output}") + self._check_copy_output_for_errors(output) - # Verify transfer success verification_result = self.verify_file( src.checksum, dest, hashing_algorithm=src.hashing_algorithm, file_system=file_system ) @@ -676,11 +660,12 @@ def verify_file(self, checksum, filename, hashing_algorithm="md5", **kwargs): Returns: (bool): True if the file is verified successfully, False otherwise. """ - exists = self.check_file_exists(filename, **kwargs) - device_checksum = ( - self.get_remote_checksum(filename, hashing_algorithm=hashing_algorithm, **kwargs) if exists else None - ) - if checksum == device_checksum: + if not self.check_file_exists(filename, **kwargs): + log.debug("Host %s: File %s not found on device", self.host, filename) + return False + + device_checksum = self.get_remote_checksum(filename, hashing_algorithm=hashing_algorithm, **kwargs) + if checksum.lower() == device_checksum.lower(): log.debug("Host %s: Checksum verification successful for file %s", self.host, filename) return True diff --git a/pyntc/utils/models.py b/pyntc/utils/models.py index 3d047a81..18c55e31 100644 --- a/pyntc/utils/models.py +++ b/pyntc/utils/models.py @@ -36,17 +36,18 @@ class FileCopyModel: vrf: Optional[str] = None ftp_passive: bool = True - # This field is calculated, so we don't pass it in the constructor + # Computed fields derived from download_url — not passed to the constructor clean_url: str = field(init=False) scheme: str = field(init=False) + hostname: str = field(init=False) + port: Optional[int] = field(init=False) + path: str = field(init=False) def __post_init__(self): """Validate the input and prepare the clean URL after initialization.""" - # 1. Validate the hashing algorithm choice if self.hashing_algorithm.lower() not in HASHING_ALGORITHMS: raise ValueError(f"Unsupported algorithm. Choose from: {HASHING_ALGORITHMS}") - # Parse the url to extract components parsed = urlparse(self.download_url) # Extract username/password from URL if not already provided as arguments @@ -55,13 +56,16 @@ def __post_init__(self): if parsed.password and not self.token: self.token = parsed.password - # 3. Create the 'clean_url' (URL without the credentials) - # This is what you actually send to the device if using ip http client - port = f":{parsed.port}" if parsed.port else "" - self.clean_url = f"{parsed.scheme}://{parsed.hostname}{port}{parsed.path}" + # Store parsed URL components self.scheme = parsed.scheme + self.hostname = parsed.hostname + self.port = parsed.port + self.path = parsed.path + + # Create the 'clean_url' (URL without credentials) + port_str = f":{parsed.port}" if parsed.port else "" + self.clean_url = f"{parsed.scheme}://{parsed.hostname}{port_str}{parsed.path}" - # Handle query params if they exist (though we're avoiding '?' for Cisco) if parsed.query: self.clean_url += f"?{parsed.query}" diff --git a/tests/integration/test_eos_device.py b/tests/integration/test_eos_device.py new file mode 100644 index 00000000..70547241 --- /dev/null +++ b/tests/integration/test_eos_device.py @@ -0,0 +1,193 @@ +"""Integration tests for EOSDevice.remote_file_copy. + +These tests connect to an actual Arista EOS device in the lab and are run manually. +They are NOT part of the CI unit test suite. + +Usage (from project root): + export EOS_HOST= + export EOS_USER= + export EOS_PASS= + export FTP_URL=ftp://:@/ + export TFTP_URL=tftp:/// + export SCP_URL=scp://:@/ + export HTTP_URL=http://:@:8081/ + export HTTPS_URL=https://:@:8443/ + export SFTP_URL=sftp://:@/ + export FILE_CHECKSUM= + poetry run pytest tests/integration/test_eos_device.py -v + +Set only the protocol URL vars for the servers you have available; each +protocol test will skip automatically if its URL is not set. + +Environment variables: + EOS_HOST - IP address or hostname of the lab EOS device + EOS_USER - SSH / eAPI username + EOS_PASS - SSH / eAPI password + FTP_URL - FTP URL of the file to transfer + TFTP_URL - TFTP URL of the file to transfer + SCP_URL - SCP URL of the file to transfer + HTTP_URL - HTTP URL of the file to transfer + HTTPS_URL - HTTPS URL of the file to transfer + SFTP_URL - SFTP URL of the file to transfer + FILE_NAME - Destination filename on the device (default: basename of URL path) + FILE_CHECKSUM - Expected sha512 checksum of the file (shared across all protocols) +""" + +import os +import posixpath + +import pytest + +from pyntc.devices import EOSDevice +from pyntc.utils.models import FileCopyModel + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_PROTOCOL_URL_VARS = { + "ftp": "FTP_URL", + "tftp": "TFTP_URL", + "scp": "SCP_URL", + "http": "HTTP_URL", + "https": "HTTPS_URL", + "sftp": "SFTP_URL", +} + + +def _make_model(url_env_var): + """Build a FileCopyModel from a per-protocol URL env var. + + Calls pytest.skip if the URL or FILE_CHECKSUM is not set. + """ + url = os.environ.get(url_env_var) + checksum = os.environ.get("FILE_CHECKSUM") + file_name = os.environ.get("FILE_NAME") or (posixpath.basename(url.split("?")[0]) if url else None) + + if not all([url, checksum, file_name]): + pytest.skip(f"{url_env_var} / FILE_CHECKSUM environment variables not set") + + return FileCopyModel( + download_url=url, + checksum=checksum, + file_name=file_name, + hashing_algorithm="sha512", + timeout=900, + ) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def device(): + """Connect to the lab EOS device. Skips all tests if credentials are not set.""" + host = os.environ.get("EOS_HOST") + user = os.environ.get("EOS_USER") + password = os.environ.get("EOS_PASS") + + if not all([host, user, password]): + pytest.skip("EOS_HOST / EOS_USER / EOS_PASS environment variables not set") + + dev = EOSDevice(host, user, password) + yield dev + dev.close() + + +@pytest.fixture(scope="module") +def any_file_copy_model(): + """Return a FileCopyModel using the first available protocol URL. + + Used by tests that only need a file reference (existence checks, checksum + verification) without caring about the transfer protocol. Skips if no + protocol URL and FILE_CHECKSUM are set. + """ + checksum = os.environ.get("FILE_CHECKSUM") + for env_var in _PROTOCOL_URL_VARS.values(): + url = os.environ.get(env_var) + if url and checksum: + file_name = os.environ.get("FILE_NAME") or posixpath.basename(url.split("?")[0]) + return FileCopyModel( + download_url=url, + checksum=checksum, + file_name=file_name, + hashing_algorithm="sha512", + timeout=900, + ) + pytest.skip("No protocol URL / FILE_CHECKSUM environment variables not set") + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +def test_device_connects(device): + """Verify the device is reachable and responds to show commands.""" + assert device.hostname + assert device.os_version + + +def test_check_file_exists_false(device, any_file_copy_model): + """Before the copy, the file should not exist (or this test is a no-op if it does).""" + result = device.check_file_exists(any_file_copy_model.file_name) + assert isinstance(result, bool) + + +def test_get_remote_checksum_after_exists(device, any_file_copy_model): + """If the file already exists, verify get_remote_checksum returns a non-empty string.""" + if not device.check_file_exists(any_file_copy_model.file_name): + pytest.skip("File does not exist on device; run test_remote_file_copy_* first") + checksum = device.get_remote_checksum(any_file_copy_model.file_name, hashing_algorithm="sha512") + assert checksum and len(checksum) > 0 + + +def test_remote_file_copy_ftp(device): + """Transfer the file using FTP and verify it exists on the device.""" + model = _make_model("FTP_URL") + device.remote_file_copy(model) + assert device.check_file_exists(model.file_name) + + +def test_remote_file_copy_tftp(device): + """Transfer the file using TFTP and verify it exists on the device.""" + model = _make_model("TFTP_URL") + device.remote_file_copy(model) + assert device.check_file_exists(model.file_name) + + +def test_remote_file_copy_scp(device): + """Transfer the file using SCP and verify it exists on the device.""" + model = _make_model("SCP_URL") + device.remote_file_copy(model) + assert device.check_file_exists(model.file_name) + + +def test_remote_file_copy_http(device): + """Transfer the file using HTTP and verify it exists on the device.""" + model = _make_model("HTTP_URL") + device.remote_file_copy(model) + assert device.check_file_exists(model.file_name) + + +def test_remote_file_copy_https(device): + """Transfer the file using HTTPS and verify it exists on the device.""" + model = _make_model("HTTPS_URL") + device.remote_file_copy(model) + assert device.check_file_exists(model.file_name) + + +def test_remote_file_copy_sftp(device): + """Transfer the file using SFTP and verify it exists on the device.""" + model = _make_model("SFTP_URL") + device.remote_file_copy(model) + assert device.check_file_exists(model.file_name) + + +def test_verify_file_after_copy(device, any_file_copy_model): + """After a successful copy the file should verify cleanly.""" + if not device.check_file_exists(any_file_copy_model.file_name): + pytest.skip("File does not exist on device; run a copy test first") + assert device.verify_file(any_file_copy_model.checksum, any_file_copy_model.file_name, hashing_algorithm="sha512") diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 37acf4f5..f3f974df 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -3,7 +3,7 @@ import pytest -from pyntc.devices import AIREOSDevice, ASADevice, IOSDevice, IOSXEWLCDevice, supported_devices +from pyntc.devices import AIREOSDevice, ASADevice, EOSDevice, IOSDevice, IOSXEWLCDevice, supported_devices def get_side_effects(mock_path, side_effects): @@ -17,6 +17,48 @@ def get_side_effects(mock_path, side_effects): return effects +# EOS fixtures + + +@pytest.fixture +def eos_device(): + with mock.patch("pyeapi.client.Node", autospec=True) as mock_node: + device = EOSDevice("host", "user", "password") + device.native = mock_node + yield device + + +@pytest.fixture +def eos_mock_path(mock_path): + return f"{mock_path}/eos" + + +@pytest.fixture +def eos_send_command(eos_device, eos_mock_path): + def _mock(side_effects, existing_device=None, device=eos_device): + if existing_device is not None: + device = existing_device + device.native_ssh = mock.MagicMock() + device.native_ssh.send_command.side_effect = get_side_effects(f"{eos_mock_path}/send_command", side_effects) + return device + + return _mock + + +@pytest.fixture +def eos_send_command_timing(eos_device, eos_mock_path): + def _mock(side_effects, existing_device=None, device=eos_device): + if existing_device is not None: + device = existing_device + device.native_ssh = mock.MagicMock() + device.native_ssh.send_command_timing.side_effect = get_side_effects( + f"{eos_mock_path}/send_command", side_effects + ) + return device + + return _mock + + def pytest_generate_tests(metafunc): if metafunc.function.__name__ == "test_device_creation": metafunc.parametrize( diff --git a/tests/unit/test_devices/test_eos_device.py b/tests/unit/test_devices/test_eos_device.py index 9848cf5a..a9a78e70 100644 --- a/tests/unit/test_devices/test_eos_device.py +++ b/tests/unit/test_devices/test_eos_device.py @@ -279,8 +279,6 @@ def test_file_copy_fail(self, mock_open, mock_close, mock_ssh, mock_ft): with self.assertRaises(FileTransferError): self.device.file_copy("source_file") - # TODO: unit test for remote_file_copy - def test_reboot(self): self.device.reboot() self.device.native.enable.assert_called_with(["reload now"], encoding="json") @@ -438,174 +436,6 @@ def test_init_pass_port_and_timeout(mock_eos_connect): ) -# Property-based tests for file system normalization -try: - from hypothesis import given - from hypothesis import strategies as st -except ImportError: - # Create dummy decorators if hypothesis is not available - def given(*args, **kwargs): - def decorator(func): - return func - - return decorator - - class _ST: - @staticmethod - def just(value): - return value - - @staticmethod - def one_of(*args): - return args[0] - - st = _ST() - - -@given( - src=st.just("not_a_filecopymodel"), -) -def test_property_type_validation(src): - """Feature: arista-remote-file-copy, Property 1: Type Validation. - - For any non-FileCopyModel object passed as `src`, the `remote_file_copy()` - method should raise a `TypeError`. - - Validates: Requirements 1.2, 15.1 - """ - device = EOSDevice("host", "user", "pass") - - with pytest.raises(TypeError) as exc_info: - device.remote_file_copy(src) - - assert "src must be an instance of FileCopyModel" in str(exc_info.value) - - -@mock.patch.object(EOSDevice, "_get_file_system") -def test_property_file_system_auto_detection(mock_get_fs): - """Feature: arista-remote-file-copy, Property 26: File System Auto-Detection. - - For any `remote_file_copy()` call without an explicit `file_system` parameter, - the method should call `_get_file_system()` to determine the default file system. - - Validates: Requirements 11.1 - """ - mock_get_fs.return_value = "/mnt/flash" - device = EOSDevice("host", "user", "pass") - - src = FileCopyModel( - download_url="http://server.example.com/file.bin", - checksum="abc123", - file_name="file.bin", - ) - - # Call remote_file_copy without file_system parameter - try: - device.remote_file_copy(src) - except Exception: - # We expect it to fail later, but we just want to verify _get_file_system was called - pass - - # Verify _get_file_system was called - mock_get_fs.assert_called() - - -@mock.patch.object(EOSDevice, "_get_file_system") -def test_property_explicit_file_system_usage(mock_get_fs): - """Feature: arista-remote-file-copy, Property 27: Explicit File System Usage. - - For any `remote_file_copy()` call with an explicit `file_system` parameter, - that value should be used instead of auto-detection. - - Validates: Requirements 11.2 - """ - device = EOSDevice("host", "user", "pass") - - src = FileCopyModel( - download_url="http://server.example.com/file.bin", - checksum="abc123", - file_name="file.bin", - ) - - # Call remote_file_copy with explicit file_system parameter - try: - device.remote_file_copy(src, file_system="/mnt/flash") - except Exception: - # We expect it to fail later, but we just want to verify _get_file_system was NOT called - pass - - # Verify _get_file_system was NOT called - mock_get_fs.assert_not_called() - - -@mock.patch.object(EOSDevice, "verify_file") -@mock.patch.object(EOSDevice, "enable") -@mock.patch.object(EOSDevice, "open") -@mock.patch.object(EOSDevice, "_get_file_system") -def test_property_default_destination_from_filecopymodel(mock_get_fs, mock_open, mock_enable, mock_verify): - """Feature: arista-remote-file-copy, Property 28: Default Destination from FileCopyModel. - - For any `remote_file_copy()` call without an explicit `dest` parameter, - the destination should default to `src.file_name`. - - Validates: Requirements 12.1 - """ - mock_get_fs.return_value = "/mnt/flash" - mock_verify.return_value = True - - device = EOSDevice("host", "user", "pass") - device.native_ssh = mock.MagicMock() - device.native_ssh.send_command.return_value = "Copy completed successfully" - - src = FileCopyModel( - download_url="http://server.example.com/myfile.bin", - checksum="abc123", - file_name="myfile.bin", - ) - - # Call remote_file_copy without explicit dest - device.remote_file_copy(src) - - # Verify verify_file was called with the default destination - mock_verify.assert_called() - call_args = mock_verify.call_args - assert call_args[0][1] == "myfile.bin" # dest should be file_name - - -@mock.patch.object(EOSDevice, "verify_file") -@mock.patch.object(EOSDevice, "enable") -@mock.patch.object(EOSDevice, "open") -@mock.patch.object(EOSDevice, "_get_file_system") -def test_property_explicit_destination_usage(mock_get_fs, mock_open, mock_enable, mock_verify): - """Feature: arista-remote-file-copy, Property 29: Explicit Destination Usage. - - For any `remote_file_copy()` call with an explicit `dest` parameter, - that value should be used as the destination filename. - - Validates: Requirements 12.2 - """ - mock_get_fs.return_value = "/mnt/flash" - mock_verify.return_value = True - - device = EOSDevice("host", "user", "pass") - device.native_ssh = mock.MagicMock() - device.native_ssh.send_command.return_value = "Copy completed successfully" - - src = FileCopyModel( - download_url="http://server.example.com/myfile.bin", - checksum="abc123", - file_name="myfile.bin", - ) - - # Call remote_file_copy with explicit dest - device.remote_file_copy(src, dest="different_name.bin") - - # Verify verify_file was called with the explicit destination - mock_verify.assert_called() - call_args = mock_verify.call_args - assert call_args[0][1] == "different_name.bin" # dest should be the explicit value - - class TestRemoteFileCopy(unittest.TestCase): """Tests for remote_file_copy method.""" @@ -622,22 +452,41 @@ def tearDown(self): def test_remote_file_copy_invalid_src_type(self): """Test remote_file_copy raises TypeError for invalid src type.""" - with pytest.raises(TypeError) as exc_info: + with self.assertRaises(TypeError) as ctx: self.device.remote_file_copy("not_a_model") - assert "src must be an instance of FileCopyModel" in str(exc_info.value) + self.assertIn("src must be an instance of FileCopyModel", str(ctx.exception)) @mock.patch.object(EOSDevice, "verify_file") @mock.patch.object(EOSDevice, "enable") @mock.patch.object(EOSDevice, "open") @mock.patch.object(EOSDevice, "_get_file_system") - def test_remote_file_copy_skip_transfer_on_checksum_match(self, mock_get_fs, mock_open, mock_enable, mock_verify): - """Test remote_file_copy skips transfer when file exists with matching checksum.""" - from pyntc.utils.models import FileCopyModel + def test_remote_file_copy_file_system_auto_detection(self, mock_get_fs, mock_open, mock_enable, mock_verify): + """Test remote_file_copy calls _get_file_system when file_system is not provided.""" + mock_get_fs.return_value = "/mnt/flash" + mock_verify.return_value = True + mock_ssh = mock.MagicMock() + mock_ssh.send_command.return_value = "Copy completed successfully" + self.device.native_ssh = mock_ssh + + src = FileCopyModel( + download_url="http://server.example.com/file.bin", + checksum="abc123", + file_name="file.bin", + ) + + self.device.remote_file_copy(src) + mock_get_fs.assert_called() + + @mock.patch.object(EOSDevice, "verify_file") + @mock.patch.object(EOSDevice, "enable") + @mock.patch.object(EOSDevice, "open") + @mock.patch.object(EOSDevice, "_get_file_system") + def test_remote_file_copy_skip_transfer_on_checksum_match(self, mock_get_fs, mock_open, mock_enable, mock_verify): + """Test remote_file_copy completes when file exists with matching checksum.""" mock_get_fs.return_value = "flash:" mock_verify.return_value = True - # Mock netmiko connection mock_ssh = mock.MagicMock() mock_ssh.send_command.return_value = "Copy completed successfully" self.device.native_ssh = mock_ssh @@ -648,10 +497,7 @@ def test_remote_file_copy_skip_transfer_on_checksum_match(self, mock_get_fs, moc file_name="file.bin", ) - # Should return without raising exception self.device.remote_file_copy(src) - - # Verify that verify_file was called mock_verify.assert_called() @mock.patch.object(EOSDevice, "verify_file") @@ -660,12 +506,9 @@ def test_remote_file_copy_skip_transfer_on_checksum_match(self, mock_get_fs, moc @mock.patch.object(EOSDevice, "_get_file_system") def test_remote_file_copy_http_transfer(self, mock_get_fs, mock_open, mock_enable, mock_verify): """Test remote_file_copy executes HTTP transfer correctly.""" - from pyntc.utils.models import FileCopyModel - mock_get_fs.return_value = "flash:" - mock_verify.return_value = True # Verification passes + mock_verify.return_value = True - # Mock netmiko connection mock_ssh = mock.MagicMock() mock_ssh.send_command.return_value = "Copy completed successfully" self.device.native_ssh = mock_ssh @@ -676,15 +519,13 @@ def test_remote_file_copy_http_transfer(self, mock_get_fs, mock_open, mock_enabl file_name="file.bin", ) - # Should not raise exception self.device.remote_file_copy(src) - # Verify open and enable were called mock_open.assert_called_once() mock_enable.assert_called_once() - - # Verify send_command was called with correct command mock_ssh.send_command.assert_called() + call_args = mock_ssh.send_command.call_args + self.assertIn("copy http://", call_args[0][0]) @mock.patch.object(EOSDevice, "verify_file") @mock.patch.object(EOSDevice, "enable") @@ -692,12 +533,9 @@ def test_remote_file_copy_http_transfer(self, mock_get_fs, mock_open, mock_enabl @mock.patch.object(EOSDevice, "_get_file_system") def test_remote_file_copy_verification_failure(self, mock_get_fs, mock_open, mock_enable, mock_verify): """Test remote_file_copy raises FileTransferError when verification fails.""" - from pyntc.utils.models import FileCopyModel - mock_get_fs.return_value = "flash:" - mock_verify.return_value = False # Verification fails + mock_verify.return_value = False - # Mock netmiko connection mock_ssh = mock.MagicMock() mock_ssh.send_command.return_value = "Copy completed successfully" self.device.native_ssh = mock_ssh @@ -708,22 +546,43 @@ def test_remote_file_copy_verification_failure(self, mock_get_fs, mock_open, moc file_name="file.bin", ) - # Should raise FileTransferError - with pytest.raises(FileTransferError): + with self.assertRaises(FileTransferError): self.device.remote_file_copy(src) + @mock.patch.object(EOSDevice, "verify_file") + @mock.patch.object(EOSDevice, "enable") + @mock.patch.object(EOSDevice, "open") + @mock.patch.object(EOSDevice, "_get_file_system") + def test_remote_file_copy_with_default_dest(self, mock_get_fs, mock_open, mock_enable, mock_verify): + """Test remote_file_copy defaults dest to src.file_name.""" + mock_get_fs.return_value = "/mnt/flash" + mock_verify.return_value = True + + mock_ssh = mock.MagicMock() + mock_ssh.send_command.return_value = "Copy completed successfully" + self.device.native_ssh = mock_ssh + + src = FileCopyModel( + download_url="http://server.example.com/myfile.bin", + checksum="abc123", + file_name="myfile.bin", + ) + + self.device.remote_file_copy(src) + + mock_verify.assert_called() + call_args = mock_verify.call_args + self.assertEqual(call_args[0][1], "myfile.bin") + @mock.patch.object(EOSDevice, "verify_file") @mock.patch.object(EOSDevice, "enable") @mock.patch.object(EOSDevice, "open") @mock.patch.object(EOSDevice, "_get_file_system") def test_remote_file_copy_with_explicit_dest(self, mock_get_fs, mock_open, mock_enable, mock_verify): """Test remote_file_copy uses explicit dest parameter.""" - from pyntc.utils.models import FileCopyModel - mock_get_fs.return_value = "flash:" mock_verify.return_value = True - # Mock netmiko connection mock_ssh = mock.MagicMock() mock_ssh.send_command.return_value = "Copy completed successfully" self.device.native_ssh = mock_ssh @@ -734,12 +593,10 @@ def test_remote_file_copy_with_explicit_dest(self, mock_get_fs, mock_open, mock_ file_name="file.bin", ) - # Call with explicit dest self.device.remote_file_copy(src, dest="custom_name.bin") - # Verify verify_file was called with custom dest call_args = mock_verify.call_args - assert call_args[0][1] == "custom_name.bin" + self.assertEqual(call_args[0][1], "custom_name.bin") @mock.patch.object(EOSDevice, "verify_file") @mock.patch.object(EOSDevice, "enable") @@ -747,11 +604,8 @@ def test_remote_file_copy_with_explicit_dest(self, mock_get_fs, mock_open, mock_ @mock.patch.object(EOSDevice, "_get_file_system") def test_remote_file_copy_with_explicit_file_system(self, mock_get_fs, mock_open, mock_enable, mock_verify): """Test remote_file_copy uses explicit file_system parameter.""" - from pyntc.utils.models import FileCopyModel - mock_verify.return_value = True - # Mock netmiko connection mock_ssh = mock.MagicMock() mock_ssh.send_command.return_value = "Copy completed successfully" self.device.native_ssh = mock_ssh @@ -762,15 +616,11 @@ def test_remote_file_copy_with_explicit_file_system(self, mock_get_fs, mock_open file_name="file.bin", ) - # Call with explicit file_system self.device.remote_file_copy(src, file_system="flash:") - # Verify _get_file_system was NOT called mock_get_fs.assert_not_called() - - # Verify send_command was called with correct file_system call_args = mock_ssh.send_command.call_args - assert "flash:" in call_args[0][0] + self.assertIn("flash:", call_args[0][0]) @mock.patch.object(EOSDevice, "verify_file") @mock.patch.object(EOSDevice, "enable") @@ -778,12 +628,9 @@ def test_remote_file_copy_with_explicit_file_system(self, mock_get_fs, mock_open @mock.patch.object(EOSDevice, "_get_file_system") def test_remote_file_copy_scp_with_credentials(self, mock_get_fs, mock_open, mock_enable, mock_verify): """Test remote_file_copy constructs SCP command with username only.""" - from pyntc.utils.models import FileCopyModel - mock_get_fs.return_value = "flash:" mock_verify.return_value = True - # Mock netmiko connection mock_ssh = mock.MagicMock() mock_ssh.send_command_timing.return_value = "Copy completed successfully" self.device.native_ssh = mock_ssh @@ -796,13 +643,11 @@ def test_remote_file_copy_scp_with_credentials(self, mock_get_fs, mock_open, moc self.device.remote_file_copy(src) - # Verify send_command_timing was called with SCP command containing username only - # Token is provided at the Arista "Password:" prompt call_args = mock_ssh.send_command_timing.call_args command = call_args[0][0] - assert "scp://" in command - assert "user@" in command - assert "pass@" not in command # Password should not be in command + self.assertIn("scp://", command) + self.assertIn("user@", command) + self.assertNotIn("pass@", command) @mock.patch.object(EOSDevice, "verify_file") @mock.patch.object(EOSDevice, "enable") @@ -810,130 +655,39 @@ def test_remote_file_copy_scp_with_credentials(self, mock_get_fs, mock_open, moc @mock.patch.object(EOSDevice, "_get_file_system") def test_remote_file_copy_timeout_applied(self, mock_get_fs, mock_open, mock_enable, mock_verify): """Test remote_file_copy applies timeout to send_command.""" - from pyntc.utils.models import FileCopyModel - mock_get_fs.return_value = "flash:" mock_verify.return_value = True - # Mock netmiko connection mock_ssh = mock.MagicMock() mock_ssh.send_command.return_value = "Copy completed successfully" self.device.native_ssh = mock_ssh - src = FileCopyModel( - download_url="http://example.com/file.bin", - checksum="abc123", - file_name="file.bin", - timeout=1800, - ) - - self.device.remote_file_copy(src) - - # Verify send_command was called with correct timeout - call_args = mock_ssh.send_command.call_args - assert call_args[1]["read_timeout"] == 1800 - - -# Property-based tests for Task 7: Pre-transfer verification - - -@mock.patch.object(EOSDevice, "verify_file") -@mock.patch.object(EOSDevice, "enable") -@mock.patch.object(EOSDevice, "open") -@mock.patch.object(EOSDevice, "_get_file_system") -def test_property_skip_transfer_on_checksum_match(mock_get_fs, mock_open, mock_enable, mock_verify): - """Feature: arista-remote-file-copy, Property 14: Skip Transfer on Checksum Match. - - For any file that already exists on the device with a matching checksum, - the `remote_file_copy()` method should return successfully after verification. - - Validates: Requirements 5.2 - """ - from pyntc.utils.models import FileCopyModel - - mock_get_fs.return_value = "/mnt/flash" - mock_verify.return_value = True # File exists with matching checksum - - device = EOSDevice("host", "user", "pass") - device.native_ssh = mock.MagicMock() - device.native_ssh.send_command.return_value = "Copy completed successfully" - - src = FileCopyModel( - download_url="http://server.example.com/file.bin", - checksum="abc123def456", - file_name="file.bin", - ) - - # Call remote_file_copy - device.remote_file_copy(src) - - # Verify that verify_file was called - mock_verify.assert_called() - - # Verify that send_command was called (transfer always occurs) - device.native_ssh.send_command.assert_called() - - -@mock.patch.object(EOSDevice, "verify_file") -@mock.patch.object(EOSDevice, "enable") -@mock.patch.object(EOSDevice, "open") -@mock.patch.object(EOSDevice, "_get_file_system") -def test_property_proceed_on_checksum_mismatch(mock_get_fs, mock_open, mock_enable, mock_verify): - """Feature: arista-remote-file-copy, Property 15: Proceed on Checksum Mismatch. - - For any file that exists on the device but has a mismatched checksum, - the `remote_file_copy()` method should proceed with the file transfer. - - Validates: Requirements 5.3 - """ - from pyntc.utils.models import FileCopyModel - - mock_get_fs.return_value = "/mnt/flash" - # Verification fails (file doesn't exist or checksum mismatches) - mock_verify.return_value = False - - device = EOSDevice("host", "user", "pass") - mock_ssh = mock.MagicMock() - mock_ssh.send_command.return_value = "Copy completed successfully" - device.native_ssh = mock_ssh - - src = FileCopyModel( - download_url="http://server.example.com/file.bin", - checksum="abc123def456", - file_name="file.bin", - ) - - # Call remote_file_copy - should raise FileTransferError because verification fails - with pytest.raises(FileTransferError): - device.remote_file_copy(src) - - # Verify that send_command was called with a copy command - mock_ssh.send_command.assert_called() - call_args = mock_ssh.send_command.call_args - assert "copy" in call_args[0][0].lower() - + for timeout in [300, 600, 900, 1800]: + with self.subTest(timeout=timeout): + src = FileCopyModel( + download_url="http://example.com/file.bin", + checksum="abc123", + file_name="file.bin", + timeout=timeout, + ) -# Tests for Task 8: Command Execution + self.device.remote_file_copy(src) - -class TestRemoteFileCopyCommandExecution(unittest.TestCase): - """Tests for command execution flow in remote_file_copy.""" + call_args = mock_ssh.send_command.call_args + self.assertEqual(call_args[1]["read_timeout"], timeout) @mock.patch.object(EOSDevice, "verify_file") @mock.patch.object(EOSDevice, "enable") @mock.patch.object(EOSDevice, "open") @mock.patch.object(EOSDevice, "_get_file_system") - def test_command_execution_with_http(self, mock_get_fs, mock_open, mock_enable, mock_verify): - """Test command execution for HTTP transfer.""" - from pyntc.utils.models import FileCopyModel - + def test_remote_file_copy_checksum_mismatch_raises_error(self, mock_get_fs, mock_open, mock_enable, mock_verify): + """Test remote_file_copy raises FileTransferError on checksum mismatch after transfer.""" mock_get_fs.return_value = "/mnt/flash" - mock_verify.return_value = True + mock_verify.return_value = False - device = EOSDevice("host", "user", "pass") mock_ssh = mock.MagicMock() mock_ssh.send_command.return_value = "Copy completed successfully" - device.native_ssh = mock_ssh + self.device.native_ssh = mock_ssh src = FileCopyModel( download_url="http://server.example.com/file.bin", @@ -941,387 +695,103 @@ def test_command_execution_with_http(self, mock_get_fs, mock_open, mock_enable, file_name="file.bin", ) - device.remote_file_copy(src) - - # Verify open() was called - mock_open.assert_called_once() - - # Verify enable() was called - mock_enable.assert_called_once() + with self.assertRaises(FileTransferError): + self.device.remote_file_copy(src) - # Verify send_command was called with HTTP copy command mock_ssh.send_command.assert_called() call_args = mock_ssh.send_command.call_args - assert "copy http://" in call_args[0][0] + self.assertIn("copy", call_args[0][0].lower()) @mock.patch.object(EOSDevice, "verify_file") @mock.patch.object(EOSDevice, "enable") @mock.patch.object(EOSDevice, "open") @mock.patch.object(EOSDevice, "_get_file_system") - def test_command_execution_with_scp_credentials(self, mock_get_fs, mock_open, mock_enable, mock_verify): - """Test command execution for SCP transfer with username only.""" - from pyntc.utils.models import FileCopyModel - + def test_remote_file_copy_post_transfer_verification(self, mock_get_fs, mock_open, mock_enable, mock_verify): + """Test remote_file_copy calls verify_file with correct algorithm after transfer.""" mock_get_fs.return_value = "/mnt/flash" mock_verify.return_value = True - device = EOSDevice("host", "user", "pass") mock_ssh = mock.MagicMock() - mock_ssh.send_command_timing.return_value = "Copy completed successfully" - device.native_ssh = mock_ssh + mock_ssh.send_command.return_value = "Copy completed successfully" + self.device.native_ssh = mock_ssh + + for checksum, algorithm in [("abc123def456", "md5"), ("abc123def456789", "sha256")]: + with self.subTest(algorithm=algorithm): + src = FileCopyModel( + download_url="http://server.example.com/file.bin", + checksum=checksum, + file_name="file.bin", + hashing_algorithm=algorithm, + ) + self.device.remote_file_copy(src) + mock_verify.assert_called() + + def test_remote_file_copy_unsupported_scheme(self): + """Test remote_file_copy raises ValueError for unsupported scheme.""" src = FileCopyModel( - download_url="scp://admin:password@backup.example.com/configs/startup-config", - checksum="abc123def456", - file_name="startup-config", - username="admin", - token="password", + download_url="http://example.com/file.bin", + checksum="abc123", + file_name="file.bin", ) + # Override scheme to something unsupported + src.scheme = "gopher" - device.remote_file_copy(src) - - # Verify send_command_timing was called with SCP copy command including username only - # Token is provided at the Arista "Password:" prompt - mock_ssh.send_command_timing.assert_called() - call_args = mock_ssh.send_command_timing.call_args - assert "copy scp://" in call_args[0][0] - assert "admin@" in call_args[0][0] - assert "password@" not in call_args[0][0] # Password should not be in command + with self.assertRaises(ValueError) as ctx: + self.device.remote_file_copy(src) + self.assertIn("Unsupported scheme", str(ctx.exception)) @mock.patch.object(EOSDevice, "verify_file") @mock.patch.object(EOSDevice, "enable") @mock.patch.object(EOSDevice, "open") @mock.patch.object(EOSDevice, "_get_file_system") - def test_timeout_applied_to_send_command(self, mock_get_fs, mock_open, mock_enable, mock_verify): - """Test that timeout is applied to send_command calls.""" - from pyntc.utils.models import FileCopyModel - - mock_get_fs.return_value = "/mnt/flash" + def test_remote_file_copy_token_only_uses_simple_builder(self, mock_get_fs, mock_open, mock_enable, mock_verify): + """Test remote_file_copy uses simple command when token is provided but username is None.""" + mock_get_fs.return_value = "flash:" mock_verify.return_value = True - device = EOSDevice("host", "user", "pass") mock_ssh = mock.MagicMock() mock_ssh.send_command.return_value = "Copy completed successfully" - device.native_ssh = mock_ssh + self.device.native_ssh = mock_ssh src = FileCopyModel( - download_url="http://server.example.com/file.bin", - checksum="abc123def456", + download_url="http://example.com/file.bin", + checksum="abc123", file_name="file.bin", - timeout=600, + token="some_token", ) - device.remote_file_copy(src) + self.device.remote_file_copy(src) - # Verify send_command was called with the specified timeout - mock_ssh.send_command.assert_called() call_args = mock_ssh.send_command.call_args - assert call_args[1]["read_timeout"] == 600 - - -# Tests for Task 9: Post-transfer Verification - - -@pytest.mark.parametrize( - "checksum,algorithm", - [ - ("abc123def456", "md5"), - ("abc123def456789", "sha256"), - ], -) -def test_property_post_transfer_verification(checksum, algorithm): - """Feature: arista-remote-file-copy, Property 20: Post-Transfer Verification. - - For any completed file transfer, the method should verify the file exists - on the device and compute its checksum using the specified algorithm. - - Validates: Requirements 9.1, 9.2 - """ - from pyntc.utils.models import FileCopyModel - - device = EOSDevice("host", "user", "pass") - device.native_ssh = mock.MagicMock() - - with mock.patch.object(device, "verify_file") as mock_verify: - with mock.patch.object(device, "_get_file_system") as mock_get_fs: - with mock.patch.object(device, "open"): - with mock.patch.object(device, "enable"): - mock_get_fs.return_value = "/mnt/flash" - mock_verify.return_value = True - device.native_ssh.send_command.return_value = "Copy completed successfully" - - src = FileCopyModel( - download_url="http://server.example.com/file.bin", - checksum=checksum, - file_name="file.bin", - hashing_algorithm=algorithm, - ) - - device.remote_file_copy(src) - - # Verify that verify_file was called - mock_verify.assert_called() - - -@pytest.mark.parametrize( - "checksum,algorithm", - [ - ("abc123def456", "md5"), - ("abc123def456789", "sha256"), - ], -) -def test_property_checksum_match_verification(checksum, algorithm): - """Feature: arista-remote-file-copy, Property 21: Checksum Match Verification. - - For any transferred file where the computed checksum matches the expected checksum, - the method should consider the transfer successful. - - Validates: Requirements 9.3 - """ - from pyntc.utils.models import FileCopyModel - - device = EOSDevice("host", "user", "pass") - device.native_ssh = mock.MagicMock() - - with mock.patch.object(device, "verify_file") as mock_verify: - with mock.patch.object(device, "_get_file_system") as mock_get_fs: - with mock.patch.object(device, "open"): - with mock.patch.object(device, "enable"): - mock_get_fs.return_value = "/mnt/flash" - # Verification passes - mock_verify.return_value = True - device.native_ssh.send_command.return_value = "Copy completed successfully" - - src = FileCopyModel( - download_url="http://server.example.com/file.bin", - checksum=checksum, - file_name="file.bin", - hashing_algorithm=algorithm, - ) - - # Should not raise an exception - device.remote_file_copy(src) - - -def test_property_checksum_mismatch_error(): - """Feature: arista-remote-file-copy, Property 22: Checksum Mismatch Error. - - For any transferred file where the computed checksum does not match the expected checksum, - the method should raise a FileTransferError. - - Validates: Requirements 9.4 - """ - from pyntc.utils.models import FileCopyModel - - device = EOSDevice("host", "user", "pass") - device.native_ssh = mock.MagicMock() - - with mock.patch.object(device, "verify_file") as mock_verify: - with mock.patch.object(device, "_get_file_system") as mock_get_fs: - with mock.patch.object(device, "open"): - with mock.patch.object(device, "enable"): - mock_get_fs.return_value = "/mnt/flash" - # First call: file doesn't exist (False) - # Second call: checksum mismatch (False) - mock_verify.side_effect = [False, False] - device.native_ssh.send_command.return_value = "Copy completed successfully" - - src = FileCopyModel( - download_url="http://server.example.com/file.bin", - checksum="abc123def456", - file_name="file.bin", - ) - - # Should raise FileTransferError - with pytest.raises(FileTransferError): - device.remote_file_copy(src) - - -def test_property_missing_file_after_transfer_error(): - """Feature: arista-remote-file-copy, Property 23: Missing File After Transfer Error. - - For any transfer that completes but the file does not exist on the device afterward, - the method should raise a FileTransferError. - - Validates: Requirements 9.5 - """ - from pyntc.utils.models import FileCopyModel - - device = EOSDevice("host", "user", "pass") - device.native_ssh = mock.MagicMock() - - with mock.patch.object(device, "verify_file") as mock_verify: - with mock.patch.object(device, "_get_file_system") as mock_get_fs: - with mock.patch.object(device, "open"): - with mock.patch.object(device, "enable"): - mock_get_fs.return_value = "/mnt/flash" - # First call: file doesn't exist (False) - # Second call: file still doesn't exist (False) - mock_verify.side_effect = [False, False] - device.native_ssh.send_command.return_value = "Copy completed successfully" - - src = FileCopyModel( - download_url="http://server.example.com/file.bin", - checksum="abc123def456", - file_name="file.bin", - ) - - # Should raise FileTransferError - with pytest.raises(FileTransferError): - device.remote_file_copy(src) - - -# Tests for Task 10: Timeout and FTP Support - - -@pytest.mark.parametrize("timeout", [300, 600, 900, 1800]) -def test_property_timeout_application(timeout): - """Feature: arista-remote-file-copy, Property 24: Timeout Application. - - For any FileCopyModel with a specified timeout value, that timeout should be used - when sending commands to the device during transfer. - - Validates: Requirements 10.1, 10.3 - """ - from pyntc.utils.models import FileCopyModel - - device = EOSDevice("host", "user", "pass") - device.native_ssh = mock.MagicMock() - - with mock.patch.object(device, "verify_file") as mock_verify: - with mock.patch.object(device, "_get_file_system") as mock_get_fs: - with mock.patch.object(device, "open"): - with mock.patch.object(device, "enable"): - mock_get_fs.return_value = "/mnt/flash" - mock_verify.return_value = True - device.native_ssh.send_command.return_value = "Copy completed successfully" - - src = FileCopyModel( - download_url="http://server.example.com/file.bin", - checksum="abc123def456", - file_name="file.bin", - timeout=timeout, - ) - - device.remote_file_copy(src) - - # Verify send_command was called with the correct timeout - call_args = device.native_ssh.send_command.call_args - assert call_args[1]["read_timeout"] == timeout - - -def test_property_default_timeout_value(): - """Feature: arista-remote-file-copy, Property 25: Default Timeout Value. - - For any FileCopyModel without an explicit timeout, the default timeout should be 900 seconds. - - Validates: Requirements 10.2 - """ - from pyntc.utils.models import FileCopyModel - - src = FileCopyModel( - download_url="http://server.example.com/file.bin", - checksum="abc123def456", - file_name="file.bin", - ) - - # Verify default timeout is 900 - assert src.timeout == 900 - - -@pytest.mark.parametrize("ftp_passive", [True, False]) -def test_property_ftp_passive_mode_configuration(ftp_passive): - """Feature: arista-remote-file-copy, Property 30/31: FTP Passive Mode Configuration. - - For any FileCopyModel with ftp_passive flag, the FTP transfer should use the specified mode. - - Validates: Requirements 19.1, 19.2 - """ - from pyntc.utils.models import FileCopyModel - - src = FileCopyModel( - download_url="ftp://admin:password@ftp.example.com/images/eos.swi", - checksum="abc123def456", - file_name="eos.swi", - ftp_passive=ftp_passive, - ) - - # Verify ftp_passive is set correctly - assert src.ftp_passive == ftp_passive - - -def test_property_default_ftp_passive_mode(): - """Feature: arista-remote-file-copy, Property 32: Default FTP Passive Mode. - - For any FileCopyModel without an explicit ftp_passive parameter, the default should be True. - - Validates: Requirements 19.3 - """ - from pyntc.utils.models import FileCopyModel - - src = FileCopyModel( - download_url="ftp://admin:password@ftp.example.com/images/eos.swi", - checksum="abc123def456", - file_name="eos.swi", - ) - - # Verify default ftp_passive is True - assert src.ftp_passive is True - - -# Tests for Task 11: Error Handling and Logging - - -class TestRemoteFileCopyErrorHandling(unittest.TestCase): - """Tests for error handling in remote_file_copy.""" - - def test_invalid_src_type_raises_typeerror(self): - """Test that invalid src type raises TypeError.""" - device = EOSDevice("host", "user", "pass") - - with pytest.raises(TypeError) as exc_info: - device.remote_file_copy("not a FileCopyModel") - - assert "src must be an instance of FileCopyModel" in str(exc_info.value) - - @mock.patch.object(EOSDevice, "verify_file") - @mock.patch.object(EOSDevice, "enable") - @mock.patch.object(EOSDevice, "open") - @mock.patch.object(EOSDevice, "_get_file_system") - def test_transfer_failure_raises_filetransfererror(self, mock_get_fs, mock_open, mock_enable, mock_verify): - """Test that transfer failure raises FileTransferError.""" - from pyntc.utils.models import FileCopyModel - - mock_get_fs.return_value = "/mnt/flash" - mock_verify.side_effect = [False, False] # Post-transfer verification fails - - device = EOSDevice("host", "user", "pass") - device.native_ssh = mock.MagicMock() - device.native_ssh.send_command.return_value = "Copy completed successfully" + command = call_args[0][0] + self.assertNotIn("None", command) + self.assertIn("copy http://", command) + def test_remote_file_copy_query_string_rejected(self): + """Test remote_file_copy raises ValueError for URLs with query strings.""" src = FileCopyModel( - download_url="http://server.example.com/file.bin", - checksum="abc123def456", + download_url="http://example.com/file.bin?token=abc", + checksum="abc123", file_name="file.bin", ) - with pytest.raises(FileTransferError): - device.remote_file_copy(src) + with self.assertRaises(ValueError) as ctx: + self.device.remote_file_copy(src) + self.assertIn("query strings are not supported", str(ctx.exception)) @mock.patch.object(EOSDevice, "verify_file") @mock.patch.object(EOSDevice, "enable") @mock.patch.object(EOSDevice, "open") @mock.patch.object(EOSDevice, "_get_file_system") - def test_logging_on_transfer_success(self, mock_get_fs, mock_open, mock_enable, mock_verify): + def test_remote_file_copy_logging_on_success(self, mock_get_fs, mock_open, mock_enable, mock_verify): """Test that transfer success is logged.""" - from pyntc.utils.models import FileCopyModel - mock_get_fs.return_value = "/mnt/flash" mock_verify.return_value = True - device = EOSDevice("host", "user", "pass") - device.native_ssh = mock.MagicMock() - device.native_ssh.send_command.return_value = "Copy completed successfully" + mock_ssh = mock.MagicMock() + mock_ssh.send_command.return_value = "Copy completed successfully" + self.device.native_ssh = mock_ssh src = FileCopyModel( download_url="http://server.example.com/file.bin", @@ -1330,103 +800,97 @@ def test_logging_on_transfer_success(self, mock_get_fs, mock_open, mock_enable, ) with mock.patch("pyntc.devices.eos_device.log") as mock_log: - device.remote_file_copy(src) - - # Verify that info log was called for successful transfer - assert any("transferred and verified successfully" in str(call) for call in mock_log.info.call_args_list) - - -# Tests for Task 12: FileCopyModel Validation - - -@pytest.mark.parametrize("algorithm", ["md5", "sha256", "sha512"]) -def test_property_hashing_algorithm_validation(algorithm): - """Feature: arista-remote-file-copy, Property 10: Hashing Algorithm Validation. - - For any unsupported hashing algorithm, FileCopyModel initialization should raise a ValueError. - - Validates: Requirements 6.3, 17.1, 17.2 - """ - from pyntc.utils.models import FileCopyModel - - # Should not raise for supported algorithms - src = FileCopyModel( - download_url="http://server.example.com/file.bin", - checksum="abc123def456", - file_name="file.bin", - hashing_algorithm=algorithm, - ) - - assert src.hashing_algorithm == algorithm - - -def test_property_case_insensitive_algorithm_validation(): - """Feature: arista-remote-file-copy, Property 11: Case-Insensitive Algorithm Validation. + self.device.remote_file_copy(src) + self.assertTrue( + any("transferred and verified successfully" in str(call) for call in mock_log.info.call_args_list) + ) - For any hashing algorithm specified in different cases, the FileCopyModel should accept it as valid. - Validates: Requirements 17.3 - """ - from pyntc.utils.models import FileCopyModel +class TestFileCopyModelValidation(unittest.TestCase): + """Tests for FileCopyModel defaults and validation.""" - # Should accept case-insensitive algorithms - for algorithm in ["MD5", "md5", "Md5", "SHA256", "sha256", "Sha256"]: + def test_default_timeout_value(self): + """Test FileCopyModel default timeout is 900 seconds.""" src = FileCopyModel( download_url="http://server.example.com/file.bin", checksum="abc123def456", file_name="file.bin", - hashing_algorithm=algorithm, ) - - # Verify it was accepted (no exception raised) - assert src.hashing_algorithm.lower() in ["md5", "sha256"] - - -@pytest.mark.parametrize( - "url,expected_username,expected_token", - [ - ("scp://admin:password@server.com/path", "admin", "password"), - ("ftp://user:pass123@ftp.example.com/file", "user", "pass123"), - ], -) -def test_property_url_credential_extraction(url, expected_username, expected_token): - """Feature: arista-remote-file-copy, Property 12: URL Credential Extraction. - - For any URL containing embedded credentials, FileCopyModel should extract username and password. - - Validates: Requirements 3.1, 16.1, 16.2, 16.3, 16.4, 16.5, 16.6 - """ - from pyntc.utils.models import FileCopyModel - - src = FileCopyModel( - download_url=url, - checksum="abc123def456", - file_name="file.bin", - ) - - # Verify credentials were extracted - assert src.username == expected_username - assert src.token == expected_token - - -def test_property_explicit_credentials_override(): - """Feature: arista-remote-file-copy, Property 13: Explicit Credentials Override. - - For any FileCopyModel where both URL-embedded credentials and explicit fields are provided, - the explicit fields should take precedence. - - Validates: Requirements 3.2 - """ - from pyntc.utils.models import FileCopyModel - - src = FileCopyModel( - download_url="scp://url_user:url_pass@server.com/path", - checksum="abc123def456", - file_name="file.bin", - username="explicit_user", - token="explicit_pass", - ) - - # Verify explicit credentials take precedence - assert src.username == "explicit_user" - assert src.token == "explicit_pass" + self.assertEqual(src.timeout, 900) + + def test_ftp_passive_mode_configuration(self): + """Test FileCopyModel ftp_passive flag is set correctly.""" + for ftp_passive in [True, False]: + with self.subTest(ftp_passive=ftp_passive): + src = FileCopyModel( + download_url="ftp://admin:password@ftp.example.com/images/eos.swi", + checksum="abc123def456", + file_name="eos.swi", + ftp_passive=ftp_passive, + ) + self.assertEqual(src.ftp_passive, ftp_passive) + + def test_default_ftp_passive_mode(self): + """Test FileCopyModel default ftp_passive is True.""" + src = FileCopyModel( + download_url="ftp://admin:password@ftp.example.com/images/eos.swi", + checksum="abc123def456", + file_name="eos.swi", + ) + self.assertTrue(src.ftp_passive) + + def test_hashing_algorithm_validation(self): + """Test FileCopyModel accepts supported hashing algorithms.""" + for algorithm in ["md5", "sha256", "sha512"]: + with self.subTest(algorithm=algorithm): + src = FileCopyModel( + download_url="http://server.example.com/file.bin", + checksum="abc123def456", + file_name="file.bin", + hashing_algorithm=algorithm, + ) + self.assertEqual(src.hashing_algorithm, algorithm) + + def test_case_insensitive_algorithm_validation(self): + """Test FileCopyModel accepts algorithms in different cases.""" + for algorithm in ["MD5", "md5", "Md5", "SHA256", "sha256", "Sha256"]: + with self.subTest(algorithm=algorithm): + src = FileCopyModel( + download_url="http://server.example.com/file.bin", + checksum="abc123def456", + file_name="file.bin", + hashing_algorithm=algorithm, + ) + self.assertIn(src.hashing_algorithm.lower(), ["md5", "sha256"]) + + +class TestFileCopyModelCredentials(unittest.TestCase): + """Tests for FileCopyModel credential extraction.""" + + def test_url_credential_extraction(self): + """Test FileCopyModel extracts credentials from URL.""" + test_cases = [ + ("scp://admin:password@server.com/path", "admin", "password"), + ("ftp://user:pass123@ftp.example.com/file", "user", "pass123"), + ] + for url, expected_username, expected_token in test_cases: + with self.subTest(url=url): + src = FileCopyModel( + download_url=url, + checksum="abc123def456", + file_name="file.bin", + ) + self.assertEqual(src.username, expected_username) + self.assertEqual(src.token, expected_token) + + def test_explicit_credentials_override(self): + """Test explicit credentials take precedence over URL-embedded credentials.""" + src = FileCopyModel( + download_url="scp://url_user:url_pass@server.com/path", + checksum="abc123def456", + file_name="file.bin", + username="explicit_user", + token="explicit_pass", + ) + self.assertEqual(src.username, "explicit_user") + self.assertEqual(src.token, "explicit_pass") From c4a959fcdff41f10fce562118d1ad6adb4cc7eb1 Mon Sep 17 00:00:00 2001 From: Matt Miller Date: Tue, 14 Apr 2026 17:26:58 -0400 Subject: [PATCH 6/9] Add remote file copy for cisco nxos devices (#367) * Add remote file copy for cisco nxos devices --- changes/367.added | 2 + pyntc/devices/nxos_device.py | 386 +++++++++++++++++++- pyntc/log.py | 3 +- tests/unit/test_devices/test_nxos_device.py | 132 ++++++- tests/unit/test_infra.py | 6 +- 5 files changed, 518 insertions(+), 11 deletions(-) create mode 100644 changes/367.added diff --git a/changes/367.added b/changes/367.added new file mode 100644 index 00000000..bbc5ba06 --- /dev/null +++ b/changes/367.added @@ -0,0 +1,2 @@ +Added remote file copy feature to Cisco NXOS devices. +Added unittests for remote file copy for Cisco NXOS devices. \ No newline at end of file diff --git a/pyntc/devices/nxos_device.py b/pyntc/devices/nxos_device.py index 1517c28d..adc8bfc5 100644 --- a/pyntc/devices/nxos_device.py +++ b/pyntc/devices/nxos_device.py @@ -4,6 +4,7 @@ import re import time +from netmiko import ConnectHandler from pynxos.device import Device as NXOSNative from pynxos.errors import CLIError from pynxos.features.file_copy import FileTransferError as NXOSFileTransferError @@ -14,11 +15,16 @@ from pyntc.errors import ( CommandError, CommandListError, + FileSystemNotFoundError, FileTransferError, NTCFileNotFoundError, OSInstallError, RebootTimeoutError, ) +from pyntc.utils.models import FileCopyModel + +NXOS_SUPPORTED_HASHING_ALGORITHMS = {"md5", "sha256", "sha512", "chk"} +NXOS_SUPPORTED_SCHEMES = {"http", "https", "scp", "ftp", "sftp", "tftp"} @fix_docs @@ -45,9 +51,18 @@ def __init__(self, host, username, password, transport="http", timeout=30, port= super().__init__(host, username, password, device_type="cisco_nxos_nxapi") self.transport = transport self.timeout = timeout + self.port = port + self.verify = verify + # Use self.native for NXAPI self.native = NXOSNative( host, username, password, transport=transport, timeout=timeout, port=port, verify=verify ) + # Use self.native_ssh for Netmiko SSH + self.native_ssh = None + self._connected = False + self._redundancy_state = None + self._active_redundancy_states = None + self.open() log.init(host=host) def _image_booted(self, image_name, **vendor_specifics): @@ -104,9 +119,12 @@ def checkpoint(self, filename): log.debug("Host %s: checkpoint is %s.", self.host, filename) return self.native.checkpoint(filename) - def close(self): # noqa: D401 - """Implements ``pass``.""" - pass # pylint: disable=unnecessary-pass + def close(self): + """Disconnect from device.""" + if self.connected: + self.native_ssh.disconnect() + self._connected = False + log.debug("Host %s: Connection closed.", self.host) def config(self, command): """Send configuration command. @@ -300,6 +318,288 @@ def file_copy_remote_exists(self, src, dest=None, file_system="bootflash:"): ) return self.native.file_copy_remote_exists(src, dest, file_system=file_system) + def _get_file_system(self): + """Determine the default file system or directory for device. + + Returns: + (str): The name of the default file system or directory for the device. + + Raises: + FileSystemNotFoundError: When the module is unable to determine the default file system. + """ + raw_data = self.show("dir", raw_text=True) + try: + file_system = re.search(r"bootflash:", raw_data).group(0) + except AttributeError: + log.error("Host %s: File system not found with command 'dir'.", self.host) + raise FileSystemNotFoundError(hostname=self.host, command="dir") + + log.debug("Host %s: File system %s.", self.host, file_system) + return file_system + + @staticmethod + def _netloc(src: FileCopyModel) -> str: + """Return host:port or just host from a FileCopyModel.""" + return f"{src.hostname}:{src.port}" if src.port else src.hostname + + @staticmethod + def _source_path(src: FileCopyModel, dest: str) -> str: + """Return the file path from the URL, falling back to dest if empty.""" + return src.path if src.path and src.path != "/" else f"/{dest}" + + def _build_url_copy_command_simple(self, src, file_system, dest): + """Build copy command for simple URL-based transfers (TFTP, HTTP, HTTPS without credentials).""" + netloc = self._netloc(src) + path = self._source_path(src, dest) + return f"copy {src.scheme}://{netloc}{path} {file_system}", False + + def _build_url_copy_command_with_creds(self, src, file_system, dest): + """Build copy command for URL-based transfers with credentials (HTTP/HTTPS/SCP/FTP/SFTP).""" + netloc = self._netloc(src) + path = self._source_path(src, dest) + + if src.scheme in ("http", "https"): + command = f"copy {src.scheme}://{src.username}:{src.token}@{netloc}{path} {file_system}" + else: + # SCP/FTP/SFTP — password provided at the interactive prompt + command = f"copy {src.scheme}://{src.username}@{netloc}{path} {file_system}" + + return command + + def check_file_exists(self, filename, file_system=None): + """Check if a remote file exists by filename. + + Args: + filename (str): The name of the file to check for on the remote device. + file_system (str): Supported only for Arista. The file system for the + remote file. If no file_system is provided, then the `get_file_system` + method is used to determine the correct file system to use. + + Returns: + (bool): True if the remote file exists, False if it doesn't. + + Raises: + CommandError: If there is an error in executing the command to check if the file exists. + """ + exists = False + + self.open() + file_system = file_system or self._get_file_system() + command = f"dir {file_system}/{filename}" + result = self.native_ssh.send_command(command, read_timeout=30) + + log.debug( + "Host %s: Checking if file %s exists on remote with command '%s' and result: %s", + self.host, + filename, + command, + result, + ) + + # Check for error patterns + if re.search(r"% Error listing directory|No such file|No files found|Path does not exist", result): + log.debug("Host %s: File %s does not exist on remote.", self.host, filename) + exists = False + elif filename in result: + # NXOS shows file details directly, just check if filename appears in output + log.debug("Host %s: File %s exists on remote.", self.host, filename) + exists = True + else: + raise CommandError(command, f"Unable to determine if file {filename} exists on remote: {result}") + + return exists + + def get_remote_checksum(self, filename, hashing_algorithm="md5", **kwargs): + """Get the checksum of a remote file on Cisco NXOS device using netmiko SSH. + + Uses NXOS's 'show file' command via SSH to compute file checksums. + + Args: + filename (str): The name of the file to check for on the remote device. + hashing_algorithm (str): The hashing algorithm to use (default: "md5"). + **kwargs (Any): Passible parameters such as file_system. + + Returns: + (str): The checksum of the remote file. + + Raises: + CommandError: If the verify command fails (but not if file doesn't exist). + """ + if hashing_algorithm not in NXOS_SUPPORTED_HASHING_ALGORITHMS: + raise ValueError( + f"Unsupported hashing algorithm '{hashing_algorithm}' for NXOS. " + f"Supported algorithms: {sorted(NXOS_SUPPORTED_HASHING_ALGORITHMS)}" + ) + + self.open() + file_system = kwargs.get("file_system") + if file_system is None: + file_system = self._get_file_system() + + # Normalize file_system + if not file_system.startswith("/") and not file_system.endswith(":"): + file_system = f"{file_system}:" + + # Use NXOS verify command to get the checksum + # Example: show file bootflash:nautobot.png sha512sum + command = f"show file {file_system}/{filename} {hashing_algorithm}sum" + + try: + result = self.native_ssh.send_command(command, read_timeout=30) + log.debug( + "Host %s: Getting remote checksum for file %s with command '%s' and result: %s", + self.host, + filename, + command, + result, + ) + print(f"result: {result}") + remote_checksum = result + return remote_checksum + + except Exception as e: + log.error("Host %s: Error getting remote checksum: %s", self.host, str(e)) + raise CommandError(command, f"Error getting remote checksum: {str(e)}") + + def remote_file_copy(self, src: FileCopyModel, dest=None, file_system=None, **kwargs): # noqa: R0912 pylint: disable=too-many-branches + """Copy a file from remote source to device. Skips if file already exists and is verified on remote device. + + Args: + src (FileCopyModel): The source file model with transfer parameters. + dest (str): Destination filename (defaults to src.file_name). + file_system (str): Device filesystem (auto-detected if not provided). + **kwargs (Any): Passible parameters such as file_system. + + Raises: + TypeError: If src is not a FileCopyModel. + FileTransferError: If transfer or verification fails. + FileSystemNotFoundError: If filesystem cannot be determined. + """ + timeout = src.timeout or 30 + + if not isinstance(src, FileCopyModel): + raise TypeError("src must be an instance of FileCopyModel") + + if src.scheme not in NXOS_SUPPORTED_SCHEMES: + raise ValueError( + f"Unsupported URL scheme '{src.scheme}' in src. Supported schemes: {sorted(NXOS_SUPPORTED_SCHEMES)}" + ) + + if "?" in src.clean_url: + raise ValueError(f"URLs with query strings are not supported on NXOS: {src.download_url}") + + if file_system is None: + file_system = self._get_file_system() + + if dest is None: + dest = src.file_name + + if src.scheme == "tftp" or src.username is None: + command = self._build_url_copy_command_simple(src, file_system, dest) + else: + command = self._build_url_copy_command_with_creds(src, file_system, dest) + log.debug("Host %s: Preparing copy command for %s", self.host, src.scheme) + + # Add VRF if specified + if src.vrf: + command += f" vrf {src.vrf}" + + log.debug( + "Host %s: Verifying file %s exists on filesystem %s before attempting a copy", + self.host, + dest, + file_system, + ) + if not self.verify_file(src.checksum, dest, hashing_algorithm=src.hashing_algorithm, file_system=file_system): + current_prompt = self.native_ssh.find_prompt() + + # Define prompt mapping for expected prompts during file copy + prompt_answers = { + r"Password": src.token or "", + r"Source username": src.username or "", + r"yes/no|Are you sure you want to continue connecting": "yes", + r"(confirm|Address or name of remote host|Source filename|Destination filename)": "", + } + keys = list(prompt_answers.keys()) + [current_prompt] + expect_regex = f"({'|'.join(keys)})" + + log.debug("Host %s: Starting remote file copy for %s to %s/%s", self.host, src.file_name, file_system, dest) + output = self.native_ssh.send_command(command, expect_string=expect_regex, read_timeout=timeout) + + while current_prompt not in output: + # Check for success message in output to break loop and avoid waiting for next prompt + if re.search(r"Copy complete|bytes copied in|File transfer successful", output, re.IGNORECASE): + log.info( + "Host %s: File %s transferred successfully with output: %s", self.host, src.file_name, output + ) + break + # Check for errors explicitly to avoid infinite loops on failure + if re.search(r"(Error|Invalid|Failed|Aborted|denied)", output, re.IGNORECASE): + log.error("Host %s: File transfer error %s", self.host, FileTransferError.default_message) + raise FileTransferError + for prompt, answer in prompt_answers.items(): + if re.search(prompt, output, re.IGNORECASE): + is_password = "Password" in prompt + output = self.native_ssh.send_command( + answer, expect_string=expect_regex, read_timeout=timeout, cmd_verify=not is_password + ) + break # Exit the for loop and check the new output for the next prompt + + # Verify file after transfer + if not self.verify_file( + src.checksum, dest, hashing_algorithm=src.hashing_algorithm, file_system=file_system + ): + log.error( + "Host %s: File verification failed after transfer for file %s", + self.host, + dest, + ) + raise FileTransferError("File verification failed after transfer") + + log.info( + "Host %s: File %s transferred successfully.", + self.host, + dest, + ) + else: + log.info( + "Host %s: File %s already exists on remote and passed verification. File copy not performed.", + self.host, + dest, + ) + + def verify_file(self, checksum, filename, hashing_algorithm="md5", file_system=None, **kwargs): + """Verify a file on the device by comparing checksums. + + Args: + checksum (str): The expected checksum of the file. + filename (str): The name of the file on the device. + hashing_algorithm (str): The hashing algorithm to use (default: "md5"). + file_system (str): The file system where the file is located. + **kwargs (Any): Passible parameters such as file_system. + + Returns: + (bool): True if the file is verified successfully, False otherwise. + """ + exists = self.check_file_exists(filename, file_system=file_system, **kwargs) + device_checksum = ( + self.get_remote_checksum(filename, hashing_algorithm=hashing_algorithm, file_system=file_system, **kwargs) + if exists + else None + ) + if checksum == device_checksum: + log.debug("Host %s: Checksum verification successful for file %s", self.host, filename) + return True + + log.debug( + "Host %s: Checksum verification failed for file %s - Expected: %s, Actual: %s", + self.host, + filename, + checksum, + device_checksum, + ) + return False + def install_os(self, image_name, **vendor_specifics): """Upgrade device with provided image. @@ -329,9 +629,83 @@ def install_os(self, image_name, **vendor_specifics): log.info("Host %s: Image %s is already running on the device.", self.host, image_name) return False - def open(self): # noqa: D401 - """Implements ``pass``.""" - pass # pylint: disable=unnecessary-pass + @property + def connected(self): # noqa: D401 + """ + Get connection status of the device. + + Returns: + (bool): True if the device is connected, else False. + """ + return self._connected + + @connected.setter + def connected(self, value): + self._connected = value + + @property + def redundancy_state(self): + """Get redundancy state of the device. + + Returns: + (str): Redundancy state of the device (e.g., "active", "standby", "init"). + """ + if self._redundancy_state is None: + try: + output = self.native.show("show redundancy state", raw_text=True) + # Parse the redundancy state from output + # Example output: "Redundancy state = active" + match = re.search(r"Redundancy\s+state\s*=\s*(\w+)", output, re.IGNORECASE) + if match: + self._redundancy_state = match.group(1).lower() + else: + # If no redundancy info, device may not support HA + self._redundancy_state = "active" + except CLIError: + # If command fails, assume active (non-HA or error condition) + self._redundancy_state = "active" + + return self._redundancy_state + + @property + def active_redundancy_states(self): + """Get list of states that indicate the device is active. + + Returns: + (list): List of active redundancy states. + """ + if self._active_redundancy_states is None: + self._active_redundancy_states = ["active", "master"] + return self._active_redundancy_states + + def is_active(self): + """ + Determine if the current processor is the active processor. + + Returns: + (bool): True if the processor is active or does not support HA, else False. + """ + return self.redundancy_state in self.active_redundancy_states + + def open(self): + """Open a connection to the network device.""" + if self.connected: + try: + self.native_ssh.find_prompt() + except: # noqa E722 # pylint: disable=bare-except + self._connected = False + + if not self.connected: + self.native_ssh = ConnectHandler( + device_type="cisco_nxos", + host=self.host, + username=self.username, + password=self.password, + timeout=self.timeout, + ) + self._connected = True + + log.debug("Host %s: SSH connection opened successfully.", self.host) def reboot(self, wait_for_reload=False, **kwargs): """ diff --git a/pyntc/log.py b/pyntc/log.py index 79b2f423..13c57744 100644 --- a/pyntc/log.py +++ b/pyntc/log.py @@ -51,10 +51,11 @@ def init(**kwargs): kwargs.setdefault("format", log_format) kwargs.setdefault("level", log_level) + host = kwargs.pop("host", None) logging.basicConfig(**kwargs) # info is defined at the end of the file - info("Logging initialized for host %s.", kwargs.pop("host", None)) + info("Logging initialized for host %s.", host) def logger(level): diff --git a/tests/unit/test_devices/test_nxos_device.py b/tests/unit/test_devices/test_nxos_device.py index cd45730c..8109a195 100644 --- a/tests/unit/test_devices/test_nxos_device.py +++ b/tests/unit/test_devices/test_nxos_device.py @@ -5,7 +5,14 @@ from pyntc.devices.base_device import RollbackError from pyntc.devices.nxos_device import NXOSDevice -from pyntc.errors import CommandError, CommandListError, FileTransferError, NTCFileNotFoundError +from pyntc.errors import ( + CommandError, + CommandListError, + FileSystemNotFoundError, + FileTransferError, + NTCFileNotFoundError, +) +from pyntc.utils.models import FileCopyModel from .device_mocks.nxos import show, show_list @@ -26,15 +33,19 @@ class TestNXOSDevice(unittest.TestCase): + @mock.patch("pyntc.devices.nxos_device.ConnectHandler", create=True) @mock.patch("pyntc.devices.nxos_device.NXOSNative", autospec=True) @mock.patch("pynxos.device.Device.facts", new_callable=mock.PropertyMock) - def setUp(self, mock_device, mock_facts): + def setUp(self, mock_facts, mock_device, mock_connect_handler): + self.mock_native_ssh = mock_connect_handler.return_value self.device = NXOSDevice("host", "user", "pass") mock_device.show.side_effect = show mock_device.show_list.side_effect = show_list mock_facts.return_value = DEVICE_FACTS self.device.native = mock_device + self.device.native_ssh = self.mock_native_ssh + self.device.native._facts = {} type(self.device.native).facts = mock_facts.return_value def test_config(self): @@ -256,6 +267,123 @@ def test_refresh(self): self.assertIsNone(self.device._uptime) self.assertFalse(hasattr(self.device.native, "_facts")) + @mock.patch.object(NXOSDevice, "show", return_value="bootflash:") + def test_get_file_system(self, mock_show): + self.assertEqual(self.device._get_file_system(), "bootflash:") + mock_show.assert_called_with("dir", raw_text=True) + + @mock.patch.object(NXOSDevice, "show", return_value="no filesystems here") + def test_get_file_system_not_found(self, mock_show): + with self.assertRaises(FileSystemNotFoundError): + self.device._get_file_system() + mock_show.assert_called_with("dir", raw_text=True) + + def test_check_file_exists_true(self): + self.device.native_ssh.send_command.return_value = "12345 bootflash:/nxos.bin" + result = self.device.check_file_exists("nxos.bin", file_system="bootflash:") + self.assertTrue(result) + self.device.native_ssh.send_command.assert_called_with("dir bootflash:/nxos.bin", read_timeout=30) + + def test_check_file_exists_false(self): + self.device.native_ssh.send_command.return_value = "No such file or directory" + result = self.device.check_file_exists("nxos.bin", file_system="bootflash:") + self.assertFalse(result) + self.device.native_ssh.send_command.assert_called_with("dir bootflash:/nxos.bin", read_timeout=30) + + def test_check_file_exists_command_error(self): + self.device.native_ssh.send_command.return_value = "some ambiguous output" + with self.assertRaises(CommandError): + self.device.check_file_exists("nxos.bin", file_system="bootflash:") + + def test_get_remote_checksum(self): + self.device.native_ssh.send_command.return_value = "abc123" + result = self.device.get_remote_checksum("nxos.bin", hashing_algorithm="md5", file_system="bootflash:") + self.assertEqual(result, "abc123") + self.device.native_ssh.send_command.assert_called_with("show file bootflash:/nxos.bin md5sum", read_timeout=30) + + def test_get_remote_checksum_invalid_algorithm(self): + with self.assertRaises(ValueError): + self.device.get_remote_checksum("nxos.bin", hashing_algorithm="sha1", file_system="bootflash:") + + def test_verify_file_true(self): + with ( + mock.patch.object(NXOSDevice, "check_file_exists", return_value=True), + mock.patch.object(NXOSDevice, "get_remote_checksum", return_value="abc123"), + ): + result = self.device.verify_file("abc123", "nxos.bin", file_system="bootflash:") + self.assertTrue(result) + + def test_verify_file_false(self): + with ( + mock.patch.object(NXOSDevice, "check_file_exists", return_value=True), + mock.patch.object(NXOSDevice, "get_remote_checksum", return_value="different"), + ): + result = self.device.verify_file("abc123", "nxos.bin", file_system="bootflash:") + self.assertFalse(result) + + def test_remote_file_copy_existing_verified_file(self): + src = FileCopyModel( + download_url="http://example.com/nxos.bin", + checksum="abc123", + file_name="nxos.bin", + hashing_algorithm="md5", + timeout=30, + ) + with mock.patch.object(NXOSDevice, "verify_file", return_value=True) as verify_mock: + self.device.remote_file_copy(src, file_system="bootflash:") + verify_mock.assert_called_once_with("abc123", "nxos.bin", hashing_algorithm="md5", file_system="bootflash:") + self.device.native_ssh.send_command.assert_not_called() + + def test_remote_file_copy_transfer_success(self): + src = FileCopyModel( + download_url="http://example.com/nxos.bin", + checksum="abc123", + file_name="nxos.bin", + hashing_algorithm="md5", + timeout=30, + ) + self.device.native_ssh.find_prompt.return_value = "host#" + self.device.native_ssh.send_command.return_value = "Copy complete" + with mock.patch.object(NXOSDevice, "verify_file", side_effect=[False, True]): + self.device.remote_file_copy(src, file_system="bootflash:") + self.device.native_ssh.send_command.assert_called_once() + + def test_remote_file_copy_transfer_fails_verification(self): + src = FileCopyModel( + download_url="http://example.com/nxos.bin", + checksum="abc123", + file_name="nxos.bin", + hashing_algorithm="md5", + timeout=30, + ) + self.device.native_ssh.find_prompt.return_value = "host#" + self.device.native_ssh.send_command.return_value = "Copy complete" + with mock.patch.object(NXOSDevice, "verify_file", side_effect=[False, False]): + with self.assertRaises(FileTransferError): + self.device.remote_file_copy(src, file_system="bootflash:") + + def test_remote_file_copy_invalid_scheme(self): + src = FileCopyModel( + download_url="smtp://example.com/nxos.bin", + checksum="abc123", + file_name="nxos.bin", + hashing_algorithm="md5", + timeout=30, + ) + with self.assertRaises(ValueError): + self.device.remote_file_copy(src, file_system="bootflash:") + + def test_remote_file_copy_query_string_not_supported(self): + src = FileCopyModel( + download_url="https://example.com/nxos.bin?token=foo", + checksum="abc123", + file_name="nxos.bin", + hashing_algorithm="md5", + timeout=30, + ) + with self.assertRaises(ValueError): + self.device.remote_file_copy(src, file_system="bootflash:") + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/test_infra.py b/tests/unit/test_infra.py index 36568f3f..881bd12a 100644 --- a/tests/unit/test_infra.py +++ b/tests/unit/test_infra.py @@ -15,10 +15,11 @@ @mock.patch("pyntc.devices.f5_device.ManagementRoot") @mock.patch("pyntc.devices.asa_device.ASADevice.open") @mock.patch("pyntc.devices.ios_device.IOSDevice.open") +@mock.patch("pyntc.devices.nxos_device.NXOSDevice.open") @mock.patch("pyntc.devices.jnpr_device.JunosNativeSW") @mock.patch("pyntc.devices.jnpr_device.JunosNativeDevice.open") @mock.patch("pyntc.devices.jnpr_device.JunosNativeDevice.timeout") -def test_device_creation(j_timeout, j_open, j_nsw, i_open, a_open, f_mr, air_open, device_type, expected): +def test_device_creation(j_timeout, j_open, j_nsw, nx_open, i_open, a_open, f_mr, air_open, device_type, expected): device = ntc_device(device_type, "host", "user", "pass") assert isinstance(device, expected) @@ -29,8 +30,9 @@ def test_unsupported_device(): @mock.patch("pyntc.devices.ios_device.IOSDevice.open") +@mock.patch("pyntc.devices.nxos_device.NXOSDevice.open") @mock.patch("pyntc.devices.jnpr_device.JunosDevice.open") -def test_device_by_name(j_open, i_open): +def test_device_by_name(j_open, nx_open, i_open): config_filepath = os.path.join(FIXTURES_DIR, ".ntc.conf.sample") nxos_device = ntc_device_by_name("test_nxos", filename=config_filepath) From 917283a33ea1021a3fd190483b12b0c942bb38cc Mon Sep 17 00:00:00 2001 From: itdependsnetworks Date: Tue, 14 Apr 2026 18:33:26 -0400 Subject: [PATCH 7/9] Release v2.3.0 --- changes/364.removed | 2 -- changes/365.added | 2 -- changes/366.added | 1 - changes/366.fixed | 3 -- changes/367.added | 2 -- changes/368.changed | 8 ----- changes/368.housekeeping | 3 -- docs/admin/release_notes/version_2.3.md | 46 +++++++++++++++++++++++++ poetry.lock | 4 +-- pyproject.toml | 4 +-- 10 files changed, 50 insertions(+), 25 deletions(-) delete mode 100644 changes/364.removed delete mode 100644 changes/365.added delete mode 100644 changes/366.added delete mode 100644 changes/366.fixed delete mode 100644 changes/367.added delete mode 100644 changes/368.changed delete mode 100644 changes/368.housekeeping create mode 100644 docs/admin/release_notes/version_2.3.md diff --git a/changes/364.removed b/changes/364.removed deleted file mode 100644 index 37f67239..00000000 --- a/changes/364.removed +++ /dev/null @@ -1,2 +0,0 @@ -Removed log.init from iosxewlc device. -Removed warning filter for logging. \ No newline at end of file diff --git a/changes/365.added b/changes/365.added deleted file mode 100644 index b3d9ecf0..00000000 --- a/changes/365.added +++ /dev/null @@ -1,2 +0,0 @@ -Added the remote file copy feature to Arista EOS devices. -Added unittests for remote file copy on Arista EOS devices. \ No newline at end of file diff --git a/changes/366.added b/changes/366.added deleted file mode 100644 index e3d75291..00000000 --- a/changes/366.added +++ /dev/null @@ -1 +0,0 @@ -Added ``remote_file_copy``, ``check_file_exists``, ``get_remote_checksum``, and ``verify_file`` support for ``ASADevice`` (FTP, TFTP, SCP, HTTP, HTTPS). diff --git a/changes/366.fixed b/changes/366.fixed deleted file mode 100644 index ccfec3bb..00000000 --- a/changes/366.fixed +++ /dev/null @@ -1,3 +0,0 @@ -Fixed ``ASADevice._get_file_system`` to use ``re.search`` instead of ``re.match`` so the filesystem label is correctly parsed regardless of leading whitespace in ``dir`` output. -Fixed ``ASADevice._send_command`` to anchor the ``%`` error pattern to the start of a line (``^% ``) to prevent false-positive ``CommandError`` raises during file copy operations. -Fixed ``ASADevice.active_redundancy_states`` to include ``"disabled"`` so standalone (non-failover) units are correctly treated as active. diff --git a/changes/367.added b/changes/367.added deleted file mode 100644 index bbc5ba06..00000000 --- a/changes/367.added +++ /dev/null @@ -1,2 +0,0 @@ -Added remote file copy feature to Cisco NXOS devices. -Added unittests for remote file copy for Cisco NXOS devices. \ No newline at end of file diff --git a/changes/368.changed b/changes/368.changed deleted file mode 100644 index 494cf9b1..00000000 --- a/changes/368.changed +++ /dev/null @@ -1,8 +0,0 @@ -Improved EOS remote file copy to validate scheme and query strings before connecting, use `clean_url` to prevent credential leakage, and simplify credential routing. -Changed copy command builders to include the source file path in the URL and use `flash:` as the destination, matching EOS CLI conventions. -Fixed `_uptime_to_string` to use integer division, preventing `ValueError` on format specifiers. -Fixed `check_file_exists` and `get_remote_checksum` to open the SSH connection before use, preventing `AttributeError` when called standalone. -Fixed password-prompt handling in `remote_file_copy` to wait for the transfer to complete before proceeding to verification. -Simplified checksum parsing in `get_remote_checksum` to use string splitting instead of regex. -Changed `verify_file` to return early when file does not exist and use case-insensitive checksum comparison. -Removed `include_username` parameter from `remote_file_copy` in favor of automatic credential routing based on scheme and username presence. diff --git a/changes/368.housekeeping b/changes/368.housekeeping deleted file mode 100644 index 406ca955..00000000 --- a/changes/368.housekeeping +++ /dev/null @@ -1,3 +0,0 @@ -Converted EOS remote file copy tests from hypothesis/pytest standalone functions to unittest TestCase with `self.assertRaises` and `subTest` for consistency with the rest of the codebase. -Removed duplicate test class `TestRemoteFileCopyCommandExecution` and consolidated into `TestRemoteFileCopy`. -Added integration tests for EOS device connectivity and remote file copy across FTP, TFTP, SCP, HTTP, HTTPS, and SFTP protocols. diff --git a/docs/admin/release_notes/version_2.3.md b/docs/admin/release_notes/version_2.3.md new file mode 100644 index 00000000..89947b08 --- /dev/null +++ b/docs/admin/release_notes/version_2.3.md @@ -0,0 +1,46 @@ + +# v2.3 Release Notes + +This document describes all new features and changes in the release. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## Release Overview + +- Added the ability to for remote file copy on Cisco NXOS, Cisco ASA, and Arista EOS operating systems. + +## [v2.3.0 (2026-04-14)](https://github.com/networktocode/pyntc/releases/tag/v2.3.0) + +### Added + +- [#365](https://github.com/networktocode/pyntc/issues/365) - Added the remote file copy feature to Arista EOS devices. +- [#365](https://github.com/networktocode/pyntc/issues/365) - Added unittests for remote file copy on Arista EOS devices. +- [#366](https://github.com/networktocode/pyntc/issues/366) - Added ``remote_file_copy``, ``check_file_exists``, ``get_remote_checksum``, and ``verify_file`` support for ``ASADevice`` (FTP, TFTP, SCP, HTTP, HTTPS). +- [#367](https://github.com/networktocode/pyntc/issues/367) - Added remote file copy feature to Cisco NXOS devices. +- [#367](https://github.com/networktocode/pyntc/issues/367) - Added unittests for remote file copy for Cisco NXOS devices. + +### Changed + +- [#368](https://github.com/networktocode/pyntc/issues/368) - Improved EOS remote file copy to validate scheme and query strings before connecting, use `clean_url` to prevent credential leakage, and simplify credential routing. +- [#368](https://github.com/networktocode/pyntc/issues/368) - Changed copy command builders to include the source file path in the URL and use `flash:` as the destination, matching EOS CLI conventions. +- [#368](https://github.com/networktocode/pyntc/issues/368) - Fixed `_uptime_to_string` to use integer division, preventing `ValueError` on format specifiers. +- [#368](https://github.com/networktocode/pyntc/issues/368) - Fixed `check_file_exists` and `get_remote_checksum` to open the SSH connection before use, preventing `AttributeError` when called standalone. +- [#368](https://github.com/networktocode/pyntc/issues/368) - Fixed password-prompt handling in `remote_file_copy` to wait for the transfer to complete before proceeding to verification. +- [#368](https://github.com/networktocode/pyntc/issues/368) - Simplified checksum parsing in `get_remote_checksum` to use string splitting instead of regex. +- [#368](https://github.com/networktocode/pyntc/issues/368) - Changed `verify_file` to return early when file does not exist and use case-insensitive checksum comparison. +- [#368](https://github.com/networktocode/pyntc/issues/368) - Removed `include_username` parameter from `remote_file_copy` in favor of automatic credential routing based on scheme and username presence. + +### Removed + +- [#364](https://github.com/networktocode/pyntc/issues/364) - Removed log.init from iosxewlc device. +- [#364](https://github.com/networktocode/pyntc/issues/364) - Removed warning filter for logging. + +### Fixed + +- [#366](https://github.com/networktocode/pyntc/issues/366) - Fixed ``ASADevice._get_file_system`` to use ``re.search`` instead of ``re.match`` so the filesystem label is correctly parsed regardless of leading whitespace in ``dir`` output. +- [#366](https://github.com/networktocode/pyntc/issues/366) - Fixed ``ASADevice._send_command`` to anchor the ``%`` error pattern to the start of a line (``^% ``) to prevent false-positive ``CommandError`` raises during file copy operations. +- [#366](https://github.com/networktocode/pyntc/issues/366) - Fixed ``ASADevice.active_redundancy_states`` to include ``"disabled"`` so standalone (non-failover) units are correctly treated as active. + +### Housekeeping + +- [#368](https://github.com/networktocode/pyntc/issues/368) - Converted EOS remote file copy tests from hypothesis/pytest standalone functions to unittest TestCase with `self.assertRaises` and `subTest` for consistency with the rest of the codebase. +- [#368](https://github.com/networktocode/pyntc/issues/368) - Removed duplicate test class `TestRemoteFileCopyCommandExecution` and consolidated into `TestRemoteFileCopy`. +- [#368](https://github.com/networktocode/pyntc/issues/368) - Added integration tests for EOS device connectivity and remote file copy across FTP, TFTP, SCP, HTTP, HTTPS, and SFTP protocols. diff --git a/poetry.lock b/poetry.lock index a83e0374..3438abb1 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. [[package]] name = "astroid" @@ -2271,7 +2271,7 @@ jinja2 = "*" tomli = {version = "*", markers = "python_version < \"3.11\""} [package.extras] -dev = ["furo (>=2024.5.6)", "nox", "packaging", "sphinx (>=5)", "twisted"] +dev = ["furo (>=2024.05.06)", "nox", "packaging", "sphinx (>=5)", "twisted"] [[package]] name = "transitions" diff --git a/pyproject.toml b/pyproject.toml index 9451d4bb..22c7328c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "pyntc" -version = "2.2.2a0" +version = "2.3.0" description = "Python library focused on tasks related to device level and OS management." authors = ["Network to Code, LLC "] readme = "README.md" @@ -172,7 +172,7 @@ addopts = "-vv --doctest-modules -p no:warnings --ignore-glob='*mock*'" [tool.towncrier] package = "pyntc" directory = "changes" -filename = "docs/admin/release_notes/version_2.2.md" +filename = "docs/admin/release_notes/version_2.3.md" template = "towncrier_template.j2" start_string = "" issue_format = "[#{issue}](https://github.com/networktocode/pyntc/issues/{issue})" From 1480f64ea880fb015830118998b99d18646ccbb5 Mon Sep 17 00:00:00 2001 From: Ken Celenza Date: Tue, 14 Apr 2026 19:23:16 -0400 Subject: [PATCH 8/9] Apply suggestion from @itdependsnetworks --- docs/user/lib_getting_started.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user/lib_getting_started.md b/docs/user/lib_getting_started.md index 2bf2bc4e..d067cb35 100644 --- a/docs/user/lib_getting_started.md +++ b/docs/user/lib_getting_started.md @@ -252,7 +252,7 @@ interface GigabitEthernet1 #### Remote File Copy (Download to Device) -Some devices support copying files directly from a URL to the device. This is useful for large files like OS images. To do this, you need to use the `FileCopyModel` data model to specify the source file information and then pass that to the `remote_file_copy` method. The model is currently supported on Cisco IOS, Juniper Junos, and Arista EOS devices. It has been tested with ftp, http, https, sftp, and tftp urls. +Some devices support copying files directly from a URL to the device. This is useful for large files like OS images. To do this, you need to use the `FileCopyModel` data model to specify the source file information and then pass that to the `remote_file_copy` method. The model is currently supported on Arista EOS, Cisco ASA, Cisco IOS, Cisco NXOS, and Juniper Junos devices. It has been tested with ftp, http, https, sftp, and tftp urls. - `remote_file_copy` method From 5a65c5dfd3bcd6c70471bc39ea9a53ceabf6a38d Mon Sep 17 00:00:00 2001 From: itdependsnetworks Date: Tue, 14 Apr 2026 20:00:36 -0400 Subject: [PATCH 9/9] update doc ordering --- mkdocs.yml | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/mkdocs.yml b/mkdocs.yml index b34481c5..72ccace2 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -132,18 +132,19 @@ nav: - Uninstall: "admin/uninstall.md" - Release Notes: - "admin/release_notes/index.md" - - v0.0: "admin/release_notes/version_0.0.md" - - v0.14: "admin/release_notes/version_0.14.md" - - v0.15: "admin/release_notes/version_0.15.md" - - v0.16: "admin/release_notes/version_0.16.md" - - v0.17: "admin/release_notes/version_0.17.md" - - v0.18: "admin/release_notes/version_0.18.md" - - v0.19: "admin/release_notes/version_0.19.md" - - v0.20: "admin/release_notes/version_0.20.md" - - v1.0: "admin/release_notes/version_1.0.md" - - v2.0: "admin/release_notes/version_2.0.md" - - v2.1: "admin/release_notes/version_2.1.md" + - v2.3: "admin/release_notes/version_2.3.md" - v2.2: "admin/release_notes/version_2.2.md" + - v2.1: "admin/release_notes/version_2.1.md" + - v2.0: "admin/release_notes/version_2.0.md" + - v1.0: "admin/release_notes/version_1.0.md" + - v0.20: "admin/release_notes/version_0.20.md" + - v0.19: "admin/release_notes/version_0.19.md" + - v0.18: "admin/release_notes/version_0.18.md" + - v0.17: "admin/release_notes/version_0.17.md" + - v0.16: "admin/release_notes/version_0.16.md" + - v0.15: "admin/release_notes/version_0.15.md" + - v0.14: "admin/release_notes/version_0.14.md" + - v0.0: "admin/release_notes/version_0.0.md" - Developer Guide: - Extending the Library: "dev/extending.md" - Contributing to the Library: "dev/contributing.md"