Skip to content

Commit d317130

Browse files
jtdubclaude
andauthored
Add ASA pre-transfer free-space check (NAPPS-1087) (#372)
* Add ASA pre-transfer free-space check (NAPPS-1087) Implements the fail-open pre-transfer free-space check on Cisco ASA using the seam added in NAPPS-1091 (PR #370). Image transfers now fail fast when ``disk0:`` lacks room instead of half-writing flash. - ASADevice._get_free_space parses the ``(N bytes free)`` trailer from ``dir`` output (same format EOS exposes). - file_copy calls _check_free_space with os.path.getsize on the local source before any SCP transfer. - remote_file_copy calls _pre_transfer_space_check inside the existing ``not verify_file`` block so the check only fires when a transfer would actually happen; still fail-open when src.file_size_bytes is None. - Unit tests cover the dir-trailer parse, the missing-trailer raise, file_copy raising before SCP, remote_file_copy raising before any copy command, and the fail-open path spying on _check_free_space. - Existing file_copy unit tests updated to mock os.path.getsize and _check_free_space (previously they passed a non-existent path that would now blow up on os.path.getsize). - Integration tests mirror the EOS pattern for manual lab runs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Add NAPPS-1087 changelog fragment Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Apply ruff format to ASA integration tests Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Broaden ASA dir free-space regex for real-device trailer Real ASA platforms print the dir trailer as ``(<N> bytes free/<pct>% free)`` — the closing paren is not adjacent to ``free``. The previous regex anchored on ``\)`` and so would have failed on live hardware (verified against an ASA 5512 running 9.9.2 at 10.1.100.80, which reports ``4118732800 bytes total (3580170240 bytes free/86% free)``). Drop the trailing ``\)`` anchor and update the mock fixture + tests to cover both the real-device shape and the legacy ``(N bytes free)`` emulator shape. Bumped the ``test_remote_file_copy_raises_not_enough_free_space`` oversize from 2 GB to 10 GB because the mock now reports ~3.3 GB free — 2 GB fits, 10 GB does not. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c4372a7 commit d317130

5 files changed

Lines changed: 274 additions & 6 deletions

File tree

changes/372.added

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added a pre-transfer free-space check to Cisco ASA ``file_copy`` and ``remote_file_copy`` that raises ``NotEnoughFreeSpaceError`` when the target filesystem lacks room for the image.

pyntc/devices/asa_device.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,39 @@ def _get_file_system(self):
138138
log.debug("Host %s: File system %s.", self.host, file_system)
139139
return file_system
140140

141+
def _get_free_space(self, file_system=None): # pylint: disable=unused-argument
142+
"""Return free bytes on ``file_system`` as reported by ASA's ``dir`` output.
143+
144+
ASA exposes a single flash filesystem in practice and ``dir`` always prints
145+
a ``<total> bytes total (<free> bytes free...)`` trailer as the last line.
146+
Real platforms append an ``/<pct>% free`` suffix inside the parentheses
147+
(e.g., ``(3580170240 bytes free/86% free)``); older releases and some
148+
emulators omit it. The regex matches both shapes. The ``file_system``
149+
argument is accepted for API parity with other drivers but is otherwise
150+
unused.
151+
152+
Args:
153+
file_system (str, optional): Ignored; retained for BaseDevice API parity.
154+
155+
Returns:
156+
int: Free bytes available on the target filesystem.
157+
158+
Raises:
159+
CommandError: When the ``dir`` output does not contain a parseable
160+
``N bytes free`` trailer.
161+
"""
162+
raw_data = self.show("dir")
163+
# Examples seen in the wild:
164+
# 16777216 bytes total (1592488 bytes free)
165+
# 4118732800 bytes total (3580170240 bytes free/86% free)
166+
match = re.search(r"\((\d+)\s+bytes\s+free", raw_data)
167+
if match is None:
168+
log.error("Host %s: could not parse free space from 'dir' output.", self.host)
169+
raise CommandError(command="dir", message="Unable to parse free space from dir output.")
170+
free_bytes = int(match.group(1))
171+
log.debug("Host %s: %s bytes free on flash.", self.host, free_bytes)
172+
return free_bytes
173+
141174
def _get_ipv4_addresses(self, host: str) -> Dict[str, List[IPv4Address]]:
142175
"""
143176
Get IPv4 Addresses for ``host``.
@@ -555,6 +588,8 @@ def file_copy(
555588
556589
Raises:
557590
FileTransferError: When the ``src`` file is unable to transfer the file to any device.
591+
NotEnoughFreeSpaceError: When ``file_system`` has fewer free bytes than
592+
``src`` requires.
558593
559594
Example:
560595
>>> dev = ASADevice(**connection_args)
@@ -566,6 +601,8 @@ def file_copy(
566601
if file_system is None:
567602
file_system = self._get_file_system()
568603

604+
self._check_free_space(os.path.getsize(src), file_system=file_system)
605+
569606
# netmiko's enable_scp
570607
self.enable_scp()
571608
self._file_copy(src, dest, file_system)
@@ -1018,6 +1055,10 @@ def remote_file_copy(self, src: FileCopyModel = None, dest=None, **kwargs: Any):
10181055
Raises:
10191056
TypeError: If ``src`` is not a ``FileCopyModel`` instance.
10201057
FileTransferError: If the transfer fails or the file cannot be verified afterwards.
1058+
NotEnoughFreeSpaceError: If ``src.file_size_bytes`` is set and
1059+
``file_system`` has fewer free bytes than ``src.file_size_bytes``.
1060+
When ``file_size`` is omitted from ``src``, the pre-transfer space
1061+
check is skipped entirely.
10211062
"""
10221063
if not isinstance(src, FileCopyModel):
10231064
raise TypeError("src must be an instance of FileCopyModel")
@@ -1028,6 +1069,7 @@ def remote_file_copy(self, src: FileCopyModel = None, dest=None, **kwargs: Any):
10281069
dest = src.file_name
10291070

10301071
if not self.verify_file(src.checksum, dest, hashing_algorithm=src.hashing_algorithm, file_system=file_system):
1072+
self._pre_transfer_space_check(src, file_system=file_system)
10311073
current_prompt = self.native.find_prompt()
10321074
prompt_answers = {
10331075
r"Password": src.token or "",

tests/integration/test_asa_device.py

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
export HTTP_URL=http://<http_user>:<http_password>@<server_ip>:8081/<file_name>
1515
export HTTPS_URL=https://<https_user>:<https_password>@<server_ip>:8443/<file_name>
1616
export FILE_CHECKSUM=<sha512_hash>
17+
export FILE_SIZE=<image_size>
18+
export FILE_SIZE_UNIT=megabytes # optional; defaults to "bytes"
1719
poetry run pytest tests/integration/test_asa_device.py -v
1820
1921
Set only the protocol URL vars for the servers you have available; each
@@ -31,15 +33,21 @@
3133
HTTPS_URL - HTTPS URL of the file to transfer
3234
FILE_NAME - Destination filename on the device (default: basename of URL path)
3335
FILE_CHECKSUM - Expected sha512 checksum of the file (shared across all protocols)
36+
FILE_SIZE - Expected size of the file expressed in FILE_SIZE_UNIT units; used for
37+
the pre-transfer free-space check
38+
FILE_SIZE_UNIT - One of "bytes", "megabytes", or "gigabytes" (default: "bytes")
3439
"""
3540

3641
import os
42+
from unittest import mock
3743

3844
import pytest
3945

4046
from pyntc.devices import ASADevice
47+
from pyntc.errors import NotEnoughFreeSpaceError
48+
from pyntc.utils.models import FILE_SIZE_UNITS, FileCopyModel
4149

42-
from ._helpers import build_file_copy_model
50+
from ._helpers import PROTOCOL_URL_VARS, build_file_copy_model, first_available_url
4351

4452
# ---------------------------------------------------------------------------
4553
# Fixtures
@@ -127,3 +135,110 @@ def test_verify_file_after_copy(device, any_file_copy_model):
127135
if not device.check_file_exists(any_file_copy_model.file_name):
128136
pytest.skip("File does not exist on device; run a copy test first")
129137
assert device.verify_file(any_file_copy_model.checksum, any_file_copy_model.file_name, hashing_algorithm="sha512")
138+
139+
140+
# ---------------------------------------------------------------------------
141+
# Free-space / pre-transfer tests (NAPPS-1087)
142+
# ---------------------------------------------------------------------------
143+
144+
145+
def test_get_free_space_returns_positive_int(device):
146+
"""``_get_free_space`` parses the ``dir`` trailer into a positive int."""
147+
free = device._get_free_space() # pylint: disable=protected-access
148+
assert isinstance(free, int)
149+
assert free > 0
150+
151+
152+
def test_check_free_space_succeeds_for_small_request(device):
153+
"""A 1-byte request must always fit; ``_check_free_space`` returns ``None``."""
154+
# pylint: disable=protected-access
155+
assert device._check_free_space(required_bytes=1) is None
156+
157+
158+
def test_check_free_space_raises_when_required_exceeds_free(device):
159+
"""When required bytes exceed what the device reports, raise NotEnoughFreeSpaceError."""
160+
# pylint: disable=protected-access
161+
free = device._get_free_space()
162+
with pytest.raises(NotEnoughFreeSpaceError):
163+
device._check_free_space(required_bytes=free + 1)
164+
165+
166+
def test_file_size_unit_conversion_matches_device_free_space(device):
167+
"""A megabyte-denominated request converts through ``FILE_SIZE_UNITS`` correctly."""
168+
# pylint: disable=protected-access
169+
free_bytes = device._get_free_space()
170+
one_mb = FILE_SIZE_UNITS["megabytes"]
171+
if free_bytes < one_mb:
172+
pytest.skip("Device has less than 1 MB free; conversion sanity test not meaningful")
173+
assert device._check_free_space(required_bytes=one_mb) is None
174+
175+
176+
def test_remote_file_copy_rejects_oversized_transfer(device):
177+
"""remote_file_copy raises NotEnoughFreeSpaceError and never copies the file."""
178+
checksum = os.environ.get("FILE_CHECKSUM")
179+
scheme, url = first_available_url()
180+
if not (url and checksum):
181+
pytest.skip("No protocol URL / FILE_CHECKSUM environment variables not set")
182+
183+
# pylint: disable=protected-access
184+
free_bytes = device._get_free_space()
185+
free_gb = free_bytes // FILE_SIZE_UNITS["gigabytes"]
186+
oversized_gb = max(free_gb * 10, 10)
187+
188+
unique_name = f"pyntc_integration_space_check_{os.getpid()}_{scheme}.bin"
189+
model = FileCopyModel(
190+
download_url=url,
191+
checksum=checksum,
192+
file_name=unique_name,
193+
file_size=oversized_gb,
194+
file_size_unit="gigabytes",
195+
hashing_algorithm="sha512",
196+
timeout=60,
197+
)
198+
199+
assert not device.check_file_exists(unique_name), "Unique filename unexpectedly exists before test"
200+
201+
with pytest.raises(NotEnoughFreeSpaceError):
202+
device.remote_file_copy(model)
203+
204+
assert not device.check_file_exists(unique_name)
205+
206+
207+
def test_remote_file_copy_accepts_declared_size_within_free_space(device):
208+
"""A correctly-sized FileCopyModel copies without the space check interfering."""
209+
scheme, _url = first_available_url()
210+
if scheme is None:
211+
pytest.skip("No protocol URL environment variables set")
212+
model = build_file_copy_model(PROTOCOL_URL_VARS[scheme])
213+
# pylint: disable=protected-access
214+
free_bytes = device._get_free_space()
215+
assert model.file_size_bytes <= free_bytes, (
216+
"Configured FILE_SIZE/FILE_SIZE_UNIT exceeds device free space; update env vars"
217+
)
218+
device.remote_file_copy(model)
219+
assert device.check_file_exists(model.file_name)
220+
221+
222+
def test_remote_file_copy_skips_space_check_when_file_size_omitted(device):
223+
"""When FileCopyModel has no file_size, _check_free_space is never called."""
224+
checksum = os.environ.get("FILE_CHECKSUM")
225+
file_name = os.environ.get("FILE_NAME")
226+
_, url = first_available_url()
227+
if not (url and checksum and file_name):
228+
pytest.skip("URL / FILE_CHECKSUM / FILE_NAME environment variables not set")
229+
230+
model = FileCopyModel(
231+
download_url=url,
232+
checksum=checksum,
233+
file_name=file_name,
234+
hashing_algorithm="sha512",
235+
timeout=60,
236+
) # file_size intentionally omitted
237+
assert model.file_size is None
238+
assert model.file_size_bytes is None
239+
240+
with mock.patch.object(ASADevice, "_check_free_space") as spy:
241+
device.remote_file_copy(model)
242+
243+
spy.assert_not_called()
244+
assert device.check_file_exists(model.file_name)

tests/unit/test_devices/device_mocks/asa/send_command/dir

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@ Directory of disk0:/
22

33
1 -rw- 15183868 asa9-12-3-11-smp-k8.bin
44

5-
16777216 bytes total (1592488 bytes free)
5+
4118732800 bytes total (3580170240 bytes free/86% free)

tests/unit/test_devices/test_asa_device.py

Lines changed: 114 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
from pyntc.devices import ASADevice
88
from pyntc.devices import asa_device as asa_module
9-
from pyntc.errors import FileTransferError
9+
from pyntc.errors import CommandError, FileTransferError, NotEnoughFreeSpaceError
1010
from pyntc.utils.models import FileCopyModel
1111

1212
from .device_mocks.asa import send_command
@@ -536,11 +536,15 @@ def test_enable_scp_enable_fail(mock_log, mock_save, mock_config, mock_peer_devi
536536
mock_save.assert_not_called()
537537

538538

539+
@mock.patch("pyntc.devices.asa_device.os.path.getsize", return_value=1024)
540+
@mock.patch.object(ASADevice, "_check_free_space")
539541
@mock.patch.object(os.path, "basename", return_value="a.txt")
540542
@mock.patch.object(ASADevice, "_get_file_system", return_value="flash:")
541543
@mock.patch.object(ASADevice, "enable_scp")
542544
@mock.patch.object(ASADevice, "_file_copy")
543-
def test_file_copy_no_peer_no_args(mock_file_copy, mock_enable_scp, mock_get_file_system, mock_basename, asa_device):
545+
def test_file_copy_no_peer_no_args(
546+
mock_file_copy, mock_enable_scp, mock_get_file_system, mock_basename, _check_space, _getsize, asa_device
547+
):
544548
asa_device.file_copy("path/to/a.txt")
545549
mock_basename.assert_called()
546550
mock_get_file_system.assert_called()
@@ -549,11 +553,15 @@ def test_file_copy_no_peer_no_args(mock_file_copy, mock_enable_scp, mock_get_fil
549553
mock_file_copy.assert_called_with("path/to/a.txt", "a.txt", "flash:")
550554

551555

556+
@mock.patch("pyntc.devices.asa_device.os.path.getsize", return_value=1024)
557+
@mock.patch.object(ASADevice, "_check_free_space")
552558
@mock.patch.object(os.path, "basename")
553559
@mock.patch.object(ASADevice, "_get_file_system")
554560
@mock.patch.object(ASADevice, "enable_scp")
555561
@mock.patch.object(ASADevice, "_file_copy")
556-
def test_file_copy_no_peer_pass_args(mock_file_copy, mock_enable_scp, mock_get_file_system, mock_basename, asa_device):
562+
def test_file_copy_no_peer_pass_args(
563+
mock_file_copy, mock_enable_scp, mock_get_file_system, mock_basename, _check_space, _getsize, asa_device
564+
):
557565
args = ("path/to/a.txt", "b.txt", "bootflash:")
558566
asa_device.file_copy(*args)
559567
mock_basename.assert_not_called()
@@ -563,13 +571,22 @@ def test_file_copy_no_peer_pass_args(mock_file_copy, mock_enable_scp, mock_get_f
563571
mock_file_copy.assert_called_with(*args)
564572

565573

574+
@mock.patch("pyntc.devices.asa_device.os.path.getsize", return_value=1024)
575+
@mock.patch.object(ASADevice, "_check_free_space")
566576
@mock.patch.object(os.path, "basename")
567577
@mock.patch.object(ASADevice, "_get_file_system")
568578
@mock.patch.object(ASADevice, "enable_scp")
569579
@mock.patch.object(ASADevice, "_file_copy")
570580
@mock.patch.object(ASADevice, "peer_device")
571581
def test_file_copy_include_peer(
572-
mock_peer_device, mock_file_copy, mock_enable_scp, mock_get_file_system, mock_basename, asa_device
582+
mock_peer_device,
583+
mock_file_copy,
584+
mock_enable_scp,
585+
mock_get_file_system,
586+
mock_basename,
587+
_check_space,
588+
_getsize,
589+
asa_device,
573590
):
574591
mock_peer_device.return_value = asa_device
575592
args = ("path/to/a.txt", "a.txt", "flash:")
@@ -1415,3 +1432,96 @@ def test_remote_file_copy_https_clean_url_used_in_command(mock_verify, mock_fs,
14151432
call_args = asa_device.native.send_command.call_args_list[0][0][0]
14161433
assert "example-user:example-password" not in call_args
14171434
assert "https://192.0.2.1/asa.bin" in call_args
1435+
1436+
1437+
# ---------------------------------------------------------------------------
1438+
# Pre-transfer free-space tests (NAPPS-1087)
1439+
# ---------------------------------------------------------------------------
1440+
1441+
DIR_OUTPUT_WITH_TRAILER = (
1442+
"Directory of disk0:/\n\n"
1443+
"1 -rw- 15183868 asa9-12-3-11-smp-k8.bin\n\n"
1444+
"4118732800 bytes total (3580170240 bytes free/86% free)"
1445+
)
1446+
DIR_OUTPUT_LEGACY_TRAILER = (
1447+
"Directory of disk0:/\n\n"
1448+
"1 -rw- 15183868 asa9-12-3-11-smp-k8.bin\n\n"
1449+
"16777216 bytes total (1592488 bytes free)"
1450+
)
1451+
1452+
1453+
@mock.patch.object(ASADevice, "show", return_value=DIR_OUTPUT_WITH_TRAILER)
1454+
def test_get_free_space_parses_dir_trailer(_mock_show, asa_device):
1455+
"""_get_free_space returns the bytes-free value from the real-device dir trailer."""
1456+
assert asa_device._get_free_space() == 3580170240
1457+
1458+
1459+
@mock.patch.object(ASADevice, "show", return_value=DIR_OUTPUT_LEGACY_TRAILER)
1460+
def test_get_free_space_parses_legacy_dir_trailer(_mock_show, asa_device):
1461+
"""_get_free_space parses the older ``(N bytes free)`` trailer shape."""
1462+
assert asa_device._get_free_space() == 1592488
1463+
1464+
1465+
@mock.patch.object(ASADevice, "show", return_value="Directory of disk0:/\nno trailer here")
1466+
def test_get_free_space_raises_when_trailer_missing(_mock_show, asa_device):
1467+
"""_get_free_space raises CommandError when the trailer can't be parsed."""
1468+
with pytest.raises(CommandError):
1469+
asa_device._get_free_space()
1470+
1471+
1472+
@mock.patch("pyntc.devices.asa_device.os.path.getsize", return_value=10**12)
1473+
@mock.patch.object(ASADevice, "_get_file_system", return_value="disk0:")
1474+
@mock.patch.object(ASADevice, "show", return_value=DIR_OUTPUT_WITH_TRAILER)
1475+
@mock.patch.object(ASADevice, "enable_scp")
1476+
@mock.patch.object(ASADevice, "_file_copy")
1477+
def test_file_copy_raises_not_enough_free_space(mock_file_copy, mock_enable_scp, _show, _fs, _getsize, asa_device):
1478+
"""file_copy raises NotEnoughFreeSpaceError and never runs SCP transfer."""
1479+
with pytest.raises(NotEnoughFreeSpaceError):
1480+
asa_device.file_copy("path/to/image.bin")
1481+
1482+
mock_enable_scp.assert_not_called()
1483+
mock_file_copy.assert_not_called()
1484+
1485+
1486+
@mock.patch.object(ASADevice, "_get_file_system", return_value="disk0:")
1487+
@mock.patch.object(ASADevice, "verify_file", return_value=False)
1488+
@mock.patch.object(ASADevice, "show", return_value=DIR_OUTPUT_WITH_TRAILER)
1489+
def test_remote_file_copy_raises_not_enough_free_space(_show, _verify, _fs, asa_device):
1490+
"""remote_file_copy raises NotEnoughFreeSpaceError and never issues a copy command."""
1491+
oversized = FileCopyModel(
1492+
download_url="ftp://192.0.2.1/asa.bin",
1493+
checksum=SHA512_CHECKSUM,
1494+
file_name="asa.bin",
1495+
file_size=10,
1496+
file_size_unit="gigabytes",
1497+
hashing_algorithm="sha512",
1498+
)
1499+
1500+
with pytest.raises(NotEnoughFreeSpaceError):
1501+
asa_device.remote_file_copy(oversized)
1502+
1503+
asa_device.native.send_command.assert_not_called()
1504+
1505+
1506+
@mock.patch.object(ASADevice, "_get_file_system", return_value="disk0:")
1507+
@mock.patch.object(ASADevice, "_check_free_space")
1508+
@mock.patch.object(ASADevice, "verify_file", side_effect=[False, True])
1509+
def test_remote_file_copy_skips_space_check_when_file_size_omitted(mock_verify, mock_check, _fs, asa_device):
1510+
"""When FileCopyModel has no file_size, _check_free_space is NOT called."""
1511+
model = FileCopyModel(
1512+
download_url="ftp://192.0.2.1/asa.bin",
1513+
checksum=SHA512_CHECKSUM,
1514+
file_name="asa.bin",
1515+
hashing_algorithm="sha512",
1516+
) # file_size intentionally omitted
1517+
assert model.file_size_bytes is None
1518+
1519+
asa_device.native.find_prompt.return_value = "asa5512#"
1520+
asa_device.native.send_command.side_effect = [
1521+
"94038 bytes copied in 0.90 secs",
1522+
]
1523+
1524+
asa_device.remote_file_copy(model)
1525+
1526+
mock_check.assert_not_called()
1527+
asa_device.native.send_command.assert_called() # transfer still happens

0 commit comments

Comments
 (0)