Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions docs/admin/release_notes/version_3.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,20 @@ This document describes all new features and changes in the release. The format
- Pyntc now requires the `PYNTC_LOG_FILE` environment variable to output logging to a file. The new default behavior is to only log to stderr.

<!-- towncrier release notes start -->
## [v3.0.2 (2026-05-28)](https://github.com/networktocode/pyntc/releases/tag/v3.0.2)

### Added

- [#395](https://github.com/networktocode/pyntc/issues/395) - Added an ``install_mode`` property to ``BaseDevice`` (abstract) and ``IOSDevice``; the IOS implementation returns ``True`` when the device boots from ``packages.conf``.

### Deprecated

- [#395](https://github.com/networktocode/pyntc/issues/395) - Deprecated the ``install_mode`` argument to ``IOSDevice.install_os``; install mode is now derived from the device's ``boot_options`` via the new ``install_mode`` property and will be removed in a future release.

### Fixed

- [#394](https://github.com/networktocode/pyntc/issues/394) - Fixed Junos reboots not being detected when waiting for the device to reload.
- [#394](https://github.com/networktocode/pyntc/issues/394) - Increased the default Junos reboot wait timeout from 1 hour to 2 hours.

## [v3.0.1 (2026-05-26)](https://github.com/networktocode/pyntc/releases/tag/v3.0.1)

Expand Down
14 changes: 14 additions & 0 deletions pyntc/devices/base_device.py
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,20 @@ def verify_file(self, checksum, filename, hashing_algorithm="md5", **kwargs):
"""
raise NotImplementedError

@property
def install_mode(self):
"""Indicate whether the device is operating in install mode.

Drivers override this to derive the value from the device's current boot
configuration. Used by ``install_os`` to choose between install-mode and
legacy upgrade procedures.

Returns:
(bool): True when the device boots from an install-mode bundle
(e.g., ``packages.conf`` on IOS-XE), False otherwise.
"""
raise NotImplementedError

def install_os(self, image_name, reboot=True, **vendor_specifics):
"""Install the OS from specified image_name.

Expand Down
38 changes: 33 additions & 5 deletions pyntc/devices/ios_device.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import os
import re
import time
import warnings

from netmiko import ConnectHandler, FileTransfer
from netmiko.exceptions import ReadTimeout
Expand Down Expand Up @@ -319,6 +320,17 @@ def boot_options(self):
log.debug("Host %s: the boot options are {dict(sys=boot_image)}", self.host)
return {"sys": boot_image}

@property
def install_mode(self):
"""Return whether the device is currently booted in install mode.

Returns:
(bool): True when the current boot image equals
:data:`INSTALL_MODE_FILE_NAME` (i.e., ``packages.conf``),
False otherwise.
"""
return self.boot_options.get("sys") == INSTALL_MODE_FILE_NAME

def checkpoint(self, checkpoint_file):
"""Create checkpoint file.

Expand Down Expand Up @@ -877,13 +889,28 @@ def file_copy_remote_exists(self, src, dest=None, file_system=None):
log.debug("Host %s: File %s does not already exist on remote.", self.host, src)
return False

def install_os(self, image_name, reboot=True, install_mode=False, read_timeout=2000, **vendor_specifics):
def _resolve_install_mode(self, install_mode):
"""Return the effective install_mode flag, warning if the caller passed it explicitly."""
if install_mode is None:
return self.install_mode
warnings.warn(
"The install_mode argument to install_os is deprecated; install mode is now "
"derived from the device's boot_options via the install_mode property.",
DeprecationWarning,
)
return install_mode

def install_os(self, image_name, reboot=True, install_mode=None, read_timeout=2000, **vendor_specifics):
"""Installs the prescribed Network OS, which must be present before issuing this command.

Args:
image_name (str): Name of the IOS image to boot into
reboot (bool): Whether to reboot the device after setting the boot options. Defaults to true.
install_mode (bool, optional): Uses newer install method on devices. Defaults to False.
install_mode (bool, optional): **Deprecated.** Whether to use the newer install-mode
upgrade procedure. When omitted (the default), the value is derived from
:attr:`install_mode`, which reads the device's current boot configuration.
Passing the argument explicitly still works but emits a ``DeprecationWarning``
and will be removed in a future release.
read_timeout (int, optional): Netmiko timeout when waiting for device prompt. Default 2000.
vendor_specifics (dict, optional): Vendor specific arguments to pass to the install command.

Expand All @@ -893,14 +920,15 @@ def install_os(self, image_name, reboot=True, install_mode=False, read_timeout=2
Returns:
(bool): False if no install is needed, true if the install completes successfully
"""
use_install_mode = self._resolve_install_mode(install_mode)
timeout = vendor_specifics.get("timeout", 3600)
if not self._image_booted(image_name):
if install_mode and not reboot:
if use_install_mode and not reboot:
raise ValueError(
"IOS devices automatically reboot after installation when using install mode but the reboot argument was set to false."
)

if install_mode:
if use_install_mode:
# Change boot statement to be boot system <flash>:packages.conf
self.set_boot_options(INSTALL_MODE_FILE_NAME, **vendor_specifics)

Expand Down Expand Up @@ -942,7 +970,7 @@ def install_os(self, image_name, reboot=True, install_mode=False, read_timeout=2
self._wait_for_device_reboot(timeout=timeout)

# Set FastCLI back to originally set when using install mode
if install_mode:
if use_install_mode:
image_name = INSTALL_MODE_FILE_NAME
# Verify the OS level
if not self._image_booted(image_name):
Expand Down
93 changes: 70 additions & 23 deletions pyntc/devices/jnpr_device.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,18 +228,53 @@ def _uptime_to_string(self, uptime_full_string):
days, hours, minutes, seconds = self._uptime_components(uptime_full_string)
return f"{days:02d}:{hours:02d}:{minutes:02d}:{seconds:02d}"

def _wait_for_device_reboot(self, timeout=3600):
def _wait_for_device_reboot(self, original_uptime, timeout=7200):
"""Block until the device reboots and accepts a fresh connection.

Drops the existing NETCONF session and polls for the device to come back.
The reboot is considered complete when a new connection succeeds and the
device reports an uptime lower than ``original_uptime`` (i.e., it has
booted since the reboot was issued).

The pre-reboot session must be discarded first: once the device restarts,
PyEZ still reports it as connected even though the transport is dead, so it
is closed here to force each probe to establish a fresh connection.

Args:
original_uptime (int): Device uptime in seconds captured before the reboot.
timeout (int, optional): Max seconds to wait for the device to return. Defaults to 2 hours.
"""
start = time.time()
disconnected = False

# Drop the pre-reboot NETCONF session so subsequent probes can't read from
# a stale connection PyEZ still reports as connected.
try:
self.close()
except Exception as close_exc: # pylint: disable=broad-exception-caught
log.debug("Host %s: Pre-reboot disconnect raised %s (ignored).", self.host, close_exc)

while time.time() - start < timeout:
if disconnected:
try:
self.open()
try:
self.open()
self._uptime = None
current_uptime = self.uptime
if current_uptime is not None and current_uptime < original_uptime:
log.info(
"Host %s: Device rebooted (uptime %ss < pre-reboot %ss).",
self.host,
current_uptime,
original_uptime,
)
return
except: # noqa E722 # nosec # pylint: disable=bare-except
pass
elif not self.connected:
disconnected = True
log.debug(
"Host %s: Reachable but uptime %ss >= pre-reboot %ss; still waiting.",
self.host,
current_uptime,
original_uptime,
)
except Exception as exc: # pylint: disable=broad-exception-caught
log.debug("Host %s: Reboot probe failed (%s); will retry.", self.host, exc)
self.native.connected = False
time.sleep(10)

raise RebootTimeoutError(hostname=self.hostname, wait_time=timeout)
Expand Down Expand Up @@ -318,12 +353,14 @@ def uptime(self):
Returns:
(int): Device uptime in seconds.
"""
try:
native_uptime_string = self.native.facts["RE0"]["up_time"]
except (AttributeError, TypeError):
native_uptime_string = None

if self._uptime is None:
try:
# Bust PyEZ's cached facts so a cold cache always reflects the live device.
self.native.facts_refresh(keys="RE0")
native_uptime_string = self.native.facts["RE0"]["up_time"]
except (AttributeError, TypeError, KeyError):
native_uptime_string = None

if native_uptime_string is not None:
self._uptime = self._uptime_to_seconds(native_uptime_string)

Expand All @@ -337,13 +374,16 @@ def uptime_string(self):
Returns:
(str): Device uptime.
"""
try:
native_uptime_string = self.native.facts["RE0"]["up_time"]
except (AttributeError, TypeError):
native_uptime_string = None

if self._uptime_string is None:
self._uptime_string = self._uptime_to_string(native_uptime_string)
try:
# Bust PyEZ's cached facts so a cold cache always reflects the live device.
self.native.facts_refresh(keys="RE0")
native_uptime_string = self.native.facts["RE0"]["up_time"]
except (AttributeError, TypeError, KeyError):
native_uptime_string = None

if native_uptime_string is not None:
self._uptime_string = self._uptime_to_string(native_uptime_string)

return self._uptime_string

Expand Down Expand Up @@ -505,13 +545,13 @@ def open(self):
if not self.connected:
self.native.open()

def reboot(self, wait_for_reload=False, timeout=3600, confirm=None):
def reboot(self, wait_for_reload=False, timeout=7200, confirm=None):
"""
Reload the controller or controller pair.

Args:
wait_for_reload (bool): Whether the reboot method should wait for the device to come back up before returning. Defaults to False.
timeout (int, optional): Time in seconds to wait for the device to return after reboot. Defaults to 1 hour.
timeout (int, optional): Time in seconds to wait for the device to return after reboot. Defaults to 2 hours.
confirm (None): Not used. Deprecated since v0.17.0.

Example:
Expand All @@ -522,9 +562,16 @@ def reboot(self, wait_for_reload=False, timeout=3600, confirm=None):
if confirm is not None:
warnings.warn("Passing 'confirm' to reboot method is deprecated.", DeprecationWarning)

self._uptime = None
original_uptime = self.uptime
if original_uptime is None:
raise CommandError(
command="reboot",
message="Could not determine pre-reboot uptime; refusing to wait for reload.",
)
self.sw.reboot(in_min=0)
if wait_for_reload:
self._wait_for_device_reboot(timeout=timeout)
self._wait_for_device_reboot(original_uptime, timeout=timeout)

def rollback(self, filename):
"""Rollback to a specific configuration file.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "pyntc"
version = "3.0.1"
version = "3.0.2"
description = "Python library focused on tasks related to device level and OS management."
authors = ["Network to Code, LLC <opensource@networktocode.com>"]
readme = "README.md"
Expand Down
15 changes: 15 additions & 0 deletions tests/unit/test_devices/test_base_device.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
"""Tests for the abstract :class:`pyntc.devices.base_device.BaseDevice` contract."""

import pytest

from pyntc.devices.base_device import BaseDevice


@pytest.fixture
def base_device():
return BaseDevice(host="host", username="user", password="pass")


def test_install_mode_raises_not_implemented(base_device):
with pytest.raises(NotImplementedError):
_ = base_device.install_mode
Loading
Loading