diff --git a/changes/375.added b/changes/375.added new file mode 100644 index 00000000..83933004 --- /dev/null +++ b/changes/375.added @@ -0,0 +1 @@ +Added free space validation for file copy operations on NXOS devices. \ No newline at end of file diff --git a/pyntc/devices/nxos_device.py b/pyntc/devices/nxos_device.py index adc8bfc5..a8b3f0ab 100644 --- a/pyntc/devices/nxos_device.py +++ b/pyntc/devices/nxos_device.py @@ -279,6 +279,7 @@ def file_copy(self, src, dest=None, file_system="bootflash:"): """ if not self.file_copy_remote_exists(src, dest, file_system): dest = dest or os.path.basename(src) + self._check_free_space(os.path.getsize(src), file_system=file_system) try: file_copy = self.native.file_copy( # pylint: disable=assignment-from-no-return src, dest, file_system=file_system @@ -337,6 +338,22 @@ def _get_file_system(self): log.debug("Host %s: File system %s.", self.host, file_system) return file_system + def _get_free_space(self, file_system=None): + """Return free bytes on ``file_system`` as reported by NXOS ``dir`` output.""" + if file_system is None: + file_system = self._get_file_system() + + raw_data = self.show(f"dir {file_system}", raw_text=True) + # Example NXOS dir output: 47171194880 bytes free + match = re.search(r"(\d+)\s+bytes\s+free", raw_data) + if match is None: + log.error("Host %s: could not parse free space from '%s'.", self.host, f"dir {file_system}") + raise CommandError(command=f"dir {file_system}", message="Unable to parse free space from dir output.") + + free_bytes = int(match.group(1)) + log.debug("Host %s: %s bytes free on %s.", self.host, free_bytes, file_system) + return free_bytes + @staticmethod def _netloc(src: FileCopyModel) -> str: """Return host:port or just host from a FileCopyModel.""" @@ -511,6 +528,7 @@ def remote_file_copy(self, src: FileCopyModel, dest=None, file_system=None, **kw file_system, ) if not self.verify_file(src.checksum, dest, hashing_algorithm=src.hashing_algorithm, file_system=file_system): + self._pre_transfer_space_check(src, file_system) current_prompt = self.native_ssh.find_prompt() # Define prompt mapping for expected prompts during file copy diff --git a/tests/unit/test_devices/test_nxos_device.py b/tests/unit/test_devices/test_nxos_device.py index 8109a195..84f27f4d 100644 --- a/tests/unit/test_devices/test_nxos_device.py +++ b/tests/unit/test_devices/test_nxos_device.py @@ -10,6 +10,7 @@ CommandListError, FileSystemNotFoundError, FileTransferError, + NotEnoughFreeSpaceError, NTCFileNotFoundError, ) from pyntc.utils.models import FileCopyModel @@ -139,14 +140,18 @@ def test_file_copy_remote_exists_failure(self): "source_file", "dest_file", file_system=FILE_SYSTEM ) + @mock.patch.object(NXOSDevice, "_get_free_space", return_value=1024 * 1024 * 1024) + @mock.patch("pyntc.devices.nxos_device.os.path.getsize", return_value=1024) @mock.patch.object(NXOSDevice, "file_copy_remote_exists", side_effect=[False, True]) - def test_file_copy(self, mock_fcre): + def test_file_copy(self, mock_fcre, mock_getsize, mock_get_free_space): self.device.file_copy("source_file", "dest_file") self.device.native.file_copy.assert_called_with("source_file", "dest_file", file_system=FILE_SYSTEM) self.device.native.file_copy.assert_called() + @mock.patch.object(NXOSDevice, "_get_free_space", return_value=1024 * 1024 * 1024) + @mock.patch("pyntc.devices.nxos_device.os.path.getsize", return_value=1024) @mock.patch.object(NXOSDevice, "file_copy_remote_exists", side_effect=[False, True]) - def test_file_copy_no_dest(self, mock_fcre): + def test_file_copy_no_dest(self, mock_fcre, mock_getsize, mock_get_free_space): self.device.file_copy("source_file") self.device.native.file_copy.assert_called_with("source_file", "source_file", file_system=FILE_SYSTEM) self.device.native.file_copy.assert_called() @@ -156,12 +161,22 @@ def test_file_copy_file_exists(self, mock_fcre): self.device.file_copy("source_file", "dest_file") self.device.native.file_copy.assert_not_called() + @mock.patch.object(NXOSDevice, "_get_free_space", return_value=1024 * 1024 * 1024) + @mock.patch("pyntc.devices.nxos_device.os.path.getsize", return_value=1024) @mock.patch.object(NXOSDevice, "file_copy_remote_exists", side_effect=[False, False]) - def test_file_copy_fail(self, mock_fcre): + def test_file_copy_fail(self, mock_fcre, mock_getsize, mock_get_free_space): with self.assertRaises(FileTransferError): self.device.file_copy("source_file") self.device.native.file_copy.assert_called() + @mock.patch.object(NXOSDevice, "_get_free_space", return_value=1024) # Only 1KB free + @mock.patch("pyntc.devices.nxos_device.os.path.getsize", return_value=1024 * 1024) # Trying to copy 1MB + @mock.patch.object(NXOSDevice, "file_copy_remote_exists", side_effect=[False]) + def test_file_copy_raises_not_enough_free_space(self, mock_fcre, mock_getsize, mock_get_free_space): + """Test file_copy raises NotEnoughFreeSpaceError when insufficient space.""" + with self.assertRaises(NotEnoughFreeSpaceError): + self.device.file_copy("source_file") + def test_reboot(self): self.device.reboot() self.device.native.show_list.assert_called_with(["terminal dont-ask", "reload"]) @@ -278,6 +293,41 @@ def test_get_file_system_not_found(self, mock_show): self.device._get_file_system() mock_show.assert_called_with("dir", raw_text=True) + @mock.patch.object(NXOSDevice, "show") + def test_get_free_space(self, mock_show): + """Test _get_free_space parses NXOS dir output correctly.""" + # NXOS dir output format with free space at the end + mock_show.return_value = """Directory of bootflash:/ +4096 Mar 03 22:47:15 2026 .rpmstore/ +4733329408 bytes used +47171194880 bytes free +51904524288 bytes total + +""" + result = self.device._get_free_space() + self.assertEqual(result, 47171194880) + mock_show.assert_called_with("dir bootflash:", raw_text=True) + + @mock.patch.object(NXOSDevice, "show") + def test_get_free_space_with_custom_filesystem(self, mock_show): + """Test _get_free_space uses custom file system when provided.""" + mock_show.return_value = """Directory of disk0:/ +1000000 bytes used +2000000 bytes free +3000000 bytes total + +""" + result = self.device._get_free_space("disk0:") + self.assertEqual(result, 2000000) + mock_show.assert_called_with("dir disk0:", raw_text=True) + + @mock.patch.object(NXOSDevice, "show") + def test_get_free_space_raises_on_parse_error(self, mock_show): + """Test _get_free_space raises CommandError when output can't be parsed.""" + mock_show.return_value = "Directory of bootflash:/\nNo free space info here\n" + with self.assertRaises(CommandError): + self.device._get_free_space() + 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:") @@ -362,6 +412,23 @@ def test_remote_file_copy_transfer_fails_verification(self): with self.assertRaises(FileTransferError): self.device.remote_file_copy(src, file_system="bootflash:") + @mock.patch.object(NXOSDevice, "verify_file", return_value=False) + @mock.patch.object(NXOSDevice, "_get_free_space", return_value=1024) # Only 1KB free + def test_remote_file_copy_raises_not_enough_free_space(self, mock_get_free_space, mock_verify): + """Test remote_file_copy raises NotEnoughFreeSpaceError when insufficient space.""" + src = FileCopyModel( + download_url="http://example.com/nxos.bin", + checksum="abc123", + file_name="nxos.bin", + hashing_algorithm="md5", + timeout=30, + file_size=1024 * 1024, # Trying to copy 1MB + ) + self.device.native_ssh.find_prompt.return_value = "host#" + with self.assertRaises(NotEnoughFreeSpaceError): + self.device.remote_file_copy(src, file_system="bootflash:") + self.device.native_ssh.send_command.assert_not_called() + def test_remote_file_copy_invalid_scheme(self): src = FileCopyModel( download_url="smtp://example.com/nxos.bin",