Skip to content

Commit c4372a7

Browse files
Add IOS pre-transfer get free space check (#371)
* Added get_fre_space * Update 371.added Co-authored-by: Jeff Kala <48843785+jeffkala@users.noreply.github.com> --------- Co-authored-by: Jeff Kala <48843785+jeffkala@users.noreply.github.com>
1 parent c323dd4 commit c4372a7

3 files changed

Lines changed: 97 additions & 5 deletions

File tree

changes/371.added

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added free space validation for file copy operations on IOS devices.

pyntc/devices/ios_device.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,22 @@ def _get_file_system(self):
141141
log.error("host %s: File system not found with command 'dir'.")
142142
raise FileSystemNotFoundError(hostname=self.hostname, command="dir")
143143

144+
def _get_free_space(self, file_system=None):
145+
"""Return free bytes on ``file_system`` as reported by IOS ``dir`` output."""
146+
if file_system is None:
147+
file_system = self._get_file_system()
148+
149+
raw_data = self.show(f"dir {file_system}")
150+
# Example: 16777216 bytes total (1592488 bytes free)
151+
match = re.search(r"\((\d+)\s+bytes\s+free\)", raw_data)
152+
if match is None:
153+
log.error("Host %s: could not parse free space from '%s'.", self.host, f"dir {file_system}")
154+
raise CommandError(command=f"dir {file_system}", message="Unable to parse free space from dir output.")
155+
156+
free_bytes = int(match.group(1))
157+
log.debug("Host %s: %s bytes free on %s.", self.host, free_bytes, file_system)
158+
return free_bytes
159+
144160
# Get the version of the image that is booted into on the device
145161
def _image_booted(self, image_name, image_pattern=r".*\.(\d+\.\d+\.\w+)\.SPA.+", **vendor_specifics):
146162
version_data = self.show("show version")
@@ -730,6 +746,7 @@ def file_copy(self, src, dest=None, file_system=None):
730746
log.debug("Host %s: Local checksum for file %s is %s.", self.host, src, local_checksum)
731747

732748
if not self.verify_file(local_checksum, dest, file_system=file_system):
749+
self._check_free_space(os.path.getsize(src), file_system=file_system)
733750
file_copy = self._file_copy_instance(src, dest, file_system=file_system)
734751
# if not self.fc.verify_space_available():
735752
# raise FileTransferError('Not enough space available.')
@@ -786,6 +803,7 @@ def remote_file_copy(self, src: FileCopyModel, dest=None, file_system=None, **kw
786803
if dest is None:
787804
dest = src.file_name
788805
if not self.verify_file(src.checksum, dest, hashing_algorithm=src.hashing_algorithm, file_system=file_system):
806+
self._pre_transfer_space_check(src, file_system)
789807
current_prompt = self.native.find_prompt()
790808

791809
# Define prompt mapping for expected prompts during file copy

tests/unit/test_devices/test_ios_device.py

Lines changed: 78 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from pyntc.devices import IOSDevice
99
from pyntc.devices import ios_device as ios_module
1010
from pyntc.devices.base_device import RollbackError
11+
from pyntc.errors import NotEnoughFreeSpaceError
1112
from pyntc.utils.models import FileCopyModel
1213

1314
from .device_mocks.ios import send_command, send_command_expect
@@ -140,9 +141,13 @@ def test_file_copy_remote_exists_not(self, mock_ft):
140141

141142
@mock.patch.object(IOSDevice, "get_local_checksum")
142143
@mock.patch.object(IOSDevice, "verify_file")
144+
@mock.patch.object(IOSDevice, "_check_free_space")
145+
@mock.patch("pyntc.devices.ios_device.os.path.getsize", return_value=1024)
143146
@mock.patch("pyntc.devices.ios_device.FileTransfer", autospec=True)
144147
@mock.patch.object(IOSDevice, "open")
145-
def test_file_copy(self, mock_open, mock_ft, mock_verify_file, mock_get_local_checksum):
148+
def test_file_copy(
149+
self, mock_open, mock_ft, _mock_getsize, _mock_check_space, mock_verify_file, mock_get_local_checksum
150+
):
146151
self.device.native.send_command.side_effect = None
147152
self.device.native.send_command.return_value = "flash: /dev/null"
148153

@@ -159,9 +164,13 @@ def test_file_copy(self, mock_open, mock_ft, mock_verify_file, mock_get_local_ch
159164

160165
@mock.patch.object(IOSDevice, "get_local_checksum")
161166
@mock.patch.object(IOSDevice, "verify_file")
167+
@mock.patch.object(IOSDevice, "_check_free_space")
168+
@mock.patch("pyntc.devices.ios_device.os.path.getsize", return_value=1024)
162169
@mock.patch("pyntc.devices.ios_device.FileTransfer", autospec=True)
163170
@mock.patch.object(IOSDevice, "open")
164-
def test_file_copy_different_dest(self, mock_open, mock_ft, mock_verify_file, mock_get_local_checksum):
171+
def test_file_copy_different_dest(
172+
self, mock_open, mock_ft, _mock_getsize, _mock_check_space, mock_verify_file, mock_get_local_checksum
173+
):
165174
self.device.native.send_command_timing.side_effect = None
166175
self.device.native.send_command.return_value = "flash: /dev/null"
167176
mock_ft_instance = mock_ft.return_value
@@ -177,9 +186,13 @@ def test_file_copy_different_dest(self, mock_open, mock_ft, mock_verify_file, mo
177186

178187
@mock.patch.object(IOSDevice, "get_local_checksum")
179188
@mock.patch.object(IOSDevice, "verify_file")
189+
@mock.patch.object(IOSDevice, "_check_free_space")
190+
@mock.patch("pyntc.devices.ios_device.os.path.getsize", return_value=1024)
180191
@mock.patch("pyntc.devices.ios_device.FileTransfer", autospec=True)
181192
@mock.patch.object(IOSDevice, "open")
182-
def test_file_copy_fail(self, mock_open, mock_ft, mock_verify_file, mock_get_local_checksum):
193+
def test_file_copy_fail(
194+
self, mock_open, mock_ft, _mock_getsize, _mock_check_space, mock_verify_file, mock_get_local_checksum
195+
):
183196
self.device.native.send_command_timing.side_effect = None
184197
self.device.native.send_command.return_value = "flash: /dev/null"
185198
mock_ft_instance = mock_ft.return_value
@@ -193,9 +206,13 @@ def test_file_copy_fail(self, mock_open, mock_ft, mock_verify_file, mock_get_loc
193206

194207
@mock.patch.object(IOSDevice, "get_local_checksum")
195208
@mock.patch.object(IOSDevice, "verify_file")
209+
@mock.patch.object(IOSDevice, "_check_free_space")
210+
@mock.patch("pyntc.devices.ios_device.os.path.getsize", return_value=1024)
196211
@mock.patch("pyntc.devices.ios_device.FileTransfer", autospec=True)
197212
@mock.patch.object(IOSDevice, "open")
198-
def test_file_copy_socket_closed_good_md5(self, mock_open, mock_ft, mock_verify_file, mock_get_local_checksum):
213+
def test_file_copy_socket_closed_good_md5(
214+
self, mock_open, mock_ft, _mock_getsize, _mock_check_space, mock_verify_file, mock_get_local_checksum
215+
):
199216
self.device.native.send_command_timing.side_effect = None
200217
self.device.native.send_command.return_value = "flash: /dev/null"
201218
mock_ft_instance = mock_ft.return_value
@@ -214,9 +231,13 @@ def test_file_copy_socket_closed_good_md5(self, mock_open, mock_ft, mock_verify_
214231

215232
@mock.patch.object(IOSDevice, "get_local_checksum")
216233
@mock.patch.object(IOSDevice, "verify_file")
234+
@mock.patch.object(IOSDevice, "_check_free_space")
235+
@mock.patch("pyntc.devices.ios_device.os.path.getsize", return_value=1024)
217236
@mock.patch("pyntc.devices.ios_device.FileTransfer", autospec=True)
218237
@mock.patch.object(IOSDevice, "open")
219-
def test_file_copy_fail_socket_closed_bad_md5(self, mock_open, mock_ft, mock_verify_file, mock_get_local_checksum):
238+
def test_file_copy_fail_socket_closed_bad_md5(
239+
self, mock_open, mock_ft, _mock_getsize, _mock_check_space, mock_verify_file, mock_get_local_checksum
240+
):
220241
self.device.native.send_command_timing.side_effect = None
221242
self.device.native.send_command.return_value = "flash: /dev/null"
222243
mock_ft_instance = mock_ft.return_value
@@ -479,6 +500,15 @@ def test_get_remote_checksum(self):
479500
with self.assertRaises(ValueError):
480501
self.device.get_remote_checksum("file.txt", hashing_algorithm="invalid_algo", file_system="flash:")
481502

503+
def test_get_free_space(self):
504+
self.device.native.send_command.return_value = "16777216 bytes total (1592488 bytes free)"
505+
self.assertEqual(self.device._get_free_space(file_system="flash:"), 1592488)
506+
507+
def test_get_free_space_raises_when_unparsable(self):
508+
self.device.native.send_command.return_value = "Directory of flash:/\nUnable to read totals"
509+
with self.assertRaises(ios_module.CommandError):
510+
self.device._get_free_space(file_system="flash:")
511+
482512
@mock.patch.object(IOSDevice, "verify_file")
483513
def test_remote_file_copy_success(self, mock_verify):
484514
# Setup file model
@@ -594,6 +624,49 @@ def test_remote_file_copy_failure_on_error_output(self, mock_verify):
594624
with self.assertRaises(FileTransferError):
595625
self.device.remote_file_copy(src)
596626

627+
@mock.patch.object(IOSDevice, "verify_file")
628+
def test_remote_file_copy_raises_not_enough_free_space(self, mock_verify):
629+
src = FileCopyModel(
630+
download_url="http://1.1.1.1/test.bin",
631+
checksum="12345",
632+
file_name="test.bin",
633+
hashing_algorithm="md5",
634+
file_size=2,
635+
file_size_unit="gigabytes",
636+
)
637+
mock_verify.return_value = False
638+
self.device.native.send_command.return_value = "16777216 bytes total (1592488 bytes free)"
639+
640+
with self.assertRaises(NotEnoughFreeSpaceError):
641+
self.device.remote_file_copy(src, file_system="flash:")
642+
643+
assert not any(
644+
"copy " in str(call.kwargs.get("command_string", call.args[0] if call.args else ""))
645+
for call in self.device.native.send_command.call_args_list
646+
)
647+
648+
@mock.patch.object(IOSDevice, "verify_file")
649+
@mock.patch.object(IOSDevice, "_check_free_space")
650+
def test_remote_file_copy_skips_space_check_when_file_size_omitted(self, mock_check_free_space, mock_verify):
651+
src = FileCopyModel(
652+
download_url="sftp://1.1.1.1/test.bin",
653+
checksum="12345",
654+
file_name="test.bin",
655+
hashing_algorithm="md5",
656+
timeout=300,
657+
)
658+
mock_verify.side_effect = [False, True]
659+
self.device.native.send_command.side_effect = [
660+
"Address or name of remote host [1.1.1.1]?",
661+
"123456 bytes copied in 10.2 secs. Copy complete.",
662+
]
663+
self.device.native.find_prompt.return_value = "Router#"
664+
665+
self.device.remote_file_copy(src, file_system="flash:")
666+
667+
mock_check_free_space.assert_not_called()
668+
self.device.native.send_command.assert_called()
669+
597670

598671
if __name__ == "__main__":
599672
unittest.main()

0 commit comments

Comments
 (0)