Skip to content

Commit 10b818d

Browse files
jtdubclaude
andauthored
Add JunOS pre-transfer free-space check (NAPPS-1085) (#373)
* Add JunOS pre-transfer free-space check (NAPPS-1085) Implements the fail-open pre-transfer free-space check on Juniper Junos using the seam added in NAPPS-1091 (PR #370). Image transfers now fail fast when the target filesystem lacks room instead of half-writing flash. Lab-validated against an SRX340 (Junos 24.x). Driver (pyntc/devices/jnpr_device.py): - JunosDevice._get_free_space uses PyEZ FS.storage_usage() and parses the human-readable avail format (e.g., "126M", "1.0G"). PyEZ does not expose a native block size and Junos block semantics vary by release, so parsing the normalised human-readable field avoids the ambiguity. - Mount resolution uses longest-prefix match (same logic df uses) via _mount_encloses_path. SRX hardware does not expose /var/tmp as its own mount — it lives inside /var — so strict equality would raise; / becomes the universal fallback. Directory-boundary semantics prevent /vari from matching /var/tmp. - file_copy calls _check_free_space(os.path.getsize(src)) before any SCP put. - remote_file_copy gains optional file_system + **kwargs for BaseDevice parity, and calls _pre_transfer_space_check before fs.cp so the check fires only when a transfer would actually happen; still fail-open when src.file_size_bytes is None. - remote_file_copy appends src.file_name to the URL when the URL carries no path (mirroring ASA), so callers can point at a bare host like ftp://server. Tests: - Unit tests cover avail parse across K/M/G/T/P units, longest-prefix match (exact hit, most-specific wins, root fallback, /vari boundary rejected, empty storage raises), file_copy raising before SCP.put, remote_file_copy raising before fs.cp, fail-open path, and URL append / keep-intact. - Existing test_file_copy and test_remote_file_copy prime fs.storage_usage so the new probe succeeds. - New integration suite (tests/integration/test_jnpr_device.py) mirrors the EOS / ASA patterns for manual lab runs. TFTP is excluded because PyEZ fs.cp does not accept TFTP URLs. Shared integration helper: - integration_hash_algo() reads FILE_HASH_ALGO (default "sha512") so lab runs can pick the algorithm their devices support. Junos does not implement sha512 — SRX/MX runs set FILE_HASH_ALGO=sha256. Backward-compatible for EOS / ASA runs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Address PR #373 review feedback from jeffkala and mattmiller87 - Add JUNOS_SUPPORTED_HASHING_ALGORITHMS = {"md5", "sha1", "sha256"} module constant mirroring EOS_SUPPORTED_HASHING_ALGORITHMS and NXOS_SUPPORTED_HASHING_ALGORITHMS, plus a matching guard in get_remote_checksum that raises ValueError before reaching PyEZ. Callers asking for sha512 now fail fast with a clear driver-level message instead of tripping over PyEZ's raw ValueError. (mattmiller87) - Clarify _get_free_space and remote_file_copy docstrings so the ``/var/tmp`` default is attributed to the underlying ``_JUNOS_DEFAULT_FILE_SYSTEM`` constant rather than implying the public signature carries the literal default. (jeffkala) - Rename single-letter ``k, v`` loop variables in JUNOS_PROTOCOL_URL_VARS to ``scheme, env_var``. NTC style prefers descriptive names. (jeffkala) - New unit test covers the ValueError path on an unsupported algo. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Drop integration_hash_algo helper; pin Junos tests to sha256 constant Per @mattmiller87's review: each device should carry its own default algorithm rather than routing through a shared env-var helper. - Delete ``integration_hash_algo()`` from ``tests/integration/_helpers.py``. - ``build_file_copy_model`` now takes ``hashing_algorithm`` as a kwarg (default ``"sha512"`` preserves EOS / ASA behaviour without any caller changes). - ``any_file_copy_model`` fixture in ``conftest.py`` reverts to the literal ``"sha512"`` default. - ``test_jnpr_device.py`` exposes ``JUNOS_INTEGRATION_HASH_ALGO = "sha256"`` at module level and passes it explicitly to every site that builds or verifies a checksum; driver-level validation (added in the prior commit) ensures Junos never sees sha512. - Docstring updated: no more ``FILE_HASH_ALGO`` env var; labs that ship md5 / sha1 binaries edit the module constant. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Auto-select integration hash algo per platform in conftest.py Supports the ``run every device's integration suite in one pytest invocation'' workflow: each platform test module maps to the hashing algorithm its device family implements, and an autouse module-scoped fixture copies the matching ``FILE_CHECKSUM_<SUFFIX>`` env var into ``FILE_CHECKSUM`` (and ``FILE_HASH_ALGO`` to the algo name) before that module's tests run. The env is restored on teardown. - ``tests/integration/conftest.py`` adds ``_PLATFORM_HASH_ALGOS`` (module name → algo) and ``_HASH_ALGO_ENV_SUFFIXES`` (algo → env suffix), plus the ``_configure_integration_env`` autouse fixture that applies the pair for each module. When the suffix env var is missing the fixture leaves both vars untouched so the user's own shell-level pair wins (or ``build_file_copy_model`` skips cleanly on no env) — guards against the silent algo/checksum mismatch that would otherwise fail later at ``verify_file``. - ``tests/integration/_helpers.py`` simplifies ``build_file_copy_model``: it now reads ``FILE_HASH_ALGO`` and ``FILE_CHECKSUM`` from the environment (the autouse fixture populates both), dropping the ``hashing_algorithm`` kwarg it grew in the previous commit. - ``tests/integration/test_jnpr_device.py`` drops the ``JUNOS_INTEGRATION_HASH_ALGO`` module constant — the autouse fixture now sets ``FILE_HASH_ALGO="sha256"`` for this module. Direct ``FileCopyModel(...)`` constructions read the algo from env; ``test_verify_file_after_copy`` uses ``any_file_copy_model.hashing_algorithm`` instead of a hardcode. Docstring updated to reference ``FILE_CHECKSUM_256``. User setup: export ``FILE_CHECKSUM_512`` / ``FILE_CHECKSUM_256`` / ``FILE_CHECKSUM_MD5`` once; ``FILE_HASH_ALGO`` and ``FILE_CHECKSUM`` no longer need to live in .env at all. 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 d317130 commit 10b818d

6 files changed

Lines changed: 687 additions & 9 deletions

File tree

changes/373.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 Juniper JunOS ``file_copy`` and ``remote_file_copy`` that raises ``NotEnoughFreeSpaceError`` when the target filesystem lacks room for the image.

pyntc/devices/jnpr_device.py

Lines changed: 160 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import time
77
import warnings
88
from tempfile import NamedTemporaryFile
9+
from urllib.parse import urlparse
910

1011
from jnpr.junos import Device as JunosNativeDevice
1112
from jnpr.junos.exception import ConfigLoadError
@@ -18,9 +19,54 @@
1819
from pyntc import log
1920
from pyntc.devices.base_device import BaseDevice, fix_docs
2021
from pyntc.devices.tables.jnpr.loopback import LoopbackTable # pylint: disable=no-name-in-module
21-
from pyntc.errors import CommandError, CommandListError, FileTransferError, OSInstallError, RebootTimeoutError
22+
from pyntc.errors import (
23+
CommandError,
24+
CommandListError,
25+
FileSystemNotFoundError,
26+
FileTransferError,
27+
OSInstallError,
28+
RebootTimeoutError,
29+
)
2230
from pyntc.utils.models import FileCopyModel
2331

32+
# Multipliers for Junos ``df``-style size suffixes. Junos formats available
33+
# space with binary (1024-based) units in its ``<available-blocks format="...">``
34+
# XML attribute (e.g., "126M", "1.0G").
35+
_JUNOS_SIZE_UNIT_MULTIPLIERS = {
36+
"": 1,
37+
"B": 1,
38+
"K": 1024,
39+
"M": 1024**2,
40+
"G": 1024**3,
41+
"T": 1024**4,
42+
"P": 1024**5,
43+
}
44+
_JUNOS_AVAIL_FORMAT_RE = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*([BKMGTP]?)\s*$", re.IGNORECASE)
45+
# Default mount point to probe when callers do not specify one. ``/var/tmp`` is
46+
# the standard destination for ``fs.cp`` transfers on Junos (remote device
47+
# mount point, not a local temp directory).
48+
_JUNOS_DEFAULT_FILE_SYSTEM = "/var/tmp" # noqa: S108
49+
50+
# Hashing algorithms that Junos implements for the ``file checksum`` RPC.
51+
# Junos does NOT implement sha512; callers passing it will be rejected at the
52+
# driver boundary rather than surfacing PyEZ's raw ValueError deeper in the
53+
# stack. Mirrors the pattern used by EOS and NXOS drivers.
54+
JUNOS_SUPPORTED_HASHING_ALGORITHMS = {"md5", "sha1", "sha256"}
55+
56+
57+
def _mount_encloses_path(mount, path):
58+
"""Return True if ``mount`` is the filesystem that contains ``path``.
59+
60+
Matches with directory-boundary semantics (the same rule ``df`` uses) so
61+
``/vari`` is not mistaken for a prefix of ``/var/tmp``. ``/`` encloses
62+
every path.
63+
"""
64+
if mount == "/":
65+
return True
66+
if path == mount:
67+
return True
68+
return path.startswith(mount.rstrip("/") + "/")
69+
2470

2571
@fix_docs
2672
class JunosDevice(BaseDevice):
@@ -62,6 +108,85 @@ def _file_copy_local_md5(self, filepath, blocksize=2**20):
62108
buf = file_name.read(blocksize)
63109
return md5_hash.hexdigest()
64110

111+
def _get_free_space(self, file_system=None):
112+
"""Return free bytes on the filesystem containing ``file_system``.
113+
114+
Probes the device via ``get-system-storage-information`` (invoked by
115+
PyEZ ``FS.storage_usage``) and parses the human-readable
116+
``available-blocks`` ``format`` attribute (e.g., ``"126M"``, ``"1.0G"``)
117+
into bytes. The human-readable string is used rather than the raw
118+
block count because PyEZ does not expose a native block size and
119+
Junos block semantics can vary by release.
120+
121+
``file_system`` is resolved by **longest-prefix mount match** — the
122+
same logic ``df`` uses — so a caller asking about ``/var/tmp`` on a
123+
platform that only mounts ``/var`` (e.g., SRX hardware) still gets
124+
back the correct filesystem's free space. ``/`` is always a fallback
125+
when nothing more specific matches.
126+
127+
Args:
128+
file_system (str, optional): Target path. When ``None`` (the
129+
default), the probe uses ``_JUNOS_DEFAULT_FILE_SYSTEM``
130+
(``/var/tmp`` — the standard destination for ``fs.cp`` copies
131+
on Junos).
132+
133+
Returns:
134+
int: Free bytes available on the resolved filesystem.
135+
136+
Raises:
137+
FileSystemNotFoundError: When no mount point encloses ``file_system``
138+
(i.e., not even ``/`` is present in ``storage_usage``).
139+
CommandError: When the ``avail`` format string cannot be parsed.
140+
"""
141+
if file_system is None:
142+
file_system = _JUNOS_DEFAULT_FILE_SYSTEM
143+
144+
usage = self.fs.storage_usage()
145+
best_info = None
146+
best_mount = None
147+
best_len = -1
148+
for _dev, info in usage.items():
149+
mount = info.get("mount")
150+
if not mount or not _mount_encloses_path(mount, file_system):
151+
continue
152+
if len(mount) > best_len:
153+
best_info = info
154+
best_mount = mount
155+
best_len = len(mount)
156+
157+
if best_info is None:
158+
log.error(
159+
"Host %s: no mount encloses %s in storage_usage output.",
160+
self.host,
161+
file_system,
162+
)
163+
raise FileSystemNotFoundError(hostname=self.host, command="show system storage")
164+
165+
avail = best_info.get("avail", "")
166+
match = _JUNOS_AVAIL_FORMAT_RE.match(str(avail))
167+
if match is None:
168+
log.error(
169+
"Host %s: could not parse avail %r for mount %s.",
170+
self.host,
171+
avail,
172+
best_mount,
173+
)
174+
raise CommandError(
175+
command="show system storage",
176+
message=f"Unable to parse available space {avail!r} for {best_mount}.",
177+
)
178+
size = float(match.group(1))
179+
multiplier = _JUNOS_SIZE_UNIT_MULTIPLIERS[match.group(2).upper()]
180+
free_bytes = int(size * multiplier)
181+
log.debug(
182+
"Host %s: %s bytes free on %s (resolved from %s).",
183+
self.host,
184+
free_bytes,
185+
best_mount,
186+
file_system,
187+
)
188+
return free_bytes
189+
65190
def _get_interfaces(self):
66191
eth_ifaces = EthPortTable(self.native)
67192
eth_ifaces.get()
@@ -304,11 +429,15 @@ def file_copy(self, src, dest=None, **kwargs):
304429
305430
Raises:
306431
FileTransferError: Raised when unable to verify file was transferred succesfully.
432+
NotEnoughFreeSpaceError: When the target filesystem has fewer free bytes
433+
than ``src`` requires.
307434
"""
308435
if not self.file_copy_remote_exists(src, dest, **kwargs):
309436
if dest is None:
310437
dest = os.path.basename(src)
311438

439+
self._check_free_space(os.path.getsize(src))
440+
312441
with SCP(self.native) as scp:
313442
scp.put(src, remote_path=dest)
314443

@@ -505,11 +634,21 @@ def get_remote_checksum(self, filename, hashing_algorithm="md5"):
505634
506635
Args:
507636
filename (str): The name of the file to check for on the remote device.
508-
hashing_algorithm (str): The hashing algorithm to use. Valid values are 'md5', 'sha1', and 'sha256'. Defaults to 'md5'.
637+
hashing_algorithm (str): The hashing algorithm to use. Valid values are
638+
those in ``JUNOS_SUPPORTED_HASHING_ALGORITHMS`` (``md5``, ``sha1``,
639+
``sha256``). Defaults to ``md5``.
509640
510641
Returns:
511642
(str): The checksum of the remote file or None if the file is not found.
643+
644+
Raises:
645+
ValueError: When ``hashing_algorithm`` is not one Junos implements.
512646
"""
647+
if hashing_algorithm.lower() not in JUNOS_SUPPORTED_HASHING_ALGORITHMS:
648+
raise ValueError(
649+
f"Unsupported hashing algorithm '{hashing_algorithm}' for Junos. "
650+
f"Supported algorithms: {sorted(JUNOS_SUPPORTED_HASHING_ALGORITHMS)}"
651+
)
513652
return self.fs.checksum(path=filename, calc=hashing_algorithm)
514653

515654
def compare_file_checksum(self, checksum, filename, hashing_algorithm="md5"):
@@ -525,24 +664,41 @@ def compare_file_checksum(self, checksum, filename, hashing_algorithm="md5"):
525664
"""
526665
return checksum == self.get_remote_checksum(filename, hashing_algorithm)
527666

528-
def remote_file_copy(self, src: FileCopyModel = None, dest=None):
667+
def remote_file_copy(self, src: FileCopyModel = None, dest=None, file_system: str | None = None, **kwargs):
529668
"""Copy a file to a remote device.
530669
531670
Args:
532671
src (FileCopyModel): The source file model.
533672
dest (str): The destination file path on the remote device.
673+
file_system (str, optional): Mount point used for the pre-transfer
674+
free-space check. When ``None`` (the default), the probe uses
675+
``_JUNOS_DEFAULT_FILE_SYSTEM`` (``/var/tmp``).
676+
**kwargs (Any): Accepted for parity with ``BaseDevice.remote_file_copy``;
677+
other drivers may forward extra options.
534678
535679
Raises:
536680
TypeError: If src is not an instance of FileCopyModel.
537681
FileTransferError: If there is an error during file transfer or if the file cannot be verified after transfer.
682+
NotEnoughFreeSpaceError: If ``src.file_size_bytes`` is set and the
683+
target mount point has fewer free bytes than ``src.file_size_bytes``.
684+
When ``file_size`` is omitted from ``src`` the pre-transfer space
685+
check is skipped entirely.
538686
"""
539687
if not isinstance(src, FileCopyModel):
540688
raise TypeError("src must be an instance of FileCopyModel")
541689

542690
if self.verify_file(src.checksum, dest, hashing_algorithm=src.hashing_algorithm):
543691
return
544692

545-
if not self.fs.cp(from_path=src.download_url, to_path=dest, dev_timeout=src.timeout):
693+
self._pre_transfer_space_check(src, file_system=file_system)
694+
695+
# Junos ``fs.cp`` requires the filename in the URL; append ``src.file_name``
696+
# when the URL carries no path so callers can point at a bare host.
697+
source_url = src.download_url
698+
if not urlparse(source_url).path.strip("/"):
699+
source_url = f"{source_url.rstrip('/')}/{src.file_name}"
700+
701+
if not self.fs.cp(from_path=source_url, to_path=dest, dev_timeout=src.timeout):
546702
raise FileTransferError(message=f"Unable to copy file from remote url {src.clean_url}")
547703

548704
# Some devices take a while to sync the filesystem after a copy but netconf returns before the sync completes

tests/integration/_helpers.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,15 @@
2222
def build_file_copy_model(url_env_var):
2323
"""Build a ``FileCopyModel`` from a per-protocol URL env var.
2424
25-
Calls ``pytest.skip`` if the URL, ``FILE_CHECKSUM``, or ``FILE_SIZE`` env
26-
vars are not set.
25+
Reads ``FILE_HASH_ALGO`` and ``FILE_CHECKSUM`` from the environment.
26+
An autouse fixture in ``conftest.py`` sets both to the running test
27+
module's platform default before each module runs, so individual
28+
tests never have to hardcode an algorithm. Calls ``pytest.skip`` when
29+
any required env var is missing.
2730
"""
2831
url = os.environ.get(url_env_var)
2932
checksum = os.environ.get("FILE_CHECKSUM")
33+
hashing_algorithm = os.environ.get("FILE_HASH_ALGO", "sha512")
3034
file_name = os.environ.get("FILE_NAME") or (posixpath.basename(url.split("?")[0]) if url else None)
3135
file_size = int(os.environ.get("FILE_SIZE", "0"))
3236
file_size_unit = os.environ.get("FILE_SIZE_UNIT", "bytes")
@@ -40,7 +44,7 @@ def build_file_copy_model(url_env_var):
4044
file_name=file_name,
4145
file_size=file_size,
4246
file_size_unit=file_size_unit,
43-
hashing_algorithm="sha512",
47+
hashing_algorithm=hashing_algorithm,
4448
timeout=900,
4549
)
4650

tests/integration/conftest.py

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,76 @@
99

1010
from ._helpers import PROTOCOL_URL_VARS
1111

12+
# Each driver's integration test module is mapped to the hashing algorithm
13+
# its device family implements. ``conftest.py`` owns this mapping (rather
14+
# than per-module constants or .env entries) so the user can run every
15+
# driver's integration suite in one ``pytest tests/integration`` invocation
16+
# and each module automatically picks up the algorithm its hardware
17+
# supports. Junos does not implement sha512 — sha256 is typical on SRX /
18+
# MX. EOS and ASA both implement sha512. Extend the map as new drivers get
19+
# integration tests.
20+
_PLATFORM_HASH_ALGOS = {
21+
"test_eos_device": "sha512",
22+
"test_asa_device": "sha512",
23+
"test_jnpr_device": "sha256",
24+
"test_ios_device": "md5",
25+
"test_nxos_device": "sha256",
26+
}
27+
28+
# Maps each hashing algorithm to the suffix convention used on the
29+
# per-algorithm checksum env vars (``FILE_CHECKSUM_512`` / ``_256`` /
30+
# ``_MD5``). The user exports one checksum per algorithm once and
31+
# ``_configure_integration_env`` copies the right one into
32+
# ``FILE_CHECKSUM`` before the module runs.
33+
_HASH_ALGO_ENV_SUFFIXES = {"sha512": "512", "sha256": "256", "md5": "MD5"}
34+
35+
36+
@pytest.fixture(scope="module", autouse=True)
37+
def _configure_integration_env(request):
38+
"""Set ``FILE_HASH_ALGO`` / ``FILE_CHECKSUM`` per test module.
39+
40+
Resolves the module-specific hashing algorithm from
41+
``_PLATFORM_HASH_ALGOS``, copies the matching ``FILE_CHECKSUM_<SUFFIX>``
42+
env var into ``FILE_CHECKSUM``, and restores any prior values when
43+
the module finishes. Test files not listed in the map are left alone —
44+
they either carry no hashing dependency or set their own env.
45+
"""
46+
module_name = request.module.__name__.split(".")[-1]
47+
algo = _PLATFORM_HASH_ALGOS.get(module_name)
48+
if algo is None:
49+
yield
50+
return
51+
52+
suffix = _HASH_ALGO_ENV_SUFFIXES.get(algo)
53+
checksum = os.environ.get(f"FILE_CHECKSUM_{suffix}") if suffix else None
54+
55+
# Skip the env overwrite entirely when the suffix env var is missing.
56+
# Otherwise we would pin FILE_HASH_ALGO to the platform's algo while
57+
# leaving FILE_CHECKSUM inherited from the shell (possibly a different
58+
# algo's hash) — tests would then fail at verify with a confusing
59+
# mismatch. Leaving both vars alone lets the user's own pair win, or
60+
# lets ``build_file_copy_model`` skip cleanly on missing env.
61+
if checksum is None:
62+
yield
63+
return
64+
65+
prior_algo = os.environ.get("FILE_HASH_ALGO")
66+
prior_checksum = os.environ.get("FILE_CHECKSUM")
67+
68+
os.environ["FILE_HASH_ALGO"] = algo
69+
os.environ["FILE_CHECKSUM"] = checksum
70+
71+
yield
72+
73+
if prior_algo is None:
74+
os.environ.pop("FILE_HASH_ALGO", None)
75+
else:
76+
os.environ["FILE_HASH_ALGO"] = prior_algo
77+
if prior_checksum is None:
78+
os.environ.pop("FILE_CHECKSUM", None)
79+
else:
80+
os.environ["FILE_CHECKSUM"] = prior_checksum
81+
1282

1383
@pytest.fixture(scope="module")
1484
def any_file_copy_model():
@@ -19,6 +89,7 @@ def any_file_copy_model():
1989
protocol URL / ``FILE_CHECKSUM`` / ``FILE_SIZE`` env vars are set.
2090
"""
2191
checksum = os.environ.get("FILE_CHECKSUM")
92+
checksum_algo = os.environ.get("FILE_HASH_ALGO", "sha512")
2293
file_size = int(os.environ.get("FILE_SIZE", "0"))
2394
file_size_unit = os.environ.get("FILE_SIZE_UNIT", "bytes")
2495
for env_var in PROTOCOL_URL_VARS.values():
@@ -31,7 +102,7 @@ def any_file_copy_model():
31102
file_name=file_name,
32103
file_size=file_size,
33104
file_size_unit=file_size_unit,
34-
hashing_algorithm="sha512",
105+
hashing_algorithm=checksum_algo,
35106
timeout=900,
36107
)
37108
pytest.skip("No protocol URL / FILE_CHECKSUM / FILE_SIZE environment variables not set")

0 commit comments

Comments
 (0)