Skip to content

Commit 415c1bc

Browse files
authored
Added OS Upgrade support to Junos (#350)
* Added OS Upgrade support to Junos. pending docs and testing * add and update tests * changelog * update docs * change default dev environment to local. temporarily disable python 3.13 in CI * revert change to tasks.py * remove Python 3.12 from CI tests
1 parent e117a59 commit 415c1bc

File tree

14 files changed

+1332
-916
lines changed

14 files changed

+1332
-916
lines changed

.github/workflows/ci.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ jobs:
9191
strategy:
9292
fail-fast: true
9393
matrix:
94-
python-version: ["3.10", "3.13"]
94+
python-version: ["3.10", "3.11"] # TODO: Switch back to latest supported Python when https://github.com/networktocode/pyntc/issues/351 is completed
9595
env:
9696
INVOKE_PYNTC_PYTHON_VER: "${{ matrix.python-version }}"
9797
steps:
@@ -127,7 +127,7 @@ jobs:
127127
strategy:
128128
fail-fast: true
129129
matrix:
130-
python-version: ["3.10", "3.11", "3.12", "3.13"]
130+
python-version: ["3.10", "3.11"] # TODO: Re-enable Python 3.12 and 3.13 when https://github.com/networktocode/pyntc/issues/351 is completed
131131
runs-on: "ubuntu-latest"
132132
env:
133133
INVOKE_PYNTC_PYTHON_VER: "${{ matrix.python-version }}"

changes/350.added

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added OS upgrade support for Junos devices.

docs/user/lib_getting_started.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,7 @@ interface GigabitEthernet1
252252

253253
#### Remote File Copy (Download to Device)
254254

255-
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 devices. Tested with ftp, http, https, sftp, and tftp urls.
255+
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.
256256

257257
- `remote_file_copy` method
258258

poetry.lock

Lines changed: 999 additions & 859 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyntc/devices/aireos_device.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -345,7 +345,7 @@ def _wait_for_device_reboot(self, timeout=3600):
345345
log.debug("Host %s: Device rebooted.", self.host)
346346
return
347347
except: # noqa E722 # nosec # pylint: disable=bare-except
348-
pass
348+
time.sleep(10)
349349

350350
# TODO: Get proper hostname parameter
351351
log.error("Host %s: Device timed out while rebooting.", self.host)

pyntc/devices/asa_device.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -294,7 +294,7 @@ def _wait_for_device_reboot(self, timeout=3600):
294294
log.debug("Host %s: Device rebooted.", self.host)
295295
return
296296
except: # noqa E722 # nosec # pylint: disable=bare-except
297-
pass
297+
time.sleep(10)
298298

299299
# TODO: Get proper hostname parameter
300300
log.error("Host %s: Device timed out while rebooting.", self.host)

pyntc/devices/eos_device.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ def _wait_for_device_reboot(self, timeout=3600):
154154
log.debug("Host %s: Device rebooted.", self.host)
155155
return
156156
except: # noqa E722 # nosec # pylint: disable=bare-except
157-
pass
157+
time.sleep(10)
158158

159159
log.error("Host %s: Device timed out while rebooting.", self.host)
160160
raise RebootTimeoutError(hostname=self.hostname, wait_time=timeout)

pyntc/devices/ios_device.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,7 @@ def _wait_for_device_reboot(self, timeout=3600):
243243
if self._has_reload_happened_recently():
244244
return
245245
except: # noqa E722 # nosec # pylint: disable=bare-except
246-
pass
246+
time.sleep(10)
247247

248248
log.error("Host %s: Device timed out while rebooting.", self.host)
249249
raise RebootTimeoutError(hostname=self.hostname, wait_time=timeout)
@@ -865,7 +865,7 @@ def install_os(self, image_name, install_mode=False, read_timeout=2000, **vendor
865865
Args:
866866
image_name (str): Name of the IOS image to boot into
867867
install_mode (bool, optional): Uses newer install method on devices. Defaults to False.
868-
read_timeout (int, optional): Netmiko timeout when waiting for device prompt. Default 30.
868+
read_timeout (int, optional): Netmiko timeout when waiting for device prompt. Default 2000.
869869
vendor_specifics (dict, optional): Vendor specific arguments to pass to the install command.
870870
871871
Raises:

pyntc/devices/iosxewlc_device.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ def _wait_for_device_reboot(self, timeout=5400):
3535
log.debug("Host %s: Device rebooted.", self.host)
3636
return
3737
except Exception: # noqa E722 # nosec # pylint: disable=broad-except
38-
pass
38+
time.sleep(10)
3939

4040
log.error("Host %s: Device timed out while rebooting.", self.host)
4141
raise RebootTimeoutError(hostname=self.hostname, wait_time=timeout)

pyntc/devices/jnpr_device.py

Lines changed: 127 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,19 @@
1515
from jnpr.junos.utils.scp import SCP
1616
from jnpr.junos.utils.sw import SW as JunosNativeSW
1717

18+
from pyntc import log
1819
from pyntc.devices.base_device import BaseDevice, fix_docs
1920
from pyntc.devices.tables.jnpr.loopback import LoopbackTable # pylint: disable=no-name-in-module
20-
from pyntc.errors import CommandError, CommandListError, FileTransferError, RebootTimeoutError
21+
from pyntc.errors import CommandError, CommandListError, FileTransferError, OSInstallError, RebootTimeoutError
22+
from pyntc.utils.models import FileCopyModel
2123

2224

2325
@fix_docs
2426
class JunosDevice(BaseDevice):
2527
"""Juniper JunOS Device Implementation."""
2628

2729
vendor = "juniper"
30+
DEFAULT_TIMEOUT = 120
2831

2932
def __init__(self, host, username, password, *args, **kwargs): # noqa: D403
3033
"""PyNTC device implementation for Juniper JunOS.
@@ -40,6 +43,8 @@ def __init__(self, host, username, password, *args, **kwargs): # noqa: D403
4043

4144
self.native = JunosNativeDevice(*args, host=host, user=username, passwd=password, **kwargs)
4245
self.open()
46+
self.native.timeout = self.DEFAULT_TIMEOUT
47+
log.init(host=host)
4348
self.cu = JunosNativeConfig(self.native) # pylint: disable=invalid-name
4449
self.fs = JunosNativeFS(self.native) # pylint: disable=invalid-name
4550
self.sw = JunosNativeSW(self.native) # pylint: disable=invalid-name
@@ -57,9 +62,6 @@ def _file_copy_local_md5(self, filepath, blocksize=2**20):
5762
buf = file_name.read(blocksize)
5863
return md5_hash.hexdigest()
5964

60-
def _file_copy_remote_md5(self, filename):
61-
return self.fs.checksum(filename)
62-
6365
def _get_interfaces(self):
6466
eth_ifaces = EthPortTable(self.native)
6567
eth_ifaces.get()
@@ -103,12 +105,17 @@ def _uptime_to_string(self, uptime_full_string):
103105

104106
def _wait_for_device_reboot(self, timeout=3600):
105107
start = time.time()
108+
disconnected = False
106109
while time.time() - start < timeout:
107-
try:
108-
self.open()
109-
return
110-
except: # noqa E722 # nosec # pylint: disable=bare-except
111-
pass
110+
if disconnected:
111+
try:
112+
self.open()
113+
return
114+
except: # noqa E722 # nosec # pylint: disable=bare-except
115+
pass
116+
elif not self.connected:
117+
disconnected = True
118+
time.sleep(10)
112119

113120
raise RebootTimeoutError(hostname=self.hostname, wait_time=timeout)
114121

@@ -326,58 +333,71 @@ def file_copy_remote_exists(self, src, dest=None, **kwargs):
326333
dest = os.path.basename(src)
327334

328335
local_hash = self._file_copy_local_md5(src)
329-
remote_hash = self._file_copy_remote_md5(dest)
336+
remote_hash = self.get_remote_checksum(dest)
330337
if local_hash is not None and local_hash == remote_hash:
331338
return True
332339
return False
333340

334-
def install_os(self, image_name, **vendor_specifics):
335-
"""Install OS on device.
341+
def install_os(self, image_name, checksum, hashing_algorithm="md5"):
342+
"""Install OS on device and reboot.
336343
337344
Args:
338345
image_name (str): Name of image.
339-
vendor_specifics (dict): Vendor specific options.
346+
checksum (str): The checksum of the file.
347+
hashing_algorithm (str): The hashing algorithm to use. Valid values are 'md5', 'sha1', and 'sha256'. Defaults to 'md5'.
340348
341-
Raises:
342-
NotImplementedError: Method currently not implemented.
343349
"""
344-
raise NotImplementedError
350+
install_ok = self.sw.install(
351+
package=image_name,
352+
checksum=checksum,
353+
checksum_algorithm=hashing_algorithm,
354+
progress=True,
355+
validate=True,
356+
no_copy=True,
357+
timeout=3600,
358+
)
359+
360+
# Sometimes install() returns a tuple of (ok, msg). Other times it returns a single bool
361+
if isinstance(install_ok, tuple):
362+
install_ok = install_ok[0]
363+
364+
if not install_ok:
365+
raise OSInstallError(hostname=self.hostname, desired_boot=image_name)
366+
367+
self.reboot(wait_for_reload=True)
345368

346369
def open(self):
347370
"""Open connection to device."""
348371
if not self.connected:
349372
self.native.open()
350373

351-
def reboot(self, wait_for_reload=False, **kwargs):
374+
def reboot(self, wait_for_reload=False, timeout=3600, confirm=None):
352375
"""
353376
Reload the controller or controller pair.
354377
355378
Args:
356-
wait_for_reload (bool): Whether or not reboot method should also run _wait_for_device_reboot(). Defaults to False.
357-
kwargs (dict): Additional keyword arguments to pass to the `reboot` command.
379+
wait_for_reload (bool): Whether the reboot method should wait for the device to come back up before returning. Defaults to False.
380+
timeout (int, optional): Time in seconds to wait for the device to return after reboot. Defaults to 1 hour.
381+
confirm (None): Not used. Deprecated since v0.17.0.
358382
359383
Example:
360384
>>> device = JunosDevice(**connection_args)
361385
>>> device.reboot()
362386
>>>
363387
"""
364-
if kwargs.get("confirm"):
388+
if confirm is not None:
365389
warnings.warn("Passing 'confirm' to reboot method is deprecated.", DeprecationWarning)
366390

367-
self.sw = JunosNativeSW(self.native)
368391
self.sw.reboot(in_min=0)
369392
if wait_for_reload:
370-
time.sleep(10)
371-
self._wait_for_device_reboot()
393+
self._wait_for_device_reboot(timeout=timeout)
372394

373395
def rollback(self, filename):
374396
"""Rollback to a specific configuration file.
375397
376398
Args:
377399
filename (str): Filename to rollback device to.
378400
"""
379-
self.native.timeout = 60
380-
381401
temp_file = NamedTemporaryFile() # pylint: disable=consider-using-with
382402

383403
with SCP(self.native) as scp:
@@ -388,8 +408,6 @@ def rollback(self, filename):
388408

389409
temp_file.close()
390410

391-
self.native.timeout = 30
392-
393411
@property
394412
def running_config(self):
395413
"""Get running configuration.
@@ -412,7 +430,7 @@ def save(self, filename=None):
412430
(bool): True if new file created for save file. Otherwise, just returns if save is to default name.
413431
"""
414432
if filename is None:
415-
self.cu.commit()
433+
self.cu.commit(dev_timeout=300)
416434
return
417435

418436
temp_file = NamedTemporaryFile(mode="w") # pylint: disable=consider-using-with
@@ -470,3 +488,84 @@ def startup_config(self):
470488
(str): Startup configuration.
471489
"""
472490
return self.show("show config")
491+
492+
def check_file_exists(self, filename):
493+
"""Check if a remote file exists by filename.
494+
495+
Args:
496+
filename (str): The name of the file to check for on the remote device.
497+
498+
Returns:
499+
(bool): True if the remote file exists, False if it doesn't.
500+
"""
501+
return self.fs.ls(filename) is not None
502+
503+
def get_remote_checksum(self, filename, hashing_algorithm="md5"):
504+
"""Get the checksum of a remote file.
505+
506+
Args:
507+
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'.
509+
510+
Returns:
511+
(str): The checksum of the remote file or None if the file is not found.
512+
"""
513+
return self.fs.checksum(path=filename, calc=hashing_algorithm)
514+
515+
def compare_file_checksum(self, checksum, filename, hashing_algorithm="md5"):
516+
"""Compare the checksum of a local file with a remote file.
517+
518+
Args:
519+
checksum (str): The checksum of the file.
520+
filename (str): The name of the file to check for on the remote device.
521+
hashing_algorithm (str): The hashing algorithm to use. Valid values are 'md5', 'sha1', and 'sha256'. Defaults to 'md5'.
522+
523+
Returns:
524+
(bool): True if the checksums match, False otherwise.
525+
"""
526+
return checksum == self.get_remote_checksum(filename, hashing_algorithm)
527+
528+
def remote_file_copy(self, src: FileCopyModel = None, dest=None):
529+
"""Copy a file to a remote device.
530+
531+
Args:
532+
src (FileCopyModel): The source file model.
533+
dest (str): The destination file path on the remote device.
534+
535+
Raises:
536+
TypeError: If src is not an instance of FileCopyModel.
537+
FileTransferError: If there is an error during file transfer or if the file cannot be verified after transfer.
538+
"""
539+
if not isinstance(src, FileCopyModel):
540+
raise TypeError("src must be an instance of FileCopyModel")
541+
542+
if self.verify_file(src.checksum, dest, hashing_algorithm=src.hashing_algorithm):
543+
return
544+
545+
if not self.fs.cp(from_path=src.download_url, to_path=dest, dev_timeout=src.timeout):
546+
raise FileTransferError(message=f"Unable to copy file from remote url {src.clean_url}")
547+
548+
# Some devices take a while to sync the filesystem after a copy but netconf returns before the sync completes
549+
for _ in range(5):
550+
if self.verify_file(src.checksum, dest, hashing_algorithm=src.hashing_algorithm):
551+
return
552+
time.sleep(30)
553+
554+
log.error(
555+
"Host %s: Attempted remote file copy, but could not validate file existed after transfer",
556+
self.host,
557+
)
558+
raise FileTransferError
559+
560+
def verify_file(self, checksum, filename, hashing_algorithm="md5"):
561+
"""Verify a file on the remote device by confirming the file exists and validate the checksum.
562+
563+
Args:
564+
checksum (str): The checksum of the file.
565+
filename (str): The name of the file to check for on the remote device.
566+
hashing_algorithm (str): The hashing algorithm to use (default: "md5").
567+
568+
Returns:
569+
(bool): True if the file is verified successfully, False otherwise.
570+
"""
571+
return self.check_file_exists(filename) and self.compare_file_checksum(checksum, filename, hashing_algorithm)

0 commit comments

Comments
 (0)