1515from jnpr .junos .utils .scp import SCP
1616from jnpr .junos .utils .sw import SW as JunosNativeSW
1717
18+ from pyntc import log
1819from pyntc .devices .base_device import BaseDevice , fix_docs
1920from 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
2426class 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