diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e31b6a74..c3a92c4f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -55,8 +55,9 @@ jobs: runs-on: "ubuntu-latest" if: "startsWith(github.ref, 'refs/tags/v')" needs: "build" - environment: "pypi" - # Steps to publish to PyPI. + # IMPORTANT: this permission is mandatory for trusted publishing. + permissions: + id-token: "write" steps: - name: "Retrieve built package from cache" uses: "actions/download-artifact@v4" @@ -65,11 +66,6 @@ jobs: path: "dist/" - name: "Publish package distributions to PyPI" uses: "pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e" # v1.13.0 - ## Used for networktocode org since trusted publisher isn't supported for GitHub Plan. - with: - user: "__token__" - password: "${{ secrets.PYPI_API_TOKEN }}" - # End publish to PyPI job. slack-notify: needs: diff --git a/docs/admin/release_notes/version_3.2.md b/docs/admin/release_notes/version_3.2.md index b788aace..9e591dfb 100644 --- a/docs/admin/release_notes/version_3.2.md +++ b/docs/admin/release_notes/version_3.2.md @@ -8,6 +8,17 @@ This document describes all new features and changes in the release. The format - Fixed Arista EOS reboot detection when waiting for a device to reload. +## [v3.2.3a0 (2026-08-12)](https://github.com/networktocode/pyntc/releases/tag/velease-3.2.3a0) + +### Added + +- [#413](https://github.com/networktocode/pyntc/issues/413) - Added Juniper SRX Chassis Cluster upgrade support when ICU. +- [#418](https://github.com/networktocode/pyntc/issues/418) - Added the `arista_eos_ssh` device type, an SSH-only Arista EOS driver for environments where eAPI is not enabled; it exposes the same API as `arista_eos_eapi` and obtains structured data via the CLI's `| json` pipe. + +### Housekeeping + +- Work on trusted publisher for networktocode org. + ## [v3.2.2 (2026-08-04)](https://github.com/networktocode/pyntc/releases/tag/v3.2.2) ### Fixed diff --git a/docs/user/lib_getting_started.md b/docs/user/lib_getting_started.md index 8d45927d..a7f962bf 100644 --- a/docs/user/lib_getting_started.md +++ b/docs/user/lib_getting_started.md @@ -11,16 +11,19 @@ The first way is to use the `ntc_device` object. Just pass in all required param Like many libraries, we need to pass in the host/IP and credentials. Because this is a multi-vendor/API library, we also use the `device_type` parameter to identify which device we are building an instance of. -pyntc currently supports seven device types: +pyntc currently supports the following device types: - cisco_aireos_ssh - cisco_asa_ssh - cisco_ios_ssh - cisco_nxos_nxapi - arista_eos_eapi +- arista_eos_ssh - juniper_junos_netconf - f5_tmos_icontrol +Arista EOS is supported over two transports. `arista_eos_eapi` uses eAPI (JSON-RPC over HTTP/HTTPS) and requires `management api http-commands` to be enabled on the device. `arista_eos_ssh` uses SSH only, for environments where eAPI is not available; it exposes exactly the same methods and properties as the eAPI driver, so the two are interchangeable. + The example below shows how to build a device object when working with a Cisco IOS router. ```python diff --git a/mkdocs.yml b/mkdocs.yml index 9d9a9e76..2125dabc 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -162,6 +162,7 @@ nav: - pyntc.devices.asa_device: "code-reference/pyntc/devices/asa_device.md" - pyntc.devices.base_device: "code-reference/pyntc/devices/base_device.md" - pyntc.devices.eos_device: "code-reference/pyntc/devices/eos_device.md" + - pyntc.devices.eos_ssh_device: "code-reference/pyntc/devices/eos_ssh_device.md" - pyntc.devices.f5_device: "code-reference/pyntc/devices/f5_device.md" - pyntc.devices.ios_device: "code-reference/pyntc/devices/ios_device.md" - pyntc.devices.iosxewlc_device: "code-reference/pyntc/devices/iosxewlc_device.md" diff --git a/pyntc/devices/__init__.py b/pyntc/devices/__init__.py index 3756dd33..83bae9c1 100644 --- a/pyntc/devices/__init__.py +++ b/pyntc/devices/__init__.py @@ -3,6 +3,7 @@ from .aireos_device import AIREOSDevice from .asa_device import ASADevice from .eos_device import EOSDevice +from .eos_ssh_device import EOSSSHDevice from .f5_device import F5Device from .ios_device import IOSDevice from .iosxewlc_device import IOSXEWLCDevice @@ -13,6 +14,7 @@ supported_devices = { "cisco_asa_ssh": ASADevice, "arista_eos_eapi": EOSDevice, + "arista_eos_ssh": EOSSSHDevice, "f5_tmos_icontrol": F5Device, "cisco_ios_ssh": IOSDevice, "cisco_iosxr_ssh": IOSXRDevice, diff --git a/pyntc/devices/eos_ssh_device.py b/pyntc/devices/eos_ssh_device.py new file mode 100644 index 00000000..5cbe100e --- /dev/null +++ b/pyntc/devices/eos_ssh_device.py @@ -0,0 +1,434 @@ +"""Module for using an Arista EOS device over SSH. + +This driver exists for environments where eAPI (``management api http-commands``) is not +available. It exposes the same public API as +:class:`~pyntc.devices.eos_device.EOSDevice`; only the transport differs. + +Structured output is obtained with the CLI's ``| json`` pipe, which renders the same +document eAPI returns -- the pipe is a pure CLI feature and does **not** require eAPI to be +enabled. Because the key names match, every fact property, the boot-option handling and the +whole file-transfer family are inherited from ``EOSDevice`` unchanged. +""" + +import json +import os +import re + +from netmiko import ConnectHandler + +from pyntc import log +from pyntc.devices.base_device import BaseDevice, fix_docs +from pyntc.devices.eos_device import DEFAULT_REBOOT_TIMEOUT, EOSDevice +from pyntc.errors import CommandError, CommandListError, FileTransferError, SocketClosedError + +DEFAULT_SSH_PORT = 22 + +# Only "show" commands may be piped to "| json". EOSDevice routes five EXEC/config commands +# through show(raw_text=False) whose return value it discards -- "copy running-config ...", +# "reload now", "configure replace ... force" and "install source ..." -- and +# "reload now | json" is not a valid command. +RE_JSON_ELIGIBLE = re.compile(r"^\s*show\b") + +# EOS reports CLI failures with a leading "% " token. "Invalid input" also appears for +# commands that have no JSON renderer, which _load_json turns into a CommandError rather +# than silently falling back to text parsing (a fallback would return a differently shaped +# document and produce wrong facts instead of an error). +RE_EOS_CLI_ERROR = re.compile(r"^%\s|^Invalid input|^Error:", re.MULTILINE) + +# Commands whose default Netmiko read timeout (100s) is too short. Resolving the timeout +# from the command text -- rather than adding a **netmiko_args parameter -- keeps show()'s +# signature byte-identical to EOSDevice.show(), so inherited callers such as +# ``set_boot_options``, ``save``, ``checkpoint`` and ``rollback`` work without overrides. +COMMAND_READ_TIMEOUTS = ( + (re.compile(r"^\s*install\s+source\b"), 3600), + (re.compile(r"^\s*copy\s+running-config\b"), 300), + (re.compile(r"^\s*configure\s+replace\b"), 300), + (re.compile(r"^\s*show\s+(running|startup)-config\b"), 120), +) +DEFAULT_READ_TIMEOUT = 100 + + +@fix_docs +class EOSSSHDevice(EOSDevice): + """Arista EOS Device Implementation over SSH.""" + + # pylint: disable=too-many-arguments, too-many-positional-arguments, super-init-not-called + def __init__(self, host, username, password, secret="", port=None, **kwargs): # nosec # noqa: D403 + """PyNTC Device implementation for Arista EOS over SSH. + + Args: + host (str): The address of the network device. + username (str): The username to authenticate with the device. + password (str): The password to authenticate with the device. + secret (str): The password to escalate privilege on the device. + port (int): The SSH port to connect on. Defaults to 22. Note this differs from + ``EOSDevice.port``, which is the eAPI port. + kwargs (dict): Additional arguments passed to Netmiko's ``ConnectHandler``. + """ + # Deliberately skips EOSDevice.__init__, which eagerly builds a pyeapi connection + # and takes eAPI-only arguments (transport/timeout). Going straight to BaseDevice + # keeps the shared state without the eAPI wiring. + BaseDevice.__init__( # pylint: disable=non-parent-init-called + self, host, username, password, device_type="arista_eos_ssh" + ) + self.native = None + self.secret = secret + self.port = int(port) if port else DEFAULT_SSH_PORT + self.netmiko_kwargs = kwargs + self._connected = False + self.open() + log.init(host=host) + + @property + def native_ssh(self): + """Alias for ``native`` so inherited Netmiko-backed code works unchanged. + + ``EOSDevice`` reaches for ``self.native_ssh`` in ``enable``, ``file_copy``, + ``check_file_exists``, ``get_remote_checksum`` and ``remote_file_copy``. Only + ``EOSDevice.open`` ever assigns it, and this class overrides ``open``, so exposing + it read-only is safe. + + Returns: + (netmiko.BaseConnection): The active Netmiko connection. + """ + return self.native + + @staticmethod + def _read_timeout_for(command): + """Resolve the Netmiko read timeout to use for ``command``. + + Args: + command (str): The command about to be sent. + + Returns: + (int): Timeout in seconds. + """ + for pattern, timeout in COMMAND_READ_TIMEOUTS: + if pattern.match(command): + return timeout + return DEFAULT_READ_TIMEOUT + + def _check_output_for_errors(self, command, output): + """Raise ``CommandError`` when the device reported a CLI error. + + Args: + command (str): The command that was sent. + output (str): The device response. + + Raises: + CommandError: When ``output`` reports an error. + """ + if RE_EOS_CLI_ERROR.search(output): + log.error("Host %s: Error in %s with response: %s", self.host, command, output) + raise CommandError(command, output) + + def _load_json(self, command, output): + """Parse ``| json`` output. + + Args: + command (str): The command that produced ``output``. + output (str): Raw device response. + + Returns: + (dict): The parsed document. + + Raises: + CommandError: When the output is not valid JSON, which on EOS means the command + has no JSON renderer. + """ + try: + return json.loads(output) + except ValueError: + log.error("Host %s: Command %s did not return JSON: %s", self.host, command, output) + raise CommandError(command, f"Command does not support JSON output: {output}") + + def _send_command(self, command, error_command=None, **netmiko_args): + """Send a single command and check the response for errors. + + Args: + command (str): The command to send on the wire. + error_command (str, optional): The command to name in a raised ``CommandError``. + Defaults to ``command``. ``show`` passes the caller's original command so + errors do not leak the ``| json`` suffix, matching the plain command name + that pyeapi reports on ``EOSDevice``. + netmiko_args (dict): Additional arguments for Netmiko's ``send_command``. + + Returns: + (str): The raw device response. + """ + netmiko_args.setdefault("read_timeout", self._read_timeout_for(command)) + response = self.native.send_command(command, **netmiko_args) + self._check_output_for_errors(error_command or command, response) + return response + + def open(self): + """Open, or re-validate, the Netmiko SSH connection to the device.""" + if self._connected: + try: + self.native.find_prompt() + except Exception: # pylint: disable=broad-except + self._connected = False + + if not self._connected: + self.native = ConnectHandler( + device_type="arista_eos", + host=self.host, + username=self.username, + password=self.password, + port=self.port, + secret=self.secret, + verbose=False, + **self.netmiko_kwargs, + ) + self._connected = True + + log.debug("Host %s: Connection to device was opened successfully.", self.host) + + def close(self): + """Disconnect from the device. + + Note this differs from ``EOSDevice.close``, which is a no-op because eAPI is + stateless. An SSH session holds a real socket that should be released. + """ + if self._connected: + self.native.disconnect() + self._connected = False + log.debug("Host %s: Connection closed.", self.host) + + def show(self, commands, raw_text=False): + """Send show command(s) to the device. + + Args: + commands (str, list): String with single command, or list with multiple commands. + raw_text (bool, optional): False to return structured data via the ``| json`` + pipe, True to return the raw CLI text. Defaults to False. + + Returns: + (dict): When ``commands`` is a str and ``raw_text`` is False. Non-show commands + cannot be piped to ``| json``; they run as plain text and return an empty dict. + (str): When ``commands`` is a str and ``raw_text`` is True. + (list): When ``commands`` is a list. + + Raises: + CommandError: When ``commands`` is a str and the device reports an error. + CommandListError: When ``commands`` is a list and one command reports an error. + """ + self.open() + self.enable() + + original_commands_is_str = isinstance(commands, str) + command_list = [commands] if original_commands_is_str else list(commands) + + responses = [] + entered_commands = [] + for command in command_list: + entered_commands.append(command) + as_json = not raw_text and bool(RE_JSON_ELIGIBLE.match(command)) + cli_command = f"{command} | json" if as_json else command + try: + output = self._send_command(cli_command, error_command=command) + if as_json: + output = self._load_json(command, output) + except CommandError as err: + if original_commands_is_str: + raise + raise CommandListError(entered_commands, command, err.cli_error_msg) from err + + if raw_text or as_json: + responses.append(output) + else: + # Non-show command sent with raw_text=False (checkpoint, save, rollback, + # reboot, set_boot_options). Every inherited caller discards the result, + # so an empty dict preserves EOSDevice's contract. + responses.append({}) + + if original_commands_is_str: + return responses[0] + + log.debug("Host %s: Successfully executed command 'show' with responses %s.", self.host, responses) + return responses + + def config(self, commands): + """Send configuration commands to a device. + + Args: + commands (str, list): String with single command, or list with multiple commands. + + Raises: + CommandError: When ``commands`` is a str and the device reports an error. + CommandListError: When ``commands`` is a list and one command reports an error. + """ + self.open() + self.enable() + + original_commands_is_str = isinstance(commands, str) + command_list = [commands] if original_commands_is_str else list(commands) + + entered_commands = [] + try: + for command in command_list: + entered_commands.append(command) + # Multi-line commands (e.g. "banner motd\n...\nEOF") drop the CLI into an + # input mode whose echo Netmiko's cmd_verify cannot match; verification must + # be disabled for them or send_config_set raises ReadTimeout. + output = self.native.send_config_set(command, exit_config_mode=False, cmd_verify="\n" not in command) + try: + self._check_output_for_errors(command, output) + except CommandError as err: + if original_commands_is_str: + raise + raise CommandListError(entered_commands, command, err.cli_error_msg) from err + finally: + # Never leave the session parked in config mode, even on failure. + self.native.exit_config_mode() + + log.info("Host %s: Device configured with commands %s.", self.host, commands) + + def reboot(self, wait_for_reload=False, timeout=DEFAULT_REBOOT_TIMEOUT, **kwargs): + """Reload the device. + + Unlike eAPI, the SSH session dies as the reload executes, so the command is sent + with ``send_command_timing`` and the resulting transport error is expected. + + Args: + wait_for_reload (bool): When True, block until the device's boot time advances + past the pre-reboot value. Defaults to False. + timeout (int): Max seconds to poll when ``wait_for_reload`` is True. + kwargs (dict): Additional keyword arguments, such as confirm. + + Raises: + RebootTimeoutError: When the device does not return within ``timeout``. + + Example: + >>> device = EOSSSHDevice(**connection_args) + >>> device.reboot() + >>> + """ + if kwargs.get("confirm"): + log.warning("Passing 'confirm' to reboot method is deprecated.") + + original_boot_time = self.boot_time if wait_for_reload else None + try: + self.native.send_command_timing("reload now") + except Exception as err: # pylint: disable=broad-except + log.debug("Host %s: Session dropped during reload, as expected (%s).", self.host, err) + + # The socket is gone regardless of how the command returned; force the next + # operation to reconnect rather than reuse a dead handle. + self._connected = False + log.info("Host %s: Device rebooted.", self.host) + + if wait_for_reload: + # Both arguments are numeric; naming them prevents a transposition from + # silently satisfying the "boot time advanced" check on the first poll. + self._wait_for_device_reboot(original_boot_time=original_boot_time, timeout=timeout) + + def file_copy_remote_exists(self, src, dest=None, file_system=None): + """Check whether ``src`` already exists on the device with a matching checksum. + + ``EOSDevice`` answers this through Netmiko's ``AristaFileTransfer``, which drops into + the switch's Linux shell (``bash`` then ``/bin/ls``). That requires shell privileges + the connecting account may not have. This override uses the CLI instead -- + ``dir /`` and ``verify /md5 `` -- matching how + ``IOSDevice`` already behaves. + + Args: + src (str): Path to the local file to check for. + dest (str, optional): Remote filename. Defaults to the basename of ``src``. + file_system (str, optional): Target filesystem. Auto-detected when omitted. + + Returns: + (bool): True when the remote file exists and its checksum matches ``src``. + """ + self.open() + self.enable() + if file_system is None: + file_system = self._get_file_system() + + dest = dest or os.path.basename(src) + local_checksum = self.get_local_checksum(src) + exists = self.verify_file(local_checksum, dest, file_system=file_system) + + log.debug("Host %s: File %s already on remote: %s.", self.host, src, exists) + return exists + + def file_copy(self, src, dest=None, file_system=None): + """Copy a local file to the device over SCP. + + Mirrors ``IOSDevice.file_copy``: existence and integrity are established with CLI + commands rather than Netmiko's shell-based helpers, so no ``bash`` access is needed. + ``AristaFileTransfer.enable_scp()`` raises ``NotImplementedError``, so unlike IOS + there is no SCP-enable step -- EOS serves SCP without one. + + Args: + src (str): Path to the local file to send. + dest (str, optional): Remote filename. Defaults to the basename of ``src``. + file_system (str, optional): Target filesystem. Auto-detected when omitted. + + Raises: + SocketClosedError: When the session drops mid-transfer and the file did not land. + FileTransferError: When the transfer fails, or the file cannot be verified + afterwards. + NotEnoughFreeSpaceError: When ``file_system`` has less room than ``src`` needs. + """ + self.open() + self.enable() + if file_system is None: + file_system = self._get_file_system() + + dest = dest or os.path.basename(src) + local_checksum = self.get_local_checksum(src) + log.debug("Host %s: Local checksum for file %s is %s.", self.host, src, local_checksum) + + if self.verify_file(local_checksum, dest, file_system=file_system): + log.info("Host %s: File %s already present and verified; skipping.", self.host, dest) + return + + self._check_free_space(os.path.getsize(src), file_system=file_system) + file_copy = self._file_copy_instance(src, dest, file_system=file_system) + + try: + file_copy.establish_scp_conn() + file_copy.transfer_file() + log.info("Host %s: File %s transferred successfully.", self.host, src) + except OSError as error: + # A dropped control channel does not necessarily mean a failed transfer; + # compare_md5() uses the CLI "verify" command, so it is safe without a shell. + if not file_copy.compare_md5(): + log.error("Host %s: Socket closed error %s", self.host, error) + raise SocketClosedError(message=error) from error + log.error("Host %s: OS error %s", self.host, error) + except: # noqa: E722 + log.error("Host %s: File transfer error %s", self.host, FileTransferError.default_message) + raise FileTransferError + finally: + file_copy.close_scp_chan() + + # Long transfers can outlive the control channel; make sure it is usable again. + self.open() + + if not self.verify_file(local_checksum, dest, file_system=file_system): + log.error( + "Host %s: Attempted file copy, but could not validate file existed after transfer %s", + self.host, + FileTransferError.default_message, + ) + raise FileTransferError + + @property + def vlans(self): + """Get list of VLANs on device. + + ``EOSDevice`` delegates to ``EOSVlans``, which is pyeapi-only + (``device.native.api("vlans")``). Over SSH the same data comes from + ``show vlan | json``, whose ``vlans`` key is a dict keyed by VLAN id. + + Returns: + (list): List of VLAN ids as strings. + """ + if self._vlans is None: + # sorted() over str keys, matching EOSVlans.get_list()'s lexicographic ordering. + self._vlans = sorted(self.show("show vlan")["vlans"].keys()) + + log.debug("Host %s: Vlans %s", self.host, self._vlans) + return self._vlans diff --git a/pyntc/devices/jnpr_device.py b/pyntc/devices/jnpr_device.py index b4c347d9..802ecc8b 100644 --- a/pyntc/devices/jnpr_device.py +++ b/pyntc/devices/jnpr_device.py @@ -24,6 +24,7 @@ from pyntc.errors import ( CommandError, CommandListError, + DeviceNotActiveError, FileSystemNotFoundError, FileTransferError, OSInstallError, @@ -108,6 +109,7 @@ def __init__(self, host, username, password, *args, **kwargs): # noqa: D403 self.cu = JunosNativeConfig(self.native) # pylint: disable=invalid-name self.fs = JunosNativeFS(self.native) # pylint: disable=invalid-name self.sw = JunosNativeSW(self.native) # pylint: disable=invalid-name + self._is_chassis_cluster = None def _file_copy_local_file_exists(self, filepath): return os.path.isfile(filepath) @@ -164,8 +166,7 @@ def _get_free_space(self, file_system=None): file_system = _JUNOS_DEFAULT_FILE_SYSTEM usage = self.fs.storage_usage() - # Flat entries always carry a "mount" key; on multi-member platforms - # the top-level values are per-member {filesystem: info} dicts instead. + # Multi-member platforms nest dicts; single-member platforms are flat with "mount" key. is_nested = bool(usage) and all(isinstance(info, dict) and "mount" not in info for info in usage.values()) member_groups = usage if is_nested else {"": usage} @@ -285,8 +286,7 @@ def _wait_for_device_reboot(self, original_uptime, timeout=7200, is_multiple=Fal counted after the initial warm-up delay. Defaults to 2 hours. is_multiple (bool, optional): Whether device is in multi-device configuration. Defaults to False. """ - # Drop the pre-reboot NETCONF session so subsequent probes can't read from - # a stale connection PyEZ still reports as connected. + # Close stale session; PyEZ may report it as connected even after reboot. try: self.close() except Exception as close_exc: # pylint: disable=broad-exception-caught @@ -339,13 +339,11 @@ def _wait_for_device_reboot(self, original_uptime, timeout=7200, is_multiple=Fal def _pending_reboot_members(self): """Return VC member names whose re_info status is still "pending".""" - # Scoped refresh: a full facts_refresh() re-collects every fact (many RPCs) - # when only re_info is needed here. + # Scoped refresh for re_info only; full refresh would re-collect all facts (many RPCs). try: self.native.facts_refresh(keys="re_info") except RuntimeError: - # PyEZ only supports scoped refreshes with fact_style="new" (its default); - # "old"/"both" raise RuntimeError, so fall back to a full refresh. + # PyEZ only supports scoped refresh with fact_style="new"; fall back to full refresh. self.native.facts_refresh() # Virtual-chassis structure: members are under re_info['default'] members = self.native.facts.get("re_info", {}).get("default", {}) @@ -364,6 +362,30 @@ def _vc_member_count(self): members = self.native.facts.get("re_info", {}).get("default", {}) return len([name for name in members if name != "default"]) + def _detect_chassis_cluster(self): + """Detect if device is an SRX chassis-cluster by checking srx_cluster in facts. + + SRX chassis-cluster stores cluster info under the 'srx_cluster' fact key. + Returns True if srx_cluster information is found in facts. + """ + srx_cluster_info = self.native.facts.get("srx_cluster") + is_chassis_cluster = bool(srx_cluster_info) + log.debug( + "Host %s: Checking for SRX chassis-cluster; srx_cluster fact=%s, result=%s", + self.host, + srx_cluster_info, + is_chassis_cluster, + ) + return is_chassis_cluster + + def _is_srx3xx(self): + """Return True if device is SRX3xx platform.""" + if self._model is None: + self._model = self.native.facts.get("model") + if self._model and "srx3" in str(self._model).lower(): + return True + return False + def _wait_for_nssu_completion(self, target_version, timeout=3600, interval=60, expected_members=None): """Wait for all members to complete NSSU/ISSU and run target version. @@ -386,9 +408,7 @@ def _wait_for_nssu_completion(self, target_version, timeout=3600, interval=60, e """ start = time.time() - # Drop the pre-install NETCONF session first: the old master's reboot kills the - # transport, but PyEZ can still report it as connected — the same quirk - # _wait_for_device_reboot guards against. Closing forces a fresh connection. + # Close stale session before poll; PyEZ may report it as connected after reboot. try: self.close() except Exception as close_exc: # pylint: disable=broad-exception-caught @@ -438,10 +458,62 @@ def _wait_for_nssu_completion(self, target_version, timeout=3600, interval=60, e ) raise OSInstallError(hostname=self.hostname, desired_boot=target_version) + def _get_chassis_cluster_versions(self): + """Get software version from both nodes in SRX chassis-cluster. + + Parses 'show version' output to extract version for each node, returning + node numbers as keys for consistency with facts and verification. + + Returns: + dict: Node numbers mapped to their running version. Example: + {'0': '21.4R3-S5.3', '1': '21.4R3-S5.3'} + """ + try: + version_output = self.show("show version") + node_versions = {} + current_node = None + + for line in version_output.splitlines(): + line = line.strip() + + # Detect node header (node0:, node1:, etc.) + if line.startswith("node") and line.endswith(":"): + current_node = line.rstrip(":") + node_versions[current_node] = None + continue + + if current_node is None or node_versions[current_node] is not None: + continue + + # Extract version from "Junos: " line + if line.startswith("Junos:"): + tokens = line.split(":", 1)[1].split() + if tokens: + node_versions[current_node] = tokens[0] + + # Convert node names (node0, node1) to node numbers (0, 1) for consistency + numbered_versions = {} + for node_name, version in node_versions.items(): + if node_name.startswith("node"): + node_num = node_name.replace("node", "") + numbered_versions[node_num] = version + else: + numbered_versions[node_name] = version + + log.debug("Host %s: SRX chassis-cluster node versions: %s", self.host, numbered_versions) + return numbered_versions + except Exception as exc: # pylint: disable=broad-exception-caught + log.error("Host %s: Failed to get chassis-cluster versions: %s", self.host, exc) + return {} + def _get_all_members_version(self): """Get software version running on all members. Parses `show version all-members` output to extract version for each member. + Handles different Junos release formats: Junos 13.2+ prints a dedicated + `Junos: ` line, while older releases only list packages in format + `JUNOS []`. Takes the first version token to drop + qualifiers like "15.1R7-S2 Limited" that would break exact-match comparison. Returns: dict: Member IDs mapped to their running version. Example: @@ -452,11 +524,7 @@ def _get_all_members_version(self): members_versions = {} current_member = None - # splitlines() + strip() normalize CRLF endings and stray indentation - # from the CLI transport, which would otherwise silently defeat the - # startswith/endswith checks below. for line in (raw_line.strip() for raw_line in version_output.splitlines()): - # Detect member header (fpc0:, fpc1:, etc.) if line.startswith("fpc") and line.endswith(":"): current_member = line.split("fpc")[1].rstrip(":") members_versions[current_member] = None @@ -465,14 +533,7 @@ def _get_all_members_version(self): if current_member is None or members_versions[current_member] is not None: continue - # Junos 13.2+ prints a dedicated "Junos: " line, which - # always precedes the package list when both exist. Older - # releases only list packages, and the package names vary by - # platform (EX 15.1 has no "JUNOS Base OS Software Suite" line), - # so fall back to the first "JUNOS []" line. if line.startswith("Junos:"): - # First token only: drops qualifiers like "15.1R7-S2 Limited" - # that would break exact-match comparison to target_version. tokens = line.split(":", 1)[1].split() if tokens: members_versions[current_member] = tokens[0] @@ -492,12 +553,15 @@ def _verify_install_version(self, target_version, is_multiple): Args: target_version (str): The expected version (e.g., "15.1R7-S2"). - is_multiple (bool): Whether this is a multi-device setup. + is_multiple (bool): Whether this is a multi-device setup (virtual-chassis). Raises: OSInstallError: If the target version is not running on any member/device. """ - if is_multiple: + # Check if this is SRX chassis-cluster (multiple nodes but not virtual-chassis) + is_chassis_cluster = self._is_chassis_cluster or self._detect_chassis_cluster() + + if is_multiple and not is_chassis_cluster: members_versions = self._get_all_members_version() if not members_versions: log.warning( @@ -517,6 +581,27 @@ def _verify_install_version(self, target_version, is_multiple): raise OSInstallError(hostname=self.hostname, desired_boot=target_version) log.info("Host %s: All members running target version %s", self.host, target_version) + elif is_chassis_cluster: + # SRX chassis-cluster: verify both nodes running target version + members_versions = self._get_chassis_cluster_versions() + if not members_versions: + log.warning( + "Host %s: Could not verify chassis-cluster node versions after install; proceeding without verification", + self.host, + ) + return + + mismatched = [node for node, version in members_versions.items() if version != target_version] + if mismatched: + log.error( + "Host %s: Version mismatch after install. Expected %s, got %s", + self.host, + target_version, + members_versions, + ) + raise OSInstallError(hostname=self.hostname, desired_boot=target_version) + + log.info("Host %s: All chassis-cluster nodes running target version %s", self.host, target_version) else: # For single device, check facts self.native.facts_refresh() @@ -558,21 +643,226 @@ def _validate_multiple_device(self): try: self.native.facts_refresh() re_info = self.native.facts.get("re_info", {}) + log.debug("Host %s: re_info keys: %s", self.host, list(re_info.keys())) - # Virtual-chassis structure: {'default': {'0': {...}, '1': {...}, 'default': {...}}} - # Count members excluding the 'default' key itself + # VC members nested under re_info['default'], excluding the 'default' key itself. if "default" in re_info and isinstance(re_info["default"], dict): members = {k: v for k, v in re_info["default"].items() if k != "default"} is_multiple = len(members) > 1 + log.debug( + "Host %s: Found re_info['default'] with members: %s, is_multiple=%s", + self.host, + list(members.keys()), + is_multiple, + ) log.info("Host %s: Multiple device configuration detected: %s", self.host, is_multiple) return is_multiple + log.debug("Host %s: re_info['default'] not found or invalid structure", self.host) log.info("Host %s: Multiple device configuration detected: False", self.host) return False except Exception as exc: # pylint: disable=broad-exception-caught log.warning("Host %s: Could not validate multiple devices: %s", self.host, exc) return False + def _get_icu_redundancy_groups(self): + """Get list of redundancy group numbers from chassis-cluster configuration. + + Parses 'show configuration chassis cluster' output to find all configured + redundancy groups. + + Returns: + list: Redundancy group numbers (e.g., [0, 1]) or empty list if not a chassis-cluster. + """ + try: + output = self.show("show configuration chassis cluster") + if "syntax error" in output.lower() or "unknown command" in output.lower(): + return [] + + redundancy_groups = [] + for line in output.splitlines(): + line = line.strip() + if not line.startswith("redundancy-group "): + continue + parts = line.split() + if len(parts) < 2: + continue + try: + group_num = int(parts[1].rstrip("{")) + if group_num not in redundancy_groups: + redundancy_groups.append(group_num) + except ValueError: + pass + + log.debug("Host %s: Found redundancy groups: %s", self.host, sorted(redundancy_groups)) + return sorted(redundancy_groups) + except Exception as exc: # pylint: disable=broad-exception-caught + log.error("Host %s: Failed to get redundancy groups: %s", self.host, exc) + return [] + + def _get_node_redundancy_status(self, node, redundancy_group): + """Get a node's status (primary/secondary) for a specific redundancy group. + + Parses 'show chassis cluster status' output to find the node's role in the given RG. + + Args: + node (str): Node name (e.g., 'node0', 'node1') + redundancy_group (int): Redundancy group number + + Returns: + str: Node status ('primary', 'secondary') or empty string if not found. + """ + try: + output = self.show("show chassis cluster status") + log.debug("Host %s: Parsing cluster status for %s in RG %d", self.host, node, redundancy_group) + current_group = None + + for line in output.splitlines(): + line = line.strip() + + if line.startswith("Redundancy group:"): + try: + parts = line.split(",")[0].split() + current_group = int(parts[2]) + log.debug("Host %s: Found RG header: %d", self.host, current_group) + except (ValueError, IndexError): + pass + continue + + if current_group == redundancy_group: + if line.startswith(node): + log.debug("Host %s: Found node line for RG %d: %s", self.host, redundancy_group, line) + parts = line.split() + if len(parts) >= 3: + status = parts[2] + log.debug( + "Host %s: Extracted status for %s in RG %d: %s", + self.host, + node, + redundancy_group, + status, + ) + return status + + log.warning( + "Host %s: Could not find status for %s in RG %d", + self.host, + node, + redundancy_group, + ) + return "" + except Exception as exc: # pylint: disable=broad-exception-caught + log.error( + "Host %s: Failed to get status for node %s in RG %d: %s", + self.host, + node, + redundancy_group, + exc, + ) + return "" + + def _failover_redundancy_group(self, redundancy_group, target_node): + """Failover a redundancy group to target node. + + Args: + redundancy_group (int): Redundancy group number + target_node (str): Target node name (e.g., 'node0') + + Raises: + CommandError: If failover fails + """ + try: + # Extract node number from name (node0 -> 0, node1 -> 1) + node_num = target_node.replace("node", "") + command = f"request chassis cluster failover redundancy-group {redundancy_group} node {node_num}" + + log.info( + "Host %s: Failing over RG %d to %s", + self.host, + redundancy_group, + target_node, + ) + log.debug("Host %s: Failover command: %s", self.host, command) + + response = self.native.rpc.cli(format="text", command=command) + log.info("Host %s: Failover RPC response for RG %d:\n%s", self.host, redundancy_group, response) + log.info("Host %s: Failover succeeded for RG %d to %s", self.host, redundancy_group, target_node) + time.sleep(30) # Allow cluster to stabilize after failover + except Exception as exc: # pylint: disable=broad-exception-caught + log.error( + "Host %s: Failover failed for RG %d to %s: %s", + self.host, + redundancy_group, + target_node, + exc, + ) + raise CommandError( + command=f"request chassis cluster failover redundancy-group {redundancy_group} node {node_num}", + message=f"Failed to failover RG {redundancy_group} to {target_node}: {exc}", + ) from exc + + def _initiate_issu_upgrade(self, image_name, is_icu=False, no_validate=False): + """Issue in-service upgrade command (ISSU or ICU) via CLI. + + Builds command dynamically: + - Base: request system software in-service-upgrade {image_name} + - If is_icu: append "no-sync" (ICU-specific) + - If no_validate: append "no-validate" (optional for both) + + Args: + image_name (str): Full path to image on device + is_icu (bool): If True, add "no-sync" flag for ICU. Defaults to False. + no_validate (bool): If True, add "no-validate" flag. Defaults to False. + + Raises: + OSInstallError: On RPC command failures (non-timeout errors). + + Returns: + RPC response text if command succeeds, None if RpcTimeoutError occurs + (timeout is expected during device reboot and is not treated as an error). + """ + flags = [] + if is_icu: + flags.append("no-sync") + if no_validate: + flags.append("no-validate") + + command = f"request system software in-service-upgrade {image_name}" + if flags: + command = f"{command} {' '.join(flags)}" + + upgrade_type = "ICU" if is_icu else "ISSU" + log.info("Host %s: Initiating %s upgrade", self.host, upgrade_type) + log.debug("Host %s: Command: %s", self.host, command) + + rpc_response = None + original_timeout = self.native.timeout + try: + if is_icu: + self.native.timeout = 1800 + log.debug("Host %s: Increased RPC timeout to 30 minutes for ICU upgrade", self.host) + rpc_response = self.native.rpc.cli(command=command) + if rpc_response is not None: + response_text = str(rpc_response).strip() + log.info("Host %s: %s response received:\n%s", self.host, upgrade_type, response_text) + else: + log.info("Host %s: %s command accepted (no immediate response)", self.host, upgrade_type) + except RpcTimeoutError: + log.info( + "Host %s: RPC timeout during %s upgrade — device initiated reboot (expected behavior)", + self.host, + upgrade_type, + ) + except Exception as exc: # pylint: disable=broad-exception-caught + log.error( + "Host %s: %s command failed with error:\n%s\nCommand was: %s", self.host, upgrade_type, exc, command + ) + raise OSInstallError(hostname=self.hostname, desired_boot=image_name) from exc + finally: + self.native.timeout = original_timeout + + return rpc_response + def _wait_for_system_snapshot(self, timeout=3600, interval=30): """Poll device to verify system snapshot completion. @@ -609,8 +899,7 @@ def _wait_for_system_snapshot(self, timeout=3600, interval=30): while time.time() - start < timeout: try: output = self.native.cli("show system snapshot media internal") - # Check for actual snapshot success: "Creation date:" indicates snapshots exist - # and timestamps indicate they were recently created + # "Creation date:" indicates recent snapshot completion. if "Creation date:" in output: log.info("Host %s: System snapshot verified (snapshots with creation dates found).", self.host) return @@ -899,135 +1188,332 @@ def file_copy_remote_exists(self, src, dest=None, **kwargs): return True return False + def _install_os_icu( + self, + image_name, + checksum, + reboot=True, + hashing_algorithm="md5", + validate=False, + snapshot=False, + ): # pylint: disable=too-many-positional-arguments,unused-argument,too-many-locals,too-many-branches,too-many-statements + """Execute ICU (In-service Cluster Upgrade) for SRX3xx chassis-cluster. + + ICU is a ~30-second disruptive upgrade with automatic failover and reboot. + The in-service upgrade reboots both nodes in sequence with automatic failover. + + Args: + image_name (str): Name of image. + checksum (str): The checksum of the file. + reboot (bool): Whether to reboot after install. Must be True for ICU. Defaults to True. + hashing_algorithm (str): The hashing algorithm to use. Defaults to 'md5'. + validate (bool): Perform image validation. When False, adds 'no-validate' flag. Defaults to False. + snapshot (bool): Take post-upgrade system snapshot. Defaults to False. + + Returns: + bool: True if upgrade completed successfully. + + Raises: + ValueError: When reboot=False (ICU requires automatic reboot). + OSInstallError: On upgrade failure or version verification failure. + """ + if not reboot: + raise ValueError("ICU (in-service cluster upgrade) requires automatic reboot; reboot=False is invalid") + + # Extract target version from image name + match = _JUNOS_VERSION_RE.search(image_name) + target_version = match.group(1) if match else None + if target_version is None: + log.warning("Host %s: Could not extract version from image %s", self.host, image_name) + + # Capture pre-upgrade uptime BEFORE initiating upgrade + try: + self._uptime = None + original_uptime = self.uptime + log.debug("Host %s: Pre-upgrade uptime: %s seconds", self.host, original_uptime) + except Exception as uptime_exc: # pylint: disable=broad-exception-caught + log.warning("Host %s: Could not capture pre-upgrade uptime: %s (proceeding anyway)", self.host, uptime_exc) + original_uptime = None + + # Issue ICU command (no-sync flag is ICU-specific) + # Note: validate=False means we want to skip validation, so no_validate=True + # Reactive failover: catch "primary node" RPC errors, failover, retry once + rpc_response = None + try: + rpc_response = self._initiate_issu_upgrade(image_name, is_icu=True, no_validate=not validate) + except RpcError as rpc_err: + # Check if this is a "primary node error" by inspecting the RPC response + err_text = str(rpc_err.rsp) if hasattr(rpc_err, "rsp") else str(rpc_err) + if not isinstance(err_text, str): + err_text = str(err_text) + err_text = err_text.lower() + if "primary node" in err_text: + log.warning( + "Host %s: ICU initiation failed with 'primary node' error. Attempting failover and retry.", + self.host, + ) + # Determine current node and non-primary redundancy groups + try: + self.native.facts_refresh() + current_re = self.native.facts.get("current_re", []) + if not current_re or len(current_re) == 0: + raise DeviceNotActiveError( + hostname=self.hostname, + redundancy_state="unknown", + peer_redundancy_state="unknown", + ) + current_node = current_re[0] + + # Get RGs that are not primary + redundancy_groups = self._get_icu_redundancy_groups() + for rg in redundancy_groups: + status = self._get_node_redundancy_status(current_node, rg) + if status != "primary": + log.info("Host %s: Failing over RG %d to achieve primary", self.host, rg) + self._failover_redundancy_group(rg, current_node) + + # Re-check: all RGs must be primary now + for rg in redundancy_groups: + status = self._get_node_redundancy_status(current_node, rg) + if status != "primary": + log.error( + "Host %s: Still not primary for RG %d after failover (status: %s)", + self.host, + rg, + status, + ) + raise DeviceNotActiveError( + hostname=self.hostname, + redundancy_state=status, + peer_redundancy_state="primary" if status == "secondary" else "secondary", + ) + + # Retry ICU after successful failover + log.info("Host %s: Retrying ICU upgrade after failover", self.host) + rpc_response = self._initiate_issu_upgrade(image_name, is_icu=True, no_validate=not validate) + except (CommandError, OSInstallError, DeviceNotActiveError): + raise + except Exception as failover_exc: # pylint: disable=broad-exception-caught + log.error("Host %s: Failover recovery failed: %s", self.host, failover_exc) + raise OSInstallError(hostname=self.hostname, desired_boot=image_name) from failover_exc + else: + # Non-primary errors are not retried + log.error("Host %s: ICU failed with non-recoverable RPC error: %s", self.host, rpc_err) + raise OSInstallError(hostname=self.hostname, desired_boot=image_name) from rpc_err + + if rpc_response: + log.info("Host %s: ICU RPC response captured", self.host) + else: + log.info("Host %s: No RPC response (device initiated reboot)", self.host) + + # Wait for device to reboot and come back up with warm-up-then-poll pattern + log.info("Host %s: ICU upgrade initiated. Waiting for device to reboot and synchronize", self.host) + + if original_uptime is not None: + log.info( + "Host %s: Waiting %s seconds before polling for reboot completion", + self.host, + _JUNOS_POLL_WARMUP_SECONDS, + ) + time.sleep(_JUNOS_POLL_WARMUP_SECONDS) + log.info("Host %s: Polling for device reboot completion", self.host) + self._wait_for_device_reboot(original_uptime, timeout=2400, is_multiple=True) + log.info("Host %s: Device rebooted successfully", self.host) + else: + log.warning("Host %s: Skipping reboot poll (uptime unavailable)", self.host) + + # Post-upgrade checks + self._post_install_checks( + image_name, + is_multiple=True, + in_service=True, + nssu=False, + verification_required=False, + snapshot=snapshot, + ) + + log.info("Host %s: ICU upgrade completed successfully", self.host) + return True + def install_os( - self, image_name, checksum, reboot=True, hashing_algorithm="md5", nssu=False, issu=False, snapshot=False - ): # pylint: disable=too-many-positional-arguments + self, + image_name, + checksum, + reboot=True, + hashing_algorithm="md5", + nssu=False, + issu=False, + snapshot=False, + validate=False, + ): # pylint: disable=too-many-positional-arguments,too-many-locals,too-many-branches,too-many-statements """Install OS on device and reboot. - For multi-device setups (virtual-chassis, chassis-cluster), supports NSSU/ISSU - upgrades and optional system snapshots after reboot. NSSU/ISSU performs its own - rolling reboot member-by-member during the install, so no separate reboot is - issued on that path; completion is verified by polling until every member - reports the target version. + For multi-device setups (virtual-chassis, chassis-cluster), supports ICU/NSSU/ISSU + upgrades and optional system snapshots after reboot. Upgrade method is auto-detected: + - SRX300-380 chassis-cluster → ICU (in-service cluster upgrade, ~30s downtime) + - SRX high-end chassis-cluster → ISSU (in-service software upgrade, zero downtime) + - Virtual-chassis + nssu flag → NSSU (nonstop software upgrade) + - Standalone or standard install → standard OS install + + In-service upgrades (ICU/ISSU/NSSU) perform rolling reboots member-by-member during + the install, so no separate reboot is issued on those paths; completion is verified + by polling until every member reports the target version. For ICU, the device performs + automatic failover during reboot; the upgrade must be initiated on the primary node. Args: image_name (str): Name of image. checksum (str): The checksum of the file. reboot (bool): Whether to reboot the device after setting the boot options. Defaults to True. - hashing_algorithm (str): The hashing algorithm to use. Valid values are 'md5', 'sha1', and 'sha256'. Defaults to 'md5'. - nssu (bool): Enable Nonstop Software Upgrade. Defaults to False. - issu (bool): Enable In-Service Software Upgrade. Defaults to False. - snapshot (bool): Take a post-upgrade ``request system snapshot slice alternate`` - to sync the alternate root with the new version. Junos does not require a - snapshot to complete an upgrade, but on dual-root platforms an unsynced - alternate slice boots the OLD version if the device ever falls back to it. - Snapshots can take 25+ minutes per member on small-flash platforms. - Defaults to False. + For ICU/ISSU/NSSU, must be True (automatic reboot is part of the upgrade). + hashing_algorithm (str): The hashing algorithm to use. Valid values are md5, sha1, and sha256. + Defaults to md5. + nssu (bool): Enable Nonstop Software Upgrade (virtual-chassis only). Defaults to False. + issu (bool): Ignored; ISSU and ICU are auto-detected based on device type. Defaults to False. + snapshot (bool): Take a post-upgrade system snapshot to sync the alternate root with the + new version. Junos does not require a snapshot to complete an upgrade, but on dual-root + platforms an unsynced alternate slice boots the OLD version if the device ever falls back + to it. Snapshots can take 25+ minutes per member on small-flash platforms. Defaults to False. + validate (bool): Perform validation of the image during install. Defaults to False. + For multi-device setups, validation can exceed 30 minutes and cause timeouts; + validation is not recommended on multi-device platforms. When False on ICU/ISSU, + adds no-validate flag to skip validation (faster upgrade). Raises: - ValueError: When both nssu and issu are True (mutually exclusive), or when - ``reboot=False`` is combined with an in-service (NSSU/ISSU) upgrade on a - multi-device setup — the in-service install reboots the members itself, - so there is no reboot left to defer. + ValueError: When both nssu and issu are True (mutually exclusive), or when reboot=False + is combined with an in-service (ICU/ISSU/NSSU) upgrade. + CommandError: When ICU upgrade fails to verify primary node availability. """ if nssu and issu: raise ValueError("nssu and issu are mutually exclusive; only one can be True") is_multiple = self._validate_multiple_device() + is_chassis_cluster = self._is_chassis_cluster or self._detect_chassis_cluster() + log.info( + "Host %s: Multi-device detected: %s, Chassis-cluster detected: %s", + self.host, + is_multiple, + is_chassis_cluster, + ) + # TODO: test this + if is_multiple and not validate: + log.warning( + "Host %s: Image validation is disabled for multi-device install; " + "enable it only if live device testing shows no timeout issues.", + self.host, + ) + + # ICU (in-service-upgrade) for SRX3xx chassis-cluster is a modified ISSU + is_icu = is_chassis_cluster and self._is_srx3xx() # In-service upgrades only apply to multi-device setups; on a standalone # device the nssu/issu flags are ignored and a standard install runs. - in_service = (nssu or issu) and is_multiple + # ICU for SRX3xx chassis-cluster is also in-service (automatic rolling reboot). + in_service = ((nssu or issu) and is_multiple) or is_icu if in_service and not reboot: raise ValueError( - "reboot=False cannot be combined with nssu/issu on a multi-device setup; " - "the in-service upgrade reboots the members as part of the install" + "reboot=False cannot be combined with in-service upgrades (NSSU/ISSU/ICU); " + "the in-service upgrade reboots the device(s) as part of the install" ) - install_kwargs = { - "package": image_name, - "checksum": checksum, - "checksum_algorithm": hashing_algorithm, - "progress": True, - "validate": True, - "no_copy": True, - "timeout": 3600, - } - - if in_service: - install_kwargs["nssu" if nssu else "issu"] = True - log.info( - "Host %s: %s enabled for multi-device upgrade", - self.host, - "NSSU" if nssu else "ISSU", + # Route to ICU path for SRX300-380 chassis-cluster (auto-detected) + icu_complete = False + if is_icu: + icu_complete = self._install_os_icu( + image_name, + checksum, + reboot=reboot, + hashing_algorithm=hashing_algorithm, + validate=validate, + snapshot=snapshot, ) - # Sometimes install() returns a tuple of (ok, msg). Other times it returns a single bool - install_ok = self.sw.install(**install_kwargs) - install_msg = None - if isinstance(install_ok, tuple): - install_ok, install_msg = install_ok[0], install_ok[1] + if not icu_complete: + # Standard install, NSSU, or ISSU (non-ICU) paths + install_kwargs = { + "package": image_name, + "checksum": checksum, + "checksum_algorithm": hashing_algorithm, + "progress": True, + "validate": validate, + "no_copy": True, + "timeout": 3600, + } + + if in_service: + install_kwargs["nssu" if nssu else "issu"] = True + log.info( + "Host %s: %s enabled for multi-device upgrade", + self.host, + "NSSU" if nssu else "ISSU", + ) + else: + log.info("Host %s: Standard install (no NSSU/ISSU)", self.host) + + log.debug("Host %s: install_kwargs before install: %s", self.host, install_kwargs) + # Sometimes install() returns a tuple of (ok, msg). Other times it returns a single bool + install_ok = None + install_msg = None + try: + install_ok = self.sw.install(**install_kwargs) + log.info("Host %s: Install RPC response: %s", self.host, install_ok) + if isinstance(install_ok, tuple): + install_ok, install_msg = install_ok[0], install_ok[1] + except RpcError as rpc_err: + log.error("Host %s: Install RPC error: %s", self.host, rpc_err) + raise + + log.info("Host %s: install_ok result: %s", self.host, install_ok) + if install_msg: + log.debug("Host %s: install message: %s", self.host, install_msg) + + # In-service upgrades roll reboot per member; don't treat "A reboot is required" as outstanding. + reboot_required = bool(install_msg) and "A reboot is required" in str(install_msg) and not in_service + + if not install_ok and not reboot_required: + log.error( + "Host %s: SW install failed for image %s. Device is in undefined state.", + self.host, + image_name, + ) + raise OSInstallError(hostname=self.hostname, desired_boot=image_name) - log.info("Host %s: install_ok result: %s", self.host, install_ok) - if install_msg: - log.debug("Host %s: install message: %s", self.host, install_msg) + if not reboot: + if reboot_required: + raise OSInstallError(hostname=self.hostname, desired_boot=image_name) + log.info("Host %s: OS image %s boot options set. Reboot the device to apply", self.host, image_name) + return True - # Check if reboot is required (indicated by specific message in output). - # NSSU/ISSU per-member output contains "A reboot is required" as informational - # text, but the rolling reboot already happens inside the install — don't - # treat it as an outstanding manual reboot on that path. - reboot_required = bool(install_msg) and "A reboot is required" in str(install_msg) and not in_service + self._reboot_to_apply(image_name, is_multiple, in_service) - if not install_ok and not reboot_required: - log.error( - "Host %s: SW install failed for image %s. Device is in undefined state.", - self.host, + self._post_install_checks( image_name, + is_multiple, + in_service, + nssu, + verification_required=not install_ok, + snapshot=snapshot, ) - raise OSInstallError(hostname=self.hostname, desired_boot=image_name) - - if not reboot: - if reboot_required: - raise OSInstallError(hostname=self.hostname, desired_boot=image_name) - log.info("Host %s: OS image %s boot options set. Reboot the device to apply", self.host, image_name) - return True - - self._reboot_to_apply(image_name, is_multiple, in_service, nssu) - - self._post_install_checks( - image_name, - is_multiple, - in_service, - nssu, - verification_required=not install_ok, - snapshot=snapshot, - ) log.info("Host %s: OS image %s installed successfully.", self.host, image_name) return True - def _reboot_to_apply(self, image_name, is_multiple, in_service, nssu): + def _reboot_to_apply(self, image_name, is_multiple, in_service): """Reboot to apply the installed image, unless the in-service upgrade already did. Args: image_name (str): Name of the installed image; used for log context only. is_multiple (bool): Whether device is in multi-device configuration. - in_service (bool): Whether the install ran as NSSU/ISSU. - nssu (bool): True for NSSU, False for ISSU; only used for log labels. + in_service (bool): Whether the install ran as NSSU/ISSU/ICU. Raises: CommandError: When the pre-reboot uptime cannot be determined on the multi-device path (the reboot is refused rather than issued blind). """ if in_service: - # The in-service upgrade already rolled through the members and rebooted - # each one inside ``sw.install()`` — the old master is typically still - # rebooting when the install RPC returns. Issuing another reboot here - # would take the whole chassis down and race that in-progress reboot. + # In-service upgrades automatically reboot devices log.info( - "Host %s: %s performed its rolling reboot during install; skipping manual reboot", + "Host %s: performed a in-service upgrade and reboots automatically; skipping manual reboot", self.host, - "NSSU" if nssu else "ISSU", ) return @@ -1048,16 +1534,27 @@ def _reboot_to_apply(self, image_name, is_multiple, in_service, nssu): self.reboot(wait_for_reload=True) def _post_install_checks( - self, image_name, is_multiple, in_service, nssu, verification_required=False, snapshot=False + self, + image_name, + is_multiple, + in_service, + nssu, + verification_required=False, + snapshot=False, ): # pylint: disable=too-many-positional-arguments """Wait for in-service completion, optionally snapshot, and verify the running version. + For in-service upgrades (NSSU/ISSU/ICU), waits for all members to reach the + target version before snapshot. This completion wait already verifies every + member runs the target version, so skips redundant verification after. + For standard installs, post-install verification confirms the upgrade worked. + Args: image_name (str): Name of the installed image; the target version is extracted from it. When no version can be extracted, the completion wait and version verification are skipped with a warning. is_multiple (bool): Whether device is in multi-device configuration. - in_service (bool): Whether the install ran as NSSU/ISSU. + in_service (bool): Whether the install ran as NSSU/ISSU/ICU (in-service upgrade). nssu (bool): True for NSSU, False for ISSU; only used for log labels. verification_required (bool): True when the install only proceeded on the "A reboot is required" heuristic (PyEZ reported failure); post-reboot @@ -1087,9 +1584,6 @@ def _post_install_checks( image_name, ) - # For in-service upgrades, wait for all members to reach the target version before - # snapshot. This wait already confirms every member runs target_version, so the - # post-snapshot verification below would just repeat the same RPC and check. verified_by_completion_wait = bool(target_version) and in_service if verified_by_completion_wait: log.info( @@ -1098,8 +1592,6 @@ def _post_install_checks( "NSSU" if nssu else "ISSU", target_version, ) - # Member count from the pre-install facts: a member absent from - # ``show version all-members`` while it reboots must not count as done. self._wait_for_nssu_completion(target_version, expected_members=self._vc_member_count() or None) # Optionally sync the alternate root with the new version after reboot/upgrade diff --git a/pyproject.toml b/pyproject.toml index f8db3b83..1f845f8b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "pyntc" -version = "3.2.2" +version = "3.2.3a0" description = "Python library focused on tasks related to device level and OS management." authors = ["Network to Code, LLC "] readme = "README.md" diff --git a/tests/fixtures/.ntc.conf.sample b/tests/fixtures/.ntc.conf.sample index e3ff40c0..fff8d330 100644 --- a/tests/fixtures/.ntc.conf.sample +++ b/tests/fixtures/.ntc.conf.sample @@ -9,7 +9,13 @@ username: user password: arista transport: http +[arista_eos_ssh:test_eos_ssh] +host: 192.168.43.4 +username: user +password: arista +port: 22 + [cisco_ios_ssh:test_ios] username: user password: pass -secret: cisco \ No newline at end of file +secret: cisco diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 6e4fb233..a423e157 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -19,6 +19,7 @@ # integration tests. _PLATFORM_HASH_ALGOS = { "test_eos_device": "sha512", + "test_eos_ssh_device": "sha512", "test_asa_device": "sha512", "test_jnpr_device": "sha256", "test_ios_device": "md5", diff --git a/tests/integration/test_eos_ssh_device.py b/tests/integration/test_eos_ssh_device.py new file mode 100644 index 00000000..87168d7f --- /dev/null +++ b/tests/integration/test_eos_ssh_device.py @@ -0,0 +1,234 @@ +"""Integration tests for EOSSSHDevice. + +These tests connect to an actual Arista EOS device over SSH and are run manually. +They are NOT part of the CI unit test suite. + +This suite is the hardware validation for the ``arista_eos_ssh`` driver. It deliberately +covers two things the unit tests cannot: + +1. That ``show | json`` really does return the eAPI-shaped document the driver + relies on -- ``test_json_key_contract`` asserts every key the inherited fact properties + dereference. This is the design's load-bearing assumption. +2. That eAPI is genuinely not required -- nothing here enables or touches + ``management api http-commands``. + +Usage (from project root): + export EOS_SSH_HOST= + export EOS_SSH_USER= + export EOS_SSH_PASS= + export SCP_URL=scp://:@/ + export HTTP_URL=http://:@:8081/ + export FILE_CHECKSUM_512= + export FILE_SIZE= + export FILE_SIZE_UNIT=megabytes # optional; defaults to "bytes" + poetry run pytest tests/integration/test_eos_ssh_device.py -v + +Set only the protocol URL vars for the servers you have available; each protocol test +skips automatically if its URL is not set. + +Environment variables: + EOS_SSH_HOST - IP address or hostname of the lab EOS device + EOS_SSH_USER - SSH username + EOS_SSH_PASS - SSH password + EOS_SSH_SECRET - Enable secret (optional) + EOS_SSH_PORT - SSH port (optional; defaults to 22) + FTP_URL - FTP URL of the file to transfer + TFTP_URL - TFTP URL of the file to transfer + SCP_URL - SCP URL of the file to transfer + HTTP_URL - HTTP URL of the file to transfer + HTTPS_URL - HTTPS URL of the file to transfer + SFTP_URL - SFTP URL of the file to transfer + FILE_NAME - Destination filename on the device (default: basename of URL path) + FILE_CHECKSUM_512 - Expected sha512 checksum of the file + FILE_SIZE - Expected size of the file expressed in FILE_SIZE_UNIT units + FILE_SIZE_UNIT - One of "bytes", "megabytes", or "gigabytes" (default: "bytes") +""" + +import os + +import pytest + +from pyntc.devices import EOSSSHDevice + +from ._helpers import build_file_copy_model + +# Every key the inherited EOSDevice fact properties dereference, per command. If this test +# passes on real hardware, the "| json" output is eAPI-compatible and the driver's whole +# inheritance strategy is sound. +JSON_KEY_CONTRACT = { + "show version": ["bootupTimestamp", "modelName", "internalVersion", "serialNumber"], + "show hostname": ["hostname", "fqdn"], + "show boot-config": ["softwareImage"], + "show interfaces status": ["interfaceStatuses"], + "show vlan": ["vlans"], +} + +# Per-interface keys consumed by _interfaces_status_list via INTERFACES_KM. +INTERFACE_KEYS = ["bandwidth", "duplex", "linkStatus", "description"] + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def device(): + """Connect to the lab EOS device over SSH. Skips all tests if credentials are not set.""" + host = os.environ.get("EOS_SSH_HOST") + user = os.environ.get("EOS_SSH_USER") + password = os.environ.get("EOS_SSH_PASS") + + if not all([host, user, password]): + pytest.skip("EOS_SSH_HOST / EOS_SSH_USER / EOS_SSH_PASS environment variables not set") + + dev = EOSSSHDevice( + host, + user, + password, + secret=os.environ.get("EOS_SSH_SECRET", ""), + port=os.environ.get("EOS_SSH_PORT"), + ) + yield dev + dev.close() + + +# --------------------------------------------------------------------------- +# The load-bearing assumption +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("command,keys", sorted(JSON_KEY_CONTRACT.items())) +def test_json_key_contract(device, command, keys): + """``show ... | json`` must return the eAPI-shaped document the driver depends on.""" + result = device.show(command) + assert isinstance(result, dict), f"{command} | json did not return a JSON object" + for key in keys: + assert key in result, f"{command} | json is missing key '{key}'" + + +def test_interface_status_keys(device): + """Each interface entry must carry the keys ``_interfaces_status_list`` reshapes.""" + statuses = device.show("show interfaces status")["interfaceStatuses"] + assert statuses, "device reported no interfaces" + for name, interface in statuses.items(): + for key in INTERFACE_KEYS: + assert key in interface, f"interface {name} is missing key '{key}'" + + +def test_non_show_commands_are_not_piped_to_json(device): + """A non-show command must not gain a ``| json`` suffix, which would be invalid.""" + # "show clock" proves the pipe is applied; a bare command proves it is not. + assert isinstance(device.show("show clock"), dict) + + +# --------------------------------------------------------------------------- +# Facts and config retrieval +# --------------------------------------------------------------------------- + + +def test_device_connects(device): + """Verify the device is reachable and responds to show commands.""" + assert device.hostname + assert device.os_version + + +def test_facts(device): + """Every fact property must resolve without error.""" + assert isinstance(device.uptime, int) + assert isinstance(device.boot_time, float) + assert device.model + assert isinstance(device.serial_number, str) + assert isinstance(device.interfaces, list) + assert isinstance(device.vlans, list) + assert device.boot_options["sys"] + + +def test_running_config(device): + """The running config must come back as non-empty text.""" + assert "hostname" in device.running_config + + +def test_startup_config(device): + """The startup config must come back as non-empty text.""" + assert device.startup_config.strip() + + +def test_config_round_trip(device): + """Apply a harmless config change, confirm it lands in the running config, then remove it.""" + marker = "pyntc integration test" + # Multi-line on purpose: banners exercise the cmd_verify=False path in config(). + device.config(f"banner motd\n{marker}\nEOF") + try: + assert marker in device.running_config + finally: + device.config("no banner motd") + assert marker not in device.running_config + + +def test_show_raises_on_bad_command(device): + """A bogus show command must raise CommandError, not return garbage.""" + from pyntc.errors import CommandError + + with pytest.raises(CommandError): + device.show("show definitely-not-a-command") + + +# --------------------------------------------------------------------------- +# Filesystem and transfer +# --------------------------------------------------------------------------- + + +def test_file_system_detection(device): + """The default filesystem must parse out of ``dir`` output.""" + assert device._get_file_system().endswith(":") + + +def test_free_space(device): + """Free space must parse out of ``dir`` output as a positive integer.""" + assert device._get_free_space() > 0 + + +def test_remote_file_copy_scp(device): + """Transfer the file using SCP and verify it exists on the device.""" + model = build_file_copy_model("SCP_URL") + device.remote_file_copy(model) + assert device.check_file_exists(model.file_name) + + +def test_remote_file_copy_http(device): + """Transfer the file using HTTP and verify it exists on the device.""" + model = build_file_copy_model("HTTP_URL") + device.remote_file_copy(model) + assert device.check_file_exists(model.file_name) + + +def test_remote_file_copy_ftp(device): + """Transfer the file using FTP and verify it exists on the device.""" + model = build_file_copy_model("FTP_URL") + device.remote_file_copy(model) + assert device.check_file_exists(model.file_name) + + +def test_remote_file_copy_tftp(device): + """Transfer the file using TFTP and verify it exists on the device.""" + model = build_file_copy_model("TFTP_URL") + device.remote_file_copy(model) + assert device.check_file_exists(model.file_name) + + +def test_get_remote_checksum(device): + """If the transferred file exists, its checksum must come back non-empty.""" + model = build_file_copy_model("SCP_URL") + if not device.check_file_exists(model.file_name): + pytest.skip("File does not exist on device; run a remote_file_copy test first") + checksum = device.get_remote_checksum(model.file_name, hashing_algorithm="sha512") + assert checksum + + +def test_verify_file(device): + """verify_file must confirm the transferred file against its expected checksum.""" + model = build_file_copy_model("SCP_URL") + if not device.check_file_exists(model.file_name): + pytest.skip("File does not exist on device; run a remote_file_copy test first") + assert device.verify_file(model.checksum, model.file_name, hashing_algorithm="sha512") is True diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index fe9fbede..516871a2 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -3,7 +3,7 @@ import pytest -from pyntc.devices import AIREOSDevice, ASADevice, EOSDevice, IOSDevice, IOSXEWLCDevice +from pyntc.devices import AIREOSDevice, ASADevice, EOSDevice, EOSSSHDevice, IOSDevice, IOSXEWLCDevice def get_side_effects(mock_path, side_effects): @@ -59,6 +59,65 @@ def _mock(side_effects, existing_device=None, device=eos_device): return _mock +# EOS SSH fixtures + + +@pytest.fixture +def eos_ssh_device(): + with mock.patch("pyntc.devices.eos_ssh_device.ConnectHandler") as ch: + device = EOSSSHDevice("host", "user", "password") + device.native = ch + # Model the normal steady state: already privileged, not parked in config mode. + # Without this the inherited enable() would call exit_config_mode() on every + # show()/config(), polluting call-count assertions. + device.native.check_enable_mode.return_value = True + device.native.check_config_mode.return_value = False + yield device + + +@pytest.fixture +def eos_ssh_device_path(): + return "pyntc.devices.eos_ssh_device.EOSSSHDevice" + + +@pytest.fixture +def eos_ssh_mock_path(mock_path): + return f"{mock_path}/eos_ssh" + + +@pytest.fixture +def eos_ssh_send_command(eos_ssh_device, eos_ssh_mock_path): + def _mock(side_effects, existing_device=None, device=eos_ssh_device): + if existing_device is not None: + device = existing_device + device.native.send_command.side_effect = get_side_effects(eos_ssh_mock_path, side_effects) + return device + + return _mock + + +@pytest.fixture +def eos_ssh_send_command_timing(eos_ssh_device, eos_ssh_mock_path): + def _mock(side_effects, existing_device=None, device=eos_ssh_device): + if existing_device is not None: + device = existing_device + device.native.send_command_timing.side_effect = get_side_effects(eos_ssh_mock_path, side_effects) + return device + + return _mock + + +@pytest.fixture +def eos_ssh_config(eos_ssh_device, eos_ssh_mock_path): + def _mock(side_effects, existing_device=None, device=eos_ssh_device): + if existing_device is not None: + device = existing_device + device.native.send_config_set.side_effect = get_side_effects(eos_ssh_mock_path, side_effects) + return device + + return _mock + + @pytest.fixture def aireos_boot_image(): return "8.2.170.0" diff --git a/tests/unit/test_devices/device_mocks/eos_ssh/README.md b/tests/unit/test_devices/device_mocks/eos_ssh/README.md new file mode 100644 index 00000000..76cb5839 --- /dev/null +++ b/tests/unit/test_devices/device_mocks/eos_ssh/README.md @@ -0,0 +1,41 @@ +# `arista_eos_ssh` mock fixtures + +Golden CLI output for `EOSSSHDevice` unit tests. Loaded by `get_side_effects()` in `tests/unit/conftest.py` — any string in a side-effect list naming a file here is replaced by that file's contents. + +## Provenance + +Captured from a real **Arista DCS-7050TX-64-R running EOS 4.28.5M**, over SSH, via `show | json`. This capture is what confirmed the driver's central design assumption: the `| json` pipe returns the same document, with the same key names, that eAPI returns — without eAPI being involved. + +| Fixture | Source | +| --- | --- | +| `show_version_json` | real capture, `show version \| json` | +| `show_hostname_json` | real capture, `show hostname \| json` | +| `show_interfaces_status_json` | real capture, `show interfaces status \| json` (65 interfaces) | +| `show_boot-config_json` | real capture, `show boot-config \| json` | +| `show_vlan_json` | real capture, `show vlan \| json` | +| `dir` | real capture, `dir` | +| `show_boot` | real capture, `show boot` | +| `show_running-config` | **not** from hardware — the repo's sanitised vEOS config (see below) | +| `show_startup-config` | **not** from hardware — the repo's sanitised vEOS config (see below) | + +### Sanitisation + +Three values were replaced in `show_version_json`; everything else is verbatim: + +- `serialNumber` → `JPE00000000` +- `systemMacAddress` / `hwMacAddress` → `00:1c:73:00:00:01` + +And in `show_interfaces_status_json`, the `Ethernet1` description was replaced with `lab uplink` (it named a client). + +### Why the configs are not real captures + +`running_config` and `startup_config` are returned verbatim by the driver and never parsed, so a real capture would validate nothing — while writing device hostnames, SNMP communities and password hashes into a committed test tree. Those two files are the repo's existing sanitised vEOS config, kept only so the tests have non-empty text to work with. That is why they say `eos-spine1` while every other fixture says `nyc-eos-01`. + +The one genuine risk those commands carry is that a config line beginning with `% ` (inside a banner, say) would false-positive the driver's CLI error regex and make `running_config` raise on a healthy device. That was checked directly on hardware with `show running-config | include ^%` — no matches. `design-notes/capture_eos_ssh_fixtures.py` re-runs that scan and reports only a line count, never content. + +## Edge cases this capture pins down + +Both were absent from the older vEOS fixtures and would have gone untested: + +- **`softwareImage` carries a `flash:/` prefix** (`flash:/EOS-4.28.5M.swi`). `boot_options` strips it with `.replace("flash:/", "")`; the vEOS fixture had no prefix, so that line had never been exercised against realistic input. +- **`Management1` is routed and has no `vlanId`** inside `vlanInformation`. The interface key map must resolve that to `None` rather than raising. diff --git a/tests/unit/test_devices/device_mocks/eos_ssh/dir b/tests/unit/test_devices/device_mocks/eos_ssh/dir new file mode 100644 index 00000000..fc7b0c64 --- /dev/null +++ b/tests/unit/test_devices/device_mocks/eos_ssh/dir @@ -0,0 +1,18 @@ +Directory of flash:/ + + -rwx 1386 Aug 5 20:51 AsuFastPktTransmit.log + -rwx 764915173 Aug 5 20:49 EOS-4.28.5M.swi + -rwx 764834205 Aug 5 17:46 EOS-4.28.9M.swi + drwx 4096 Aug 3 21:32 Fossil + -rwx 852 Aug 5 20:51 SsuRestore.log + -rwx 852 Aug 5 20:51 SsuRestoreLegacy.log + -rwx 27 Aug 5 20:49 boot-config + drwx 4096 Aug 8 19:25 debug + drwx 4096 Aug 3 21:32 fastpkttx.backup + -rwx 94038 Apr 7 15:42 nautobot.png + drwx 4096 Aug 8 19:24 persist + drwx 4096 Aug 12 2024 schedule + -rwx 3610 Aug 5 20:48 startup-config + -rwx 0 Dec 2 2024 zerotouch-config + +3634421760 bytes total (1327603712 bytes free) diff --git a/tests/unit/test_devices/device_mocks/eos_ssh/show_boot b/tests/unit/test_devices/device_mocks/eos_ssh/show_boot new file mode 100644 index 00000000..ed29ea9b --- /dev/null +++ b/tests/unit/test_devices/device_mocks/eos_ssh/show_boot @@ -0,0 +1,6 @@ +Software image: flash:/EOS-4.28.5M.swi +Console speed: (not set) +Aboot password (encrypted): (not set) +Memory test iterations: (not set) +Checksum: 0a2c4f1390395f61a042f46c3fe19b86152fc2ca +Checksum algorithm: sha1 diff --git a/tests/unit/test_devices/device_mocks/eos_ssh/show_boot-config_json b/tests/unit/test_devices/device_mocks/eos_ssh/show_boot-config_json new file mode 100644 index 00000000..d92ff9c9 --- /dev/null +++ b/tests/unit/test_devices/device_mocks/eos_ssh/show_boot-config_json @@ -0,0 +1,18 @@ +{ + "upgradeCert": "", + "userCert": "", + "securebootSupported": false, + "tpmPassword": false, + "fileChecksum": "0a2c4f1390395f61a042f46c3fe19b86152fc2ca", + "aristaCertEnabled": false, + "spiUpdateEnabled": false, + "abootPassword": "(not set)", + "memTestIterations": 0, + "softwareImage": "flash:/EOS-4.28.5M.swi", + "aristaCert": "", + "fileChecksumAlg": "sha1", + "securebootEnabled": false, + "measuredbootEnabled": false, + "certsLoaded": false, + "spiFlashWriteProtected": false +} diff --git a/tests/unit/test_devices/device_mocks/eos_ssh/show_hostname_json b/tests/unit/test_devices/device_mocks/eos_ssh/show_hostname_json new file mode 100644 index 00000000..dec90d32 --- /dev/null +++ b/tests/unit/test_devices/device_mocks/eos_ssh/show_hostname_json @@ -0,0 +1,4 @@ +{ + "fqdn": "nyc-eos-01", + "hostname": "nyc-eos-01" +} diff --git a/tests/unit/test_devices/device_mocks/eos_ssh/show_interfaces_status_json b/tests/unit/test_devices/device_mocks/eos_ssh/show_interfaces_status_json new file mode 100644 index 00000000..21944bb5 --- /dev/null +++ b/tests/unit/test_devices/device_mocks/eos_ssh/show_interfaces_status_json @@ -0,0 +1,978 @@ +{ + "interfaceStatuses": { + "Ethernet1": { + "vlanInformation": { + "interfaceMode": "bridged", + "vlanId": 10, + "interfaceForwardingModel": "bridged" + }, + "bandwidth": 0, + "interfaceType": "10GBASE-T", + "description": "lab uplink", + "autoNegotiateActive": true, + "duplex": "duplexUnknown", + "autoNegotigateActive": true, + "linkStatus": "notconnect", + "lineProtocolStatus": "down" + }, + "Ethernet2": { + "vlanInformation": { + "interfaceMode": "bridged", + "vlanId": 10, + "interfaceForwardingModel": "bridged" + }, + "bandwidth": 0, + "interfaceType": "10GBASE-T", + "description": "", + "autoNegotiateActive": true, + "duplex": "duplexUnknown", + "autoNegotigateActive": true, + "linkStatus": "notconnect", + "lineProtocolStatus": "down" + }, + "Ethernet3": { + "vlanInformation": { + "interfaceMode": "bridged", + "vlanId": 10, + "interfaceForwardingModel": "bridged" + }, + "bandwidth": 0, + "interfaceType": "10GBASE-T", + "description": "", + "autoNegotiateActive": true, + "duplex": "duplexUnknown", + "autoNegotigateActive": true, + "linkStatus": "notconnect", + "lineProtocolStatus": "down" + }, + "Ethernet4": { + "vlanInformation": { + "interfaceMode": "bridged", + "vlanId": 10, + "interfaceForwardingModel": "bridged" + }, + "bandwidth": 0, + "interfaceType": "10GBASE-T", + "description": "", + "autoNegotiateActive": true, + "duplex": "duplexUnknown", + "autoNegotigateActive": true, + "linkStatus": "notconnect", + "lineProtocolStatus": "down" + }, + "Ethernet5": { + "vlanInformation": { + "interfaceMode": "bridged", + "vlanId": 10, + "interfaceForwardingModel": "bridged" + }, + "bandwidth": 0, + "interfaceType": "10GBASE-T", + "description": "", + "autoNegotiateActive": true, + "duplex": "duplexUnknown", + "autoNegotigateActive": true, + "linkStatus": "notconnect", + "lineProtocolStatus": "down" + }, + "Ethernet6": { + "vlanInformation": { + "interfaceMode": "bridged", + "vlanId": 11, + "interfaceForwardingModel": "bridged" + }, + "bandwidth": 0, + "interfaceType": "10GBASE-T", + "description": "", + "autoNegotiateActive": true, + "duplex": "duplexUnknown", + "autoNegotigateActive": true, + "linkStatus": "notconnect", + "lineProtocolStatus": "down" + }, + "Ethernet7": { + "vlanInformation": { + "interfaceMode": "bridged", + "vlanId": 11, + "interfaceForwardingModel": "bridged" + }, + "bandwidth": 0, + "interfaceType": "10GBASE-T", + "description": "", + "autoNegotiateActive": true, + "duplex": "duplexUnknown", + "autoNegotigateActive": true, + "linkStatus": "notconnect", + "lineProtocolStatus": "down" + }, + "Ethernet8": { + "vlanInformation": { + "interfaceMode": "bridged", + "vlanId": 11, + "interfaceForwardingModel": "bridged" + }, + "bandwidth": 0, + "interfaceType": "10GBASE-T", + "description": "", + "autoNegotiateActive": true, + "duplex": "duplexUnknown", + "autoNegotigateActive": true, + "linkStatus": "notconnect", + "lineProtocolStatus": "down" + }, + "Ethernet9": { + "vlanInformation": { + "interfaceMode": "bridged", + "vlanId": 1, + "interfaceForwardingModel": "bridged" + }, + "bandwidth": 0, + "interfaceType": "10GBASE-T", + "description": "", + "autoNegotiateActive": true, + "duplex": "duplexUnknown", + "autoNegotigateActive": true, + "linkStatus": "notconnect", + "lineProtocolStatus": "down" + }, + "Ethernet10": { + "vlanInformation": { + "interfaceMode": "bridged", + "vlanId": 12, + "interfaceForwardingModel": "bridged" + }, + "bandwidth": 0, + "interfaceType": "10GBASE-T", + "description": "", + "autoNegotiateActive": true, + "duplex": "duplexUnknown", + "autoNegotigateActive": true, + "linkStatus": "notconnect", + "lineProtocolStatus": "down" + }, + "Ethernet11": { + "vlanInformation": { + "interfaceMode": "bridged", + "vlanId": 12, + "interfaceForwardingModel": "bridged" + }, + "bandwidth": 0, + "interfaceType": "10GBASE-T", + "description": "", + "autoNegotiateActive": true, + "duplex": "duplexUnknown", + "autoNegotigateActive": true, + "linkStatus": "notconnect", + "lineProtocolStatus": "down" + }, + "Ethernet12": { + "vlanInformation": { + "interfaceMode": "bridged", + "vlanId": 12, + "interfaceForwardingModel": "bridged" + }, + "bandwidth": 0, + "interfaceType": "10GBASE-T", + "description": "", + "autoNegotiateActive": true, + "duplex": "duplexUnknown", + "autoNegotigateActive": true, + "linkStatus": "notconnect", + "lineProtocolStatus": "down" + }, + "Ethernet13": { + "vlanInformation": { + "interfaceMode": "bridged", + "vlanId": 12, + "interfaceForwardingModel": "bridged" + }, + "bandwidth": 0, + "interfaceType": "10GBASE-T", + "description": "", + "autoNegotiateActive": true, + "duplex": "duplexUnknown", + "autoNegotigateActive": true, + "linkStatus": "notconnect", + "lineProtocolStatus": "down" + }, + "Ethernet14": { + "vlanInformation": { + "interfaceMode": "bridged", + "vlanId": 12, + "interfaceForwardingModel": "bridged" + }, + "bandwidth": 0, + "interfaceType": "10GBASE-T", + "description": "", + "autoNegotiateActive": true, + "duplex": "duplexUnknown", + "autoNegotigateActive": true, + "linkStatus": "notconnect", + "lineProtocolStatus": "down" + }, + "Ethernet15": { + "vlanInformation": { + "interfaceMode": "bridged", + "vlanId": 12, + "interfaceForwardingModel": "bridged" + }, + "bandwidth": 0, + "interfaceType": "10GBASE-T", + "description": "", + "autoNegotiateActive": true, + "duplex": "duplexUnknown", + "autoNegotigateActive": true, + "linkStatus": "notconnect", + "lineProtocolStatus": "down" + }, + "Ethernet16": { + "vlanInformation": { + "interfaceMode": "bridged", + "vlanId": 1, + "interfaceForwardingModel": "bridged" + }, + "bandwidth": 0, + "interfaceType": "10GBASE-T", + "description": "", + "autoNegotiateActive": true, + "duplex": "duplexUnknown", + "autoNegotigateActive": true, + "linkStatus": "notconnect", + "lineProtocolStatus": "down" + }, + "Ethernet17": { + "vlanInformation": { + "interfaceMode": "bridged", + "vlanId": 1, + "interfaceForwardingModel": "bridged" + }, + "bandwidth": 0, + "interfaceType": "10GBASE-T", + "description": "", + "autoNegotiateActive": true, + "duplex": "duplexUnknown", + "autoNegotigateActive": true, + "linkStatus": "notconnect", + "lineProtocolStatus": "down" + }, + "Ethernet18": { + "vlanInformation": { + "interfaceMode": "bridged", + "vlanId": 1, + "interfaceForwardingModel": "bridged" + }, + "bandwidth": 0, + "interfaceType": "10GBASE-T", + "description": "", + "autoNegotiateActive": true, + "duplex": "duplexUnknown", + "autoNegotigateActive": true, + "linkStatus": "notconnect", + "lineProtocolStatus": "down" + }, + "Ethernet19": { + "vlanInformation": { + "interfaceMode": "bridged", + "vlanId": 1, + "interfaceForwardingModel": "bridged" + }, + "bandwidth": 0, + "interfaceType": "10GBASE-T", + "description": "", + "autoNegotiateActive": true, + "duplex": "duplexUnknown", + "autoNegotigateActive": true, + "linkStatus": "notconnect", + "lineProtocolStatus": "down" + }, + "Ethernet20": { + "vlanInformation": { + "interfaceMode": "bridged", + "vlanId": 1, + "interfaceForwardingModel": "bridged" + }, + "bandwidth": 0, + "interfaceType": "10GBASE-T", + "description": "", + "autoNegotiateActive": true, + "duplex": "duplexUnknown", + "autoNegotigateActive": true, + "linkStatus": "notconnect", + "lineProtocolStatus": "down" + }, + "Ethernet21": { + "vlanInformation": { + "interfaceMode": "bridged", + "vlanId": 1, + "interfaceForwardingModel": "bridged" + }, + "bandwidth": 0, + "interfaceType": "10GBASE-T", + "description": "", + "autoNegotiateActive": true, + "duplex": "duplexUnknown", + "autoNegotigateActive": true, + "linkStatus": "notconnect", + "lineProtocolStatus": "down" + }, + "Ethernet22": { + "vlanInformation": { + "interfaceMode": "bridged", + "vlanId": 1, + "interfaceForwardingModel": "bridged" + }, + "bandwidth": 0, + "interfaceType": "10GBASE-T", + "description": "", + "autoNegotiateActive": true, + "duplex": "duplexUnknown", + "autoNegotigateActive": true, + "linkStatus": "notconnect", + "lineProtocolStatus": "down" + }, + "Ethernet23": { + "vlanInformation": { + "interfaceMode": "bridged", + "vlanId": 1, + "interfaceForwardingModel": "bridged" + }, + "bandwidth": 0, + "interfaceType": "10GBASE-T", + "description": "", + "autoNegotiateActive": true, + "duplex": "duplexUnknown", + "autoNegotigateActive": true, + "linkStatus": "notconnect", + "lineProtocolStatus": "down" + }, + "Ethernet24": { + "vlanInformation": { + "interfaceMode": "bridged", + "vlanId": 1, + "interfaceForwardingModel": "bridged" + }, + "bandwidth": 0, + "interfaceType": "10GBASE-T", + "description": "", + "autoNegotiateActive": true, + "duplex": "duplexUnknown", + "autoNegotigateActive": true, + "linkStatus": "notconnect", + "lineProtocolStatus": "down" + }, + "Ethernet25": { + "vlanInformation": { + "interfaceMode": "bridged", + "vlanId": 1, + "interfaceForwardingModel": "bridged" + }, + "bandwidth": 0, + "interfaceType": "10GBASE-T", + "description": "", + "autoNegotiateActive": true, + "duplex": "duplexUnknown", + "autoNegotigateActive": true, + "linkStatus": "notconnect", + "lineProtocolStatus": "down" + }, + "Ethernet26": { + "vlanInformation": { + "interfaceMode": "bridged", + "vlanId": 1, + "interfaceForwardingModel": "bridged" + }, + "bandwidth": 0, + "interfaceType": "10GBASE-T", + "description": "", + "autoNegotiateActive": true, + "duplex": "duplexUnknown", + "autoNegotigateActive": true, + "linkStatus": "notconnect", + "lineProtocolStatus": "down" + }, + "Ethernet27": { + "vlanInformation": { + "interfaceMode": "bridged", + "vlanId": 1, + "interfaceForwardingModel": "bridged" + }, + "bandwidth": 0, + "interfaceType": "10GBASE-T", + "description": "", + "autoNegotiateActive": true, + "duplex": "duplexUnknown", + "autoNegotigateActive": true, + "linkStatus": "notconnect", + "lineProtocolStatus": "down" + }, + "Ethernet28": { + "vlanInformation": { + "interfaceMode": "bridged", + "vlanId": 1, + "interfaceForwardingModel": "bridged" + }, + "bandwidth": 0, + "interfaceType": "10GBASE-T", + "description": "", + "autoNegotiateActive": true, + "duplex": "duplexUnknown", + "autoNegotigateActive": true, + "linkStatus": "notconnect", + "lineProtocolStatus": "down" + }, + "Ethernet29": { + "vlanInformation": { + "interfaceMode": "bridged", + "vlanId": 1, + "interfaceForwardingModel": "bridged" + }, + "bandwidth": 0, + "interfaceType": "10GBASE-T", + "description": "", + "autoNegotiateActive": true, + "duplex": "duplexUnknown", + "autoNegotigateActive": true, + "linkStatus": "notconnect", + "lineProtocolStatus": "down" + }, + "Ethernet30": { + "vlanInformation": { + "interfaceMode": "bridged", + "vlanId": 1, + "interfaceForwardingModel": "bridged" + }, + "bandwidth": 0, + "interfaceType": "10GBASE-T", + "description": "", + "autoNegotiateActive": true, + "duplex": "duplexUnknown", + "autoNegotigateActive": true, + "linkStatus": "notconnect", + "lineProtocolStatus": "down" + }, + "Ethernet31": { + "vlanInformation": { + "interfaceMode": "bridged", + "vlanId": 1, + "interfaceForwardingModel": "bridged" + }, + "bandwidth": 0, + "interfaceType": "10GBASE-T", + "description": "", + "autoNegotiateActive": true, + "duplex": "duplexUnknown", + "autoNegotigateActive": true, + "linkStatus": "notconnect", + "lineProtocolStatus": "down" + }, + "Ethernet32": { + "vlanInformation": { + "interfaceMode": "bridged", + "vlanId": 1, + "interfaceForwardingModel": "bridged" + }, + "bandwidth": 0, + "interfaceType": "10GBASE-T", + "description": "", + "autoNegotiateActive": true, + "duplex": "duplexUnknown", + "autoNegotigateActive": true, + "linkStatus": "notconnect", + "lineProtocolStatus": "down" + }, + "Ethernet33": { + "vlanInformation": { + "interfaceMode": "bridged", + "vlanId": 1, + "interfaceForwardingModel": "bridged" + }, + "bandwidth": 0, + "interfaceType": "10GBASE-T", + "description": "", + "autoNegotiateActive": true, + "duplex": "duplexUnknown", + "autoNegotigateActive": true, + "linkStatus": "notconnect", + "lineProtocolStatus": "down" + }, + "Ethernet34": { + "vlanInformation": { + "interfaceMode": "bridged", + "vlanId": 1, + "interfaceForwardingModel": "bridged" + }, + "bandwidth": 0, + "interfaceType": "10GBASE-T", + "description": "", + "autoNegotiateActive": true, + "duplex": "duplexUnknown", + "autoNegotigateActive": true, + "linkStatus": "notconnect", + "lineProtocolStatus": "down" + }, + "Ethernet35": { + "vlanInformation": { + "interfaceMode": "bridged", + "vlanId": 1, + "interfaceForwardingModel": "bridged" + }, + "bandwidth": 0, + "interfaceType": "10GBASE-T", + "description": "", + "autoNegotiateActive": true, + "duplex": "duplexUnknown", + "autoNegotigateActive": true, + "linkStatus": "notconnect", + "lineProtocolStatus": "down" + }, + "Ethernet36": { + "vlanInformation": { + "interfaceMode": "bridged", + "vlanId": 1, + "interfaceForwardingModel": "bridged" + }, + "bandwidth": 0, + "interfaceType": "10GBASE-T", + "description": "", + "autoNegotiateActive": true, + "duplex": "duplexUnknown", + "autoNegotigateActive": true, + "linkStatus": "notconnect", + "lineProtocolStatus": "down" + }, + "Ethernet37": { + "vlanInformation": { + "interfaceMode": "bridged", + "vlanId": 1, + "interfaceForwardingModel": "bridged" + }, + "bandwidth": 0, + "interfaceType": "10GBASE-T", + "description": "", + "autoNegotiateActive": true, + "duplex": "duplexUnknown", + "autoNegotigateActive": true, + "linkStatus": "notconnect", + "lineProtocolStatus": "down" + }, + "Ethernet38": { + "vlanInformation": { + "interfaceMode": "bridged", + "vlanId": 1, + "interfaceForwardingModel": "bridged" + }, + "bandwidth": 0, + "interfaceType": "10GBASE-T", + "description": "", + "autoNegotiateActive": true, + "duplex": "duplexUnknown", + "autoNegotigateActive": true, + "linkStatus": "notconnect", + "lineProtocolStatus": "down" + }, + "Ethernet39": { + "vlanInformation": { + "interfaceMode": "bridged", + "vlanId": 1, + "interfaceForwardingModel": "bridged" + }, + "bandwidth": 0, + "interfaceType": "10GBASE-T", + "description": "", + "autoNegotiateActive": true, + "duplex": "duplexUnknown", + "autoNegotigateActive": true, + "linkStatus": "notconnect", + "lineProtocolStatus": "down" + }, + "Ethernet40": { + "vlanInformation": { + "interfaceMode": "bridged", + "vlanId": 1, + "interfaceForwardingModel": "bridged" + }, + "bandwidth": 0, + "interfaceType": "10GBASE-T", + "description": "", + "autoNegotiateActive": true, + "duplex": "duplexUnknown", + "autoNegotigateActive": true, + "linkStatus": "notconnect", + "lineProtocolStatus": "down" + }, + "Ethernet41": { + "vlanInformation": { + "interfaceMode": "bridged", + "vlanId": 1, + "interfaceForwardingModel": "bridged" + }, + "bandwidth": 0, + "interfaceType": "10GBASE-T", + "description": "", + "autoNegotiateActive": true, + "duplex": "duplexUnknown", + "autoNegotigateActive": true, + "linkStatus": "notconnect", + "lineProtocolStatus": "down" + }, + "Ethernet42": { + "vlanInformation": { + "interfaceMode": "bridged", + "vlanId": 1, + "interfaceForwardingModel": "bridged" + }, + "bandwidth": 0, + "interfaceType": "10GBASE-T", + "description": "", + "autoNegotiateActive": true, + "duplex": "duplexUnknown", + "autoNegotigateActive": true, + "linkStatus": "notconnect", + "lineProtocolStatus": "down" + }, + "Ethernet43": { + "vlanInformation": { + "interfaceMode": "bridged", + "vlanId": 1, + "interfaceForwardingModel": "bridged" + }, + "bandwidth": 0, + "interfaceType": "10GBASE-T", + "description": "", + "autoNegotiateActive": true, + "duplex": "duplexUnknown", + "autoNegotigateActive": true, + "linkStatus": "notconnect", + "lineProtocolStatus": "down" + }, + "Ethernet44": { + "vlanInformation": { + "interfaceMode": "bridged", + "vlanId": 1, + "interfaceForwardingModel": "bridged" + }, + "bandwidth": 0, + "interfaceType": "10GBASE-T", + "description": "", + "autoNegotiateActive": true, + "duplex": "duplexUnknown", + "autoNegotigateActive": true, + "linkStatus": "notconnect", + "lineProtocolStatus": "down" + }, + "Ethernet45": { + "vlanInformation": { + "interfaceMode": "bridged", + "vlanId": 1, + "interfaceForwardingModel": "bridged" + }, + "bandwidth": 0, + "interfaceType": "10GBASE-T", + "description": "", + "autoNegotiateActive": true, + "duplex": "duplexUnknown", + "autoNegotigateActive": true, + "linkStatus": "notconnect", + "lineProtocolStatus": "down" + }, + "Ethernet46": { + "vlanInformation": { + "interfaceMode": "bridged", + "vlanId": 1, + "interfaceForwardingModel": "bridged" + }, + "bandwidth": 0, + "interfaceType": "10GBASE-T", + "description": "", + "autoNegotiateActive": true, + "duplex": "duplexUnknown", + "autoNegotigateActive": true, + "linkStatus": "notconnect", + "lineProtocolStatus": "down" + }, + "Ethernet47": { + "vlanInformation": { + "interfaceMode": "bridged", + "vlanId": 1, + "interfaceForwardingModel": "bridged" + }, + "bandwidth": 0, + "interfaceType": "10GBASE-T", + "description": "", + "autoNegotiateActive": true, + "duplex": "duplexUnknown", + "autoNegotigateActive": true, + "linkStatus": "notconnect", + "lineProtocolStatus": "down" + }, + "Ethernet48": { + "vlanInformation": { + "interfaceMode": "bridged", + "vlanId": 1, + "interfaceForwardingModel": "bridged" + }, + "bandwidth": 0, + "interfaceType": "10GBASE-T", + "description": "", + "autoNegotiateActive": true, + "duplex": "duplexUnknown", + "autoNegotigateActive": true, + "linkStatus": "notconnect", + "lineProtocolStatus": "down" + }, + "Ethernet49/1": { + "vlanInformation": { + "interfaceMode": "bridged", + "vlanId": 1, + "interfaceForwardingModel": "bridged" + }, + "bandwidth": 10000000000, + "interfaceType": "Not Present", + "description": "", + "autoNegotiateActive": false, + "duplex": "duplexFull", + "autoNegotigateActive": false, + "linkStatus": "notconnect", + "lineProtocolStatus": "notPresent" + }, + "Ethernet49/2": { + "vlanInformation": { + "interfaceMode": "bridged", + "vlanId": 1, + "interfaceForwardingModel": "bridged" + }, + "bandwidth": 10000000000, + "interfaceType": "Not Present", + "description": "", + "autoNegotiateActive": false, + "duplex": "duplexFull", + "autoNegotigateActive": false, + "linkStatus": "notconnect", + "lineProtocolStatus": "notPresent" + }, + "Ethernet49/3": { + "vlanInformation": { + "interfaceMode": "bridged", + "vlanId": 1, + "interfaceForwardingModel": "bridged" + }, + "bandwidth": 10000000000, + "interfaceType": "Not Present", + "description": "", + "autoNegotiateActive": false, + "duplex": "duplexFull", + "autoNegotigateActive": false, + "linkStatus": "notconnect", + "lineProtocolStatus": "notPresent" + }, + "Ethernet49/4": { + "vlanInformation": { + "interfaceMode": "bridged", + "vlanId": 1, + "interfaceForwardingModel": "bridged" + }, + "bandwidth": 10000000000, + "interfaceType": "Not Present", + "description": "", + "autoNegotiateActive": false, + "duplex": "duplexFull", + "autoNegotigateActive": false, + "linkStatus": "notconnect", + "lineProtocolStatus": "notPresent" + }, + "Ethernet50/1": { + "vlanInformation": { + "interfaceMode": "bridged", + "vlanId": 1, + "interfaceForwardingModel": "bridged" + }, + "bandwidth": 10000000000, + "interfaceType": "Not Present", + "description": "", + "autoNegotiateActive": false, + "duplex": "duplexFull", + "autoNegotigateActive": false, + "linkStatus": "notconnect", + "lineProtocolStatus": "notPresent" + }, + "Ethernet50/2": { + "vlanInformation": { + "interfaceMode": "bridged", + "vlanId": 1, + "interfaceForwardingModel": "bridged" + }, + "bandwidth": 10000000000, + "interfaceType": "Not Present", + "description": "", + "autoNegotiateActive": false, + "duplex": "duplexFull", + "autoNegotigateActive": false, + "linkStatus": "notconnect", + "lineProtocolStatus": "notPresent" + }, + "Ethernet50/3": { + "vlanInformation": { + "interfaceMode": "bridged", + "vlanId": 1, + "interfaceForwardingModel": "bridged" + }, + "bandwidth": 10000000000, + "interfaceType": "Not Present", + "description": "", + "autoNegotiateActive": false, + "duplex": "duplexFull", + "autoNegotigateActive": false, + "linkStatus": "notconnect", + "lineProtocolStatus": "notPresent" + }, + "Ethernet50/4": { + "vlanInformation": { + "interfaceMode": "bridged", + "vlanId": 1, + "interfaceForwardingModel": "bridged" + }, + "bandwidth": 10000000000, + "interfaceType": "Not Present", + "description": "", + "autoNegotiateActive": false, + "duplex": "duplexFull", + "autoNegotigateActive": false, + "linkStatus": "notconnect", + "lineProtocolStatus": "notPresent" + }, + "Ethernet51/1": { + "vlanInformation": { + "interfaceMode": "bridged", + "vlanId": 1, + "interfaceForwardingModel": "bridged" + }, + "bandwidth": 10000000000, + "interfaceType": "Not Present", + "description": "", + "autoNegotiateActive": false, + "duplex": "duplexFull", + "autoNegotigateActive": false, + "linkStatus": "notconnect", + "lineProtocolStatus": "notPresent" + }, + "Ethernet51/2": { + "vlanInformation": { + "interfaceMode": "bridged", + "vlanId": 1, + "interfaceForwardingModel": "bridged" + }, + "bandwidth": 10000000000, + "interfaceType": "Not Present", + "description": "", + "autoNegotiateActive": false, + "duplex": "duplexFull", + "autoNegotigateActive": false, + "linkStatus": "notconnect", + "lineProtocolStatus": "notPresent" + }, + "Ethernet51/3": { + "vlanInformation": { + "interfaceMode": "bridged", + "vlanId": 1, + "interfaceForwardingModel": "bridged" + }, + "bandwidth": 10000000000, + "interfaceType": "Not Present", + "description": "", + "autoNegotiateActive": false, + "duplex": "duplexFull", + "autoNegotigateActive": false, + "linkStatus": "notconnect", + "lineProtocolStatus": "notPresent" + }, + "Ethernet51/4": { + "vlanInformation": { + "interfaceMode": "bridged", + "vlanId": 1, + "interfaceForwardingModel": "bridged" + }, + "bandwidth": 10000000000, + "interfaceType": "Not Present", + "description": "", + "autoNegotiateActive": false, + "duplex": "duplexFull", + "autoNegotigateActive": false, + "linkStatus": "notconnect", + "lineProtocolStatus": "notPresent" + }, + "Ethernet52/1": { + "vlanInformation": { + "interfaceMode": "bridged", + "vlanId": 1, + "interfaceForwardingModel": "bridged" + }, + "bandwidth": 10000000000, + "interfaceType": "Not Present", + "description": "", + "autoNegotiateActive": false, + "duplex": "duplexFull", + "autoNegotigateActive": false, + "linkStatus": "notconnect", + "lineProtocolStatus": "notPresent" + }, + "Ethernet52/2": { + "vlanInformation": { + "interfaceMode": "bridged", + "vlanId": 1, + "interfaceForwardingModel": "bridged" + }, + "bandwidth": 10000000000, + "interfaceType": "Not Present", + "description": "", + "autoNegotiateActive": false, + "duplex": "duplexFull", + "autoNegotigateActive": false, + "linkStatus": "notconnect", + "lineProtocolStatus": "notPresent" + }, + "Ethernet52/3": { + "vlanInformation": { + "interfaceMode": "bridged", + "vlanId": 1, + "interfaceForwardingModel": "bridged" + }, + "bandwidth": 10000000000, + "interfaceType": "Not Present", + "description": "", + "autoNegotiateActive": false, + "duplex": "duplexFull", + "autoNegotigateActive": false, + "linkStatus": "notconnect", + "lineProtocolStatus": "notPresent" + }, + "Ethernet52/4": { + "vlanInformation": { + "interfaceMode": "bridged", + "vlanId": 1, + "interfaceForwardingModel": "bridged" + }, + "bandwidth": 10000000000, + "interfaceType": "Not Present", + "description": "", + "autoNegotiateActive": false, + "duplex": "duplexFull", + "autoNegotigateActive": false, + "linkStatus": "notconnect", + "lineProtocolStatus": "notPresent" + }, + "Management1": { + "vlanInformation": { + "interfaceMode": "routed", + "interfaceForwardingModel": "routed" + }, + "bandwidth": 100000000, + "interfaceType": "10/100/1000", + "description": "", + "autoNegotiateActive": true, + "duplex": "duplexFull", + "autoNegotigateActive": true, + "linkStatus": "connected", + "lineProtocolStatus": "up" + } + } +} diff --git a/tests/unit/test_devices/device_mocks/eos_ssh/show_running-config b/tests/unit/test_devices/device_mocks/eos_ssh/show_running-config new file mode 100644 index 00000000..f983a02b --- /dev/null +++ b/tests/unit/test_devices/device_mocks/eos_ssh/show_running-config @@ -0,0 +1,48 @@ +! Command: show running-config +! device: eos-spine1 (vEOS, EOS-4.14.7M) +! +! boot system flash:/new_image.swi +! +transceiver qsfp default-mode 4x10G +! +hostname eos-spine1 +ip domain-name ntc.com +! +snmp-server community public ro +! +spanning-tree mode mstp +! +no aaa root +! +username admin privilege 15 role network-admin secret 5 $1$7yUmRiH6$9F1Io4WMwAWSc2GMjeK3h/ +username ntc privilege 15 role network-admin secret 5 $1$yLXNmzh4$eltlOr6yIb8IpRGCjp8Bj/ +! +interface Ethernet1 +! +interface Ethernet2 +! +interface Ethernet3 +! +interface Ethernet4 +! +interface Ethernet5 +! +interface Ethernet6 +! +interface Ethernet7 +! +interface Ethernet8 +! +interface Management1 + ip address 10.0.0.11/24 +! +ip route 0.0.0.0/0 10.0.0.2 +! +ip routing +! +management api http-commands + protocol http + no shutdown +! +! +end diff --git a/tests/unit/test_devices/device_mocks/eos_ssh/show_startup-config b/tests/unit/test_devices/device_mocks/eos_ssh/show_startup-config new file mode 100644 index 00000000..bc65cb1d --- /dev/null +++ b/tests/unit/test_devices/device_mocks/eos_ssh/show_startup-config @@ -0,0 +1,49 @@ +! Command: show startup-config +! Startup-config last modified at Sat Jan 23 16:50:06 2016 by ntc +! device: eos-spine1 (vEOS, EOS-4.14.7M) +! +! boot system flash:EOS.swi +! +transceiver qsfp default-mode 4x10G +! +hostname eos-spine1 +ip domain-name ntc.com +! +snmp-server community public ro +! +spanning-tree mode mstp +! +no aaa root +! +username admin privilege 15 role network-admin secret 5 $1$7yUmRiH6$9F1Io4WMwAWSc2GMjeK3h/ +username ntc privilege 15 role network-admin secret 5 $1$yLXNmzh4$eltlOr6yIb8IpRGCjp8Bj/ +! +interface Ethernet1 +! +interface Ethernet2 +! +interface Ethernet3 +! +interface Ethernet4 +! +interface Ethernet5 +! +interface Ethernet6 +! +interface Ethernet7 +! +interface Ethernet8 +! +interface Management1 + ip address 10.0.0.11/24 +! +ip route 0.0.0.0/0 10.0.0.2 +! +ip routing +! +management api http-commands + protocol http + no shutdown +! +! +end diff --git a/tests/unit/test_devices/device_mocks/eos_ssh/show_version_json b/tests/unit/test_devices/device_mocks/eos_ssh/show_version_json new file mode 100644 index 00000000..a6a9abcf --- /dev/null +++ b/tests/unit/test_devices/device_mocks/eos_ssh/show_version_json @@ -0,0 +1,20 @@ +{ + "imageFormatVersion": "3.0", + "uptime": 254314.71, + "modelName": "DCS-7050TX-64-R", + "internalVersion": "4.28.5M-29792660.4285M", + "memTotal": 3982512, + "mfgName": "Arista", + "serialNumber": "JPE00000000", + "systemMacAddress": "00:1c:73:00:00:01", + "bootupTimestamp": 1785963023.376446, + "memFree": 2485644, + "version": "4.28.5M", + "configMacAddress": "00:00:00:00:00:00", + "isIntlVersion": false, + "imageOptimization": "Strata-4GB", + "internalBuildId": "d9aad8b6-4e46-4507-8815-ada0b879f38a", + "hardwareRevision": "01.01", + "hwMacAddress": "00:1c:73:00:00:01", + "architecture": "i686" +} diff --git a/tests/unit/test_devices/device_mocks/eos_ssh/show_vlan_json b/tests/unit/test_devices/device_mocks/eos_ssh/show_vlan_json new file mode 100644 index 00000000..5f564907 --- /dev/null +++ b/tests/unit/test_devices/device_mocks/eos_ssh/show_vlan_json @@ -0,0 +1,35 @@ +{ + "sourceDetail": "", + "vlans": { + "1": { + "status": "active", + "name": "default", + "interfaces": {}, + "dynamic": false + }, + "11": { + "status": "active", + "name": "HR", + "interfaces": {}, + "dynamic": false + }, + "12": { + "status": "active", + "name": "FIN", + "interfaces": {}, + "dynamic": false + }, + "10": { + "status": "active", + "name": "IT_DEP", + "interfaces": {}, + "dynamic": false + }, + "9": { + "status": "active", + "name": "AP", + "interfaces": {}, + "dynamic": false + } + } +} diff --git a/tests/unit/test_devices/test_eos_ssh_device.py b/tests/unit/test_devices/test_eos_ssh_device.py new file mode 100644 index 00000000..868cccf6 --- /dev/null +++ b/tests/unit/test_devices/test_eos_ssh_device.py @@ -0,0 +1,982 @@ +"""Unit tests for the ``arista_eos_ssh`` driver. + +Fixtures are a real ``show ... | json`` capture from a DCS-7050TX-64-R running EOS 4.28.5M +(serial, MACs and one port description sanitised). Real hardware output covers two shapes +the older vEOS fixtures did not: ``softwareImage`` carrying a ``flash:/`` prefix, and a +routed ``Management1`` whose ``vlanInformation`` has no ``vlanId`` at all. + +Because the two drivers no longer share fixture data, +``test_facts_match_eapi_driver_for_identical_payload`` is what guards against drift: it +feeds the same document to both drivers and asserts every fact comes out identical. +""" + +import hashlib +import inspect +import json +import os +import time +from unittest import mock + +import pytest + +from pyntc import ntc_device +from pyntc.devices import EOSDevice, EOSSSHDevice +from pyntc.devices.base_device import RollbackError +from pyntc.devices.eos_device import DEFAULT_REBOOT_TIMEOUT +from pyntc.devices.eos_ssh_device import DEFAULT_READ_TIMEOUT +from pyntc.devices.eos_ssh_device import EOSSSHDevice as Driver +from pyntc.errors import ( + CommandError, + CommandListError, + FileTransferError, + NotEnoughFreeSpaceError, + OSInstallError, + SocketClosedError, +) +from pyntc.utils.models import FileCopyModel + +BOOT_TIMESTAMP = 1785963023.376446 +MODEL = "DCS-7050TX-64-R" +OS_VERSION = "4.28.5M-29792660.4285M" +HOSTNAME = "nyc-eos-01" +SERIAL_NUMBER = "JPE00000000" +BOOT_IMAGE = "EOS-4.28.5M.swi" +FREE_BYTES = 1327603712 +INTERFACE_COUNT = 65 + + +# --------------------------------------------------------------------------- +# Registration +# --------------------------------------------------------------------------- + + +def test_registered_device_type(): + with mock.patch.object(EOSSSHDevice, "open"): + device = ntc_device("arista_eos_ssh", "host", "user", "password") + assert isinstance(device, EOSSSHDevice) + assert device.device_type == "arista_eos_ssh" + + +def test_vendor(eos_ssh_device): + assert eos_ssh_device.vendor == "arista" + + +# --------------------------------------------------------------------------- +# Connection handling +# --------------------------------------------------------------------------- + + +def test_init_defaults(): + with mock.patch("pyntc.devices.eos_ssh_device.ConnectHandler"): + device = EOSSSHDevice("host", "user", "password") + assert device.port == 22 + assert device.secret == "" + assert device.device_type == "arista_eos_ssh" + + +def test_init_accepts_port_and_secret(): + with mock.patch("pyntc.devices.eos_ssh_device.ConnectHandler"): + device = EOSSSHDevice("host", "user", "password", secret="enable_me", port="2222") + assert device.port == 2222 + assert device.secret == "enable_me" + + +def test_init_does_not_build_an_eapi_connection(): + # EOSDevice.__init__ would call pyeapi.connect(); the SSH driver must not. + with mock.patch("pyntc.devices.eos_ssh_device.ConnectHandler"): + with mock.patch("pyntc.devices.eos_device.eos_connect") as mock_connect: + EOSSSHDevice("host", "user", "password") + mock_connect.assert_not_called() + + +def test_open_connects_with_arista_eos_driver(): + with mock.patch("pyntc.devices.eos_ssh_device.ConnectHandler") as ch: + EOSSSHDevice("host", "user", "password", port=2222, secret="s3cret") + _, kwargs = ch.call_args + assert kwargs["device_type"] == "arista_eos" + assert kwargs["host"] == "host" + assert kwargs["port"] == 2222 + assert kwargs["secret"] == "s3cret" + + +def test_open_passes_extra_netmiko_kwargs(): + with mock.patch("pyntc.devices.eos_ssh_device.ConnectHandler") as ch: + EOSSSHDevice("host", "user", "password", global_delay_factor=2) + assert ch.call_args[1]["global_delay_factor"] == 2 + + +def test_open_is_noop_when_already_connected(eos_ssh_device): + with mock.patch("pyntc.devices.eos_ssh_device.ConnectHandler") as ch: + eos_ssh_device.open() + ch.assert_not_called() + eos_ssh_device.native.find_prompt.assert_called() + + +def test_open_reconnects_when_session_is_dead(): + with mock.patch("pyntc.devices.eos_ssh_device.ConnectHandler") as ch: + device = EOSSSHDevice("host", "user", "password") + assert ch.call_count == 1 + device.native.find_prompt.side_effect = OSError("socket closed") + device.open() + assert ch.call_count == 2 + assert device._connected is True + + +def test_native_ssh_is_native(eos_ssh_device): + # Inherited file-transfer code reaches for native_ssh; it must be the Netmiko handler. + assert eos_ssh_device.native_ssh is eos_ssh_device.native + + +def test_close_disconnects(eos_ssh_device): + eos_ssh_device.close() + eos_ssh_device.native.disconnect.assert_called_once() + assert eos_ssh_device._connected is False + + +def test_close_is_idempotent(eos_ssh_device): + eos_ssh_device.close() + eos_ssh_device.close() + eos_ssh_device.native.disconnect.assert_called_once() + + +# --------------------------------------------------------------------------- +# show() +# --------------------------------------------------------------------------- + + +def test_show_single_command_returns_dict(eos_ssh_send_command): + device = eos_ssh_send_command(["show_version_json"]) + result = device.show("show version") + assert isinstance(result, dict) + assert result["modelName"] == MODEL + device.native.send_command.assert_called_with("show version | json", read_timeout=DEFAULT_READ_TIMEOUT) + + +def test_show_list_returns_list(eos_ssh_send_command): + device = eos_ssh_send_command(["show_version_json", "show_hostname_json"]) + results = device.show(["show version", "show hostname"]) + assert isinstance(results, list) + assert len(results) == 2 + assert results[0]["modelName"] == MODEL + assert results[1]["hostname"] == HOSTNAME + + +def test_show_raw_text_returns_str_without_json_pipe(eos_ssh_send_command): + device = eos_ssh_send_command(["dir"]) + result = device.show("dir", raw_text=True) + assert isinstance(result, str) + assert "bytes free" in result + device.native.send_command.assert_called_with("dir", read_timeout=DEFAULT_READ_TIMEOUT) + + +def test_show_raw_text_list(eos_ssh_send_command): + device = eos_ssh_send_command(["dir", "show_boot"]) + results = device.show(["dir", "show boot"], raw_text=True) + assert [isinstance(item, str) for item in results] == [True, True] + + +@pytest.mark.parametrize( + "command", + [ + "reload now", + "copy running-config startup-config", + "configure replace flash:cp force", + "install source flash:EOS.swi", + ], +) +def test_show_does_not_pipe_non_show_commands_to_json(eos_ssh_send_command, command): + # "reload now | json" is not a valid command. Non-show commands must go through bare, + # and every inherited caller discards the return value, so {} preserves the contract. + device = eos_ssh_send_command([""]) + result = device.show(command) + assert result == {} + assert device.native.send_command.call_args[0][0] == command + + +def test_show_raises_command_error(eos_ssh_send_command): + device = eos_ssh_send_command(["% Invalid input (at token 1: 'bogus')"]) + with pytest.raises(CommandError) as err: + device.show("show bogus") + # The caller's command, not the wire command -- errors must not leak the "| json" + # suffix, matching the plain command name pyeapi reports on EOSDevice. + assert err.value.command == "show bogus" + assert "Invalid input" in err.value.cli_error_msg + + +def test_show_list_raises_command_list_error(eos_ssh_send_command): + device = eos_ssh_send_command(["show_version_json", "% Invalid input"]) + with pytest.raises(CommandListError) as err: + device.show(["show version", "show bogus"]) + assert err.value.commands == ["show version", "show bogus"] + assert err.value.command == "show bogus" + + +def test_show_raises_when_output_is_not_json(eos_ssh_send_command): + # A command with no JSON renderer must raise, never silently fall back to text + # parsing -- a fallback returns a differently shaped document and yields wrong facts. + device = eos_ssh_send_command(["This command is not converted to JSON"]) + with pytest.raises(CommandError) as err: + device.show("show something-unconverted") + assert "does not support JSON output" in err.value.cli_error_msg + + +def test_show_list_raises_command_list_error_when_output_is_not_json(eos_ssh_send_command): + # The list contract must hold for JSON parse failures too, not only device-reported + # errors: a non-JSON response mid-list raises CommandListError, never bare CommandError. + device = eos_ssh_send_command(["show_version_json", "This command is not converted to JSON"]) + with pytest.raises(CommandListError) as err: + device.show(["show version", "show something-unconverted"]) + assert err.value.commands == ["show version", "show something-unconverted"] + assert err.value.command == "show something-unconverted" + + +def test_show_reopens_connection(eos_ssh_send_command): + # Guards reboot polling: _wait_for_device_reboot survives only because show() re-opens. + device = eos_ssh_send_command(["show_version_json"]) + with mock.patch.object(Driver, "open") as mock_open: + device.show("show version") + mock_open.assert_called_once() + + +@pytest.mark.parametrize( + "command,expected_timeout", + [ + ("install source flash:EOS.swi", 3600), + ("copy running-config startup-config", 300), + ("configure replace flash:cp force", 300), + ("show running-config", 120), + ("show startup-config", 120), + ("show version", DEFAULT_READ_TIMEOUT), + ], +) +def test_show_resolves_read_timeout_per_command(eos_ssh_send_command, command, expected_timeout): + # Timeouts are derived from the command text so show()'s signature can stay identical + # to EOSDevice.show() while inherited long-running callers still work. + device = eos_ssh_send_command(['{"x": 1}']) + device.show(command, raw_text=True) + assert device.native.send_command.call_args[1]["read_timeout"] == expected_timeout + + +# --------------------------------------------------------------------------- +# config() +# --------------------------------------------------------------------------- + + +def test_config_single_command_returns_none(eos_ssh_config): + device = eos_ssh_config([""]) + assert device.config("interface Ethernet1") is None + device.native.send_config_set.assert_called_with("interface Ethernet1", exit_config_mode=False, cmd_verify=True) + + +def test_config_disables_cmd_verify_for_multiline_commands(eos_ssh_config): + # Multi-line input modes (banner motd ... EOF) echo in a way cmd_verify cannot match; + # verified against real hardware (vEOS): with cmd_verify netmiko raises ReadTimeout. + device = eos_ssh_config([""]) + device.config("banner motd\npyntc\nEOF") + device.native.send_config_set.assert_called_with( + "banner motd\npyntc\nEOF", exit_config_mode=False, cmd_verify=False + ) + + +def test_config_list_returns_none(eos_ssh_config): + device = eos_ssh_config(["", ""]) + assert device.config(["interface Ethernet1", "no shutdown"]) is None + assert device.native.send_config_set.call_count == 2 + + +def test_config_exits_config_mode(eos_ssh_config): + device = eos_ssh_config([""]) + device.config("interface Ethernet1") + device.native.exit_config_mode.assert_called_once() + + +def test_config_raises_command_error(eos_ssh_config): + device = eos_ssh_config(["% Invalid input"]) + with pytest.raises(CommandError) as err: + device.config("bogus command") + assert err.value.command == "bogus command" + + +def test_config_list_raises_command_list_error(eos_ssh_config): + device = eos_ssh_config(["", "% Invalid input"]) + with pytest.raises(CommandListError) as err: + device.config(["interface Ethernet1", "bogus"]) + assert err.value.command == "bogus" + assert err.value.commands == ["interface Ethernet1", "bogus"] + + +def test_config_exits_config_mode_on_error(eos_ssh_config): + # A failed command must not leave the session parked in config mode. + device = eos_ssh_config(["% Invalid input"]) + with pytest.raises(CommandError): + device.config("bogus command") + device.native.exit_config_mode.assert_called_once() + + +# --------------------------------------------------------------------------- +# Fact properties -- expectations match test_eos_device.py exactly +# --------------------------------------------------------------------------- + + +def test_hostname(eos_ssh_send_command): + device = eos_ssh_send_command(["show_hostname_json"]) + assert device.hostname == HOSTNAME + + +def test_fqdn(eos_ssh_send_command): + device = eos_ssh_send_command(["show_hostname_json"]) + assert device.fqdn == HOSTNAME + + +def test_model(eos_ssh_send_command): + device = eos_ssh_send_command(["show_version_json"]) + assert device.model == MODEL + + +def test_os_version(eos_ssh_send_command): + device = eos_ssh_send_command(["show_version_json"]) + assert device.os_version == OS_VERSION + + +def test_serial_number(eos_ssh_send_command): + device = eos_ssh_send_command(["show_version_json"]) + assert device.serial_number == SERIAL_NUMBER + + +def test_boot_time(eos_ssh_send_command): + device = eos_ssh_send_command(["show_version_json"]) + boot_time = device.boot_time + assert isinstance(boot_time, float) + assert boot_time == BOOT_TIMESTAMP + + +def test_uptime(eos_ssh_send_command): + device = eos_ssh_send_command(["show_version_json"]) + uptime = device.uptime + assert isinstance(uptime, int) + assert uptime == pytest.approx(int(time.time() - BOOT_TIMESTAMP), abs=2) + + +def test_uptime_string(eos_ssh_send_command): + device = eos_ssh_send_command(["show_version_json"]) + with mock.patch.object(Driver, "_uptime_to_string", return_value="02:00:03:38"): + assert device.uptime_string == "02:00:03:38" + + +def test_interfaces(eos_ssh_send_command): + device = eos_ssh_send_command(["show_interfaces_status_json"]) + interfaces = device.interfaces + assert len(interfaces) == INTERFACE_COUNT + # Sorted lexicographically, matching EOSDevice: "Ethernet10" precedes "Ethernet2". + assert interfaces == sorted(interfaces) + assert interfaces[:3] == ["Ethernet1", "Ethernet10", "Ethernet11"] + assert interfaces[-1] == "Management1" + assert "Ethernet49/1" in interfaces + + +def test_routed_interface_without_vlan_id(eos_ssh_send_command): + # Management1 is routed: its vlanInformation carries no vlanId. The key map must + # resolve that to None rather than raising. + device = eos_ssh_send_command(["show_interfaces_status_json"]) + management = [i for i in device._interfaces_status_list() if i["interface"] == "Management1"][0] + assert management["vlan"] is None + assert management["state"] == "connected" + + +def test_vlans(eos_ssh_send_command): + device = eos_ssh_send_command(["show_vlan_json"]) + # Lexicographic ordering, matching EOSVlans.get_list() -- so "9" sorts last. + assert device.vlans == ["1", "10", "11", "12", "9"] + + +def test_vlans_uses_show_vlan_not_pyeapi_api(eos_ssh_send_command): + # EOSVlans reaches for device.native.api("vlans"), which does not exist over SSH. + device = eos_ssh_send_command(["show_vlan_json"]) + device.vlans # noqa: B018 + assert device.native.send_command.call_args[0][0] == "show vlan | json" + device.native.api.assert_not_called() + + +def test_boot_options_strips_flash_prefix(eos_ssh_send_command): + # Real hardware returns softwareImage as "flash:/EOS-4.28.5M.swi"; the vEOS fixture had + # no prefix, so boot_options' .replace("flash:/", "") was previously untested. + device = eos_ssh_send_command(["show_boot-config_json"]) + assert device.boot_options == {"sys": BOOT_IMAGE} + + +def test_running_config(eos_ssh_send_command): + device = eos_ssh_send_command(["show_running-config"]) + running_config = device.running_config + assert isinstance(running_config, str) + assert "hostname eos-spine1" in running_config + device.native.send_command.assert_called_with("show running-config", read_timeout=120) + + +def test_startup_config(eos_ssh_send_command): + device = eos_ssh_send_command(["show_startup-config"]) + assert "hostname eos-spine1" in device.startup_config + + +def test_facts_are_cached(eos_ssh_send_command): + device = eos_ssh_send_command(["show_hostname_json"]) + assert device.hostname == HOSTNAME + assert device.hostname == HOSTNAME + # A single device round-trip: the second read comes from the cache. + assert device.native.send_command.call_count == 1 + + +def test_backup_running_config(eos_ssh_send_command, tmp_path): + # Inherited backup_running_config reads running_config twice (once to write, once to + # log) and running_config is not cached, so two round-trips are expected. + device = eos_ssh_send_command(["show_running-config", "show_running-config"]) + target = tmp_path / "backup.cfg" + device.backup_running_config(str(target)) + assert "hostname eos-spine1" in target.read_text() + + +# --------------------------------------------------------------------------- +# Filesystem helpers (inherited, exercised over SSH) +# --------------------------------------------------------------------------- + + +def test_get_file_system(eos_ssh_send_command): + device = eos_ssh_send_command(["dir"]) + assert device._get_file_system() == "flash:" + + +def test_get_free_space(eos_ssh_send_command): + device = eos_ssh_send_command(["dir"]) + assert device._get_free_space() == FREE_BYTES + + +def test_get_free_space_raises_when_unparseable(eos_ssh_send_command): + device = eos_ssh_send_command(["nothing useful here"]) + with pytest.raises(CommandError): + device._get_free_space() + + +def test_check_free_space_raises_when_insufficient(eos_ssh_send_command): + from pyntc.errors import NotEnoughFreeSpaceError + + device = eos_ssh_send_command(["dir"]) + with pytest.raises(NotEnoughFreeSpaceError): + device._check_free_space(99_999_999_999, file_system="flash:") + + +def test_image_booted(eos_ssh_send_command): + device = eos_ssh_send_command(["show_boot", "show_boot"]) + assert device._image_booted(BOOT_IMAGE) is True + # The other image present on flash is not the booted one. + assert device._image_booted("EOS-4.28.9M.swi") is False + + +# --------------------------------------------------------------------------- +# Inherited file operations (these already used Netmiko on the eAPI driver) +# --------------------------------------------------------------------------- + + +def test_check_file_exists_true(eos_ssh_send_command): + device = eos_ssh_send_command(["dir", "Directory of flash:/EOS.swi\n\n-rwx 1234 EOS.swi\n"]) + assert device.check_file_exists("EOS.swi") is True + + +def test_check_file_exists_false(eos_ssh_send_command): + device = eos_ssh_send_command(["dir", "% Error listing directory"]) + assert device.check_file_exists("missing.swi") is False + + +def test_check_file_exists_raises_on_unknown_output(eos_ssh_send_command): + device = eos_ssh_send_command(["dir", "something unexpected"]) + with pytest.raises(CommandError): + device.check_file_exists("EOS.swi") + + +def test_get_remote_checksum(eos_ssh_send_command): + device = eos_ssh_send_command(["dir", "verify /sha512 (flash:EOS.swi) = abc123"]) + assert device.get_remote_checksum("EOS.swi", hashing_algorithm="sha512") == "abc123" + + +def test_get_remote_checksum_rejects_unsupported_algorithm(eos_ssh_device): + with pytest.raises(ValueError, match="Unsupported hashing algorithm"): + eos_ssh_device.get_remote_checksum("EOS.swi", hashing_algorithm="blake3") + + +def test_verify_file_matches(eos_ssh_send_command): + device = eos_ssh_send_command( + ["dir", "Directory of flash:/EOS.swi\n", "dir", "verify /md5 (flash:EOS.swi) = ABC123"] + ) + assert device.verify_file("abc123", "EOS.swi") is True + + +def test_verify_file_missing_file(eos_ssh_send_command): + device = eos_ssh_send_command(["dir", "No such file"]) + assert device.verify_file("abc123", "EOS.swi") is False + + +# --------------------------------------------------------------------------- +# Pushing code onto the box: file_copy (local -> device, over SCP) +# --------------------------------------------------------------------------- + + +def test_file_copy_instance_uses_the_netmiko_session(eos_ssh_device): + # The whole reason native_ssh is aliased: inherited FileTransfer code must receive the + # SSH driver's own Netmiko handler, not a separate session. + with mock.patch("pyntc.devices.eos_device.FileTransfer") as file_transfer: + eos_ssh_device._file_copy_instance("/local/EOS.swi", "EOS.swi", file_system="flash:") + args, kwargs = file_transfer.call_args + assert args[0] is eos_ssh_device.native + # "flash:" is the CLI name; SCP addresses the same filesystem by its Linux path. + assert kwargs["file_system"] == "/mnt/flash" + + +@pytest.fixture +def local_image(tmp_path): + """A small local file plus its md5, standing in for an image to upload.""" + source = tmp_path / "EOS.swi" + source.write_bytes(b"x" * 32) + return source, hashlib.md5(b"x" * 32).hexdigest() # noqa: S324 + + +def _present(checksum): + """Side effects for a verify_file() that finds a matching remote file.""" + return ["Directory of flash:/EOS.swi\n", f"verify /md5 (flash:EOS.swi) = {checksum}"] + + +ABSENT = ["No such file"] + + +def test_file_copy_skips_transfer_when_already_present(eos_ssh_send_command, local_image): + source, checksum = local_image + device = eos_ssh_send_command(["dir", *_present(checksum)]) + with mock.patch("pyntc.devices.eos_device.FileTransfer") as file_transfer: + device.file_copy(str(source)) + file_transfer.return_value.transfer_file.assert_not_called() + + +def test_file_copy_transfers_when_missing(eos_ssh_send_command, local_image): + source, checksum = local_image + device = eos_ssh_send_command(["dir", *ABSENT, "dir", *_present(checksum)]) + with mock.patch("pyntc.devices.eos_device.FileTransfer") as file_transfer: + device.file_copy(str(source)) + file_transfer.return_value.establish_scp_conn.assert_called_once() + file_transfer.return_value.transfer_file.assert_called_once() + file_transfer.return_value.close_scp_chan.assert_called_once() + # Arista's FileTransfer raises NotImplementedError here, so it must never be called. + file_transfer.return_value.enable_scp.assert_not_called() + + +def test_file_copy_never_enters_the_shell(eos_ssh_send_command, local_image): + # The reason this override exists: the connecting account may have no bash access. + source, checksum = local_image + device = eos_ssh_send_command(["dir", *ABSENT, "dir", *_present(checksum)]) + with mock.patch("pyntc.devices.eos_device.FileTransfer"): + device.file_copy(str(source)) + commands = [call[0][0] for call in device.native.send_command.call_args_list] + assert not any(command.strip() == "bash" or command.startswith("/bin/") for command in commands) + + +def test_file_copy_verifies_over_the_cli(eos_ssh_send_command, local_image): + source, checksum = local_image + device = eos_ssh_send_command(["dir", *ABSENT, "dir", *_present(checksum)]) + with mock.patch("pyntc.devices.eos_device.FileTransfer"): + device.file_copy(str(source)) + commands = [call[0][0] for call in device.native.send_command.call_args_list] + assert "dir flash:/EOS.swi" in commands + assert "verify /md5 flash:EOS.swi" in commands + + +def test_file_copy_raises_when_transfer_fails(eos_ssh_send_command, local_image): + source, _ = local_image + device = eos_ssh_send_command(["dir", *ABSENT, "dir"]) + with mock.patch("pyntc.devices.eos_device.FileTransfer") as file_transfer: + file_transfer.return_value.transfer_file.side_effect = RuntimeError("scp blew up") + with pytest.raises(FileTransferError): + device.file_copy(str(source)) + # The SCP channel must be closed even when the transfer blows up. + file_transfer.return_value.close_scp_chan.assert_called_once() + + +def test_file_copy_raises_socket_closed_when_session_drops_and_file_missing(eos_ssh_send_command, local_image): + source, _ = local_image + device = eos_ssh_send_command(["dir", *ABSENT, "dir"]) + with mock.patch("pyntc.devices.eos_device.FileTransfer") as file_transfer: + file_transfer.return_value.transfer_file.side_effect = OSError("socket closed") + file_transfer.return_value.compare_md5.return_value = False + with pytest.raises(SocketClosedError): + device.file_copy(str(source)) + + +def test_file_copy_tolerates_dropped_session_when_file_landed(eos_ssh_send_command, local_image): + # A dropped control channel is survivable if the file actually made it. + source, checksum = local_image + device = eos_ssh_send_command(["dir", *ABSENT, "dir", *_present(checksum)]) + with mock.patch("pyntc.devices.eos_device.FileTransfer") as file_transfer: + file_transfer.return_value.transfer_file.side_effect = OSError("socket closed") + file_transfer.return_value.compare_md5.return_value = True + device.file_copy(str(source)) + + +def test_file_copy_raises_when_file_absent_after_transfer(eos_ssh_send_command, local_image): + source, _ = local_image + device = eos_ssh_send_command(["dir", *ABSENT, "dir", *ABSENT]) + with mock.patch("pyntc.devices.eos_device.FileTransfer"): + with pytest.raises(FileTransferError): + device.file_copy(str(source)) + + +def test_file_copy_raises_when_not_enough_free_space(eos_ssh_send_command, local_image): + source, _ = local_image + device = eos_ssh_send_command(["dir", *ABSENT, "dir"]) + with mock.patch("pyntc.devices.eos_device.FileTransfer") as file_transfer: + with mock.patch("os.path.getsize", return_value=FREE_BYTES + 1): + with pytest.raises(NotEnoughFreeSpaceError): + device.file_copy(str(source)) + file_transfer.return_value.transfer_file.assert_not_called() + + +def test_file_copy_remote_exists_true(eos_ssh_send_command, local_image): + source, checksum = local_image + device = eos_ssh_send_command(["dir", *_present(checksum)]) + assert device.file_copy_remote_exists(str(source)) is True + + +def test_file_copy_remote_exists_false_when_checksum_differs(eos_ssh_send_command, local_image): + source, _ = local_image + device = eos_ssh_send_command(["dir", *_present("deadbeef")]) + assert device.file_copy_remote_exists(str(source)) is False + + +def test_file_copy_accepts_explicit_file_system_and_dest(eos_ssh_send_command, local_image): + # With both supplied there is no "dir" filesystem probe -- verification goes first. + source, checksum = local_image + device = eos_ssh_send_command( + ["Directory of flash:/boot.swi\n", f"verify /md5 (flash:boot.swi) = {checksum}"], + ) + with mock.patch("pyntc.devices.eos_device.FileTransfer") as file_transfer: + device.file_copy(str(source), dest="boot.swi", file_system="flash:") + file_transfer.return_value.transfer_file.assert_not_called() + assert device.native.send_command.call_args_list[0][0][0] == "dir flash:/boot.swi" + + +def test_file_copy_remote_exists_accepts_explicit_file_system(eos_ssh_send_command, local_image): + source, checksum = local_image + device = eos_ssh_send_command(_present(checksum)) + assert device.file_copy_remote_exists(str(source), file_system="flash:") is True + + +def test_file_copy_remote_exists_never_enters_the_shell(eos_ssh_send_command, local_image): + source, checksum = local_image + device = eos_ssh_send_command(["dir", *_present(checksum)]) + device.file_copy_remote_exists(str(source)) + commands = [call[0][0] for call in device.native.send_command.call_args_list] + assert not any(command.strip() == "bash" or command.startswith("/bin/") for command in commands) + + +# --------------------------------------------------------------------------- +# Pulling code onto the box: remote_file_copy (device fetches from a server) +# --------------------------------------------------------------------------- + + +def _model(url="http://192.0.2.5/EOS.swi", checksum="abc123", **kwargs): + return FileCopyModel(download_url=url, checksum=checksum, file_name="EOS.swi", **kwargs) + + +def test_remote_file_copy_issues_copy_command_and_verifies(eos_ssh_send_command): + device = eos_ssh_send_command( + [ + "dir", # _get_file_system + "", # the copy command itself + "Directory of flash:/EOS.swi\n", # verify_file -> check_file_exists + "verify /md5 (flash:EOS.swi) = abc123", # verify_file -> get_remote_checksum + ] + ) + device.remote_file_copy(_model()) + commands = [call[0][0] for call in device.native.send_command.call_args_list] + assert "copy http://192.0.2.5/EOS.swi flash:" in commands + + +def test_remote_file_copy_embeds_credentials_for_http(eos_ssh_send_command): + device = eos_ssh_send_command(["dir", "", "Directory of flash:/EOS.swi\n", "verify /md5 (flash:EOS.swi) = abc123"]) + device.remote_file_copy(_model(url="http://user:token@192.0.2.5/EOS.swi")) + commands = [call[0][0] for call in device.native.send_command.call_args_list] + assert "copy http://user:token@192.0.2.5/EOS.swi flash:" in commands + + +def test_remote_file_copy_prompts_for_scp_password(eos_ssh_send_command, eos_ssh_send_command_timing): + # SCP cannot carry the password in the URL, so the driver answers the prompt interactively. + device = eos_ssh_send_command(["dir", "Directory of flash:/EOS.swi\n", "verify /md5 (flash:EOS.swi) = abc123"]) + eos_ssh_send_command_timing(["Password:", ""], existing_device=device) + device.remote_file_copy(_model(url="scp://user:token@192.0.2.5/EOS.swi")) + timing_commands = [call[0][0] for call in device.native.send_command_timing.call_args_list] + assert timing_commands[0] == "copy scp://user@192.0.2.5/EOS.swi flash:" + assert timing_commands[1] == "token" # the password, sent only after the prompt appears + + +def test_remote_file_copy_rejects_non_model(eos_ssh_device): + with pytest.raises(TypeError): + eos_ssh_device.remote_file_copy("http://192.0.2.5/EOS.swi") + + +def test_remote_file_copy_rejects_unsupported_scheme(eos_ssh_device): + with pytest.raises(ValueError, match="Unsupported scheme"): + eos_ssh_device.remote_file_copy(_model(url="rsync://192.0.2.5/EOS.swi")) + + +def test_remote_file_copy_rejects_query_string(eos_ssh_device): + # The EOS CLI cannot handle "?" in a copy URL. + with pytest.raises(ValueError, match="query strings"): + eos_ssh_device.remote_file_copy(_model(url="https://192.0.2.5/EOS.swi?token=x")) + + +def test_remote_file_copy_checks_free_space_first(eos_ssh_send_command): + device = eos_ssh_send_command(["dir", "dir"]) + with pytest.raises(NotEnoughFreeSpaceError): + device.remote_file_copy(_model(file_size=10, file_size_unit="gigabytes")) + # Nothing was transferred. + commands = [call[0][0] for call in device.native.send_command.call_args_list] + assert not any(command.startswith("copy ") for command in commands) + + +def test_remote_file_copy_raises_on_error_output(eos_ssh_send_command): + device = eos_ssh_send_command(["dir", "Error: connection refused"]) + with pytest.raises(FileTransferError): + device.remote_file_copy(_model()) + + +def test_remote_file_copy_raises_when_checksum_mismatches(eos_ssh_send_command): + device = eos_ssh_send_command( + ["dir", "", "Directory of flash:/EOS.swi\n", "verify /md5 (flash:EOS.swi) = deadbeef"] + ) + with pytest.raises(FileTransferError): + device.remote_file_copy(_model(checksum="abc123")) + + +# --------------------------------------------------------------------------- +# Upgrading: install_os +# --------------------------------------------------------------------------- + +NEW_IMAGE = "EOS-4.28.9M.swi" +NEW_IMAGE_BOOTED = "Software image: flash:/EOS-4.28.9M.swi\n" + + +def _set_boot_options_effects(): + """Side effects consumed by set_boot_options: fs probe, dir listing, install, readback.""" + return ["dir", "dir", "", '{"softwareImage": "flash:/EOS-4.28.9M.swi"}'] + + +def test_install_os_returns_false_when_image_already_booted(eos_ssh_send_command): + device = eos_ssh_send_command(["show_boot"]) + assert device.install_os(BOOT_IMAGE) is False + + +def test_install_os_sets_boot_options_then_reboots(eos_ssh_send_command): + device = eos_ssh_send_command(["show_boot", *_set_boot_options_effects(), NEW_IMAGE_BOOTED]) + with mock.patch.object(Driver, "reboot") as mock_reboot: + assert device.install_os(NEW_IMAGE) is True + mock_reboot.assert_called_once_with(wait_for_reload=True, timeout=DEFAULT_REBOOT_TIMEOUT) + commands = [call[0][0] for call in device.native.send_command.call_args_list] + assert f"install source flash:{NEW_IMAGE}" in commands + + +def test_install_os_honours_custom_timeout(eos_ssh_send_command): + device = eos_ssh_send_command(["show_boot", *_set_boot_options_effects(), NEW_IMAGE_BOOTED]) + with mock.patch.object(Driver, "reboot") as mock_reboot: + device.install_os(NEW_IMAGE, timeout=120) + mock_reboot.assert_called_once_with(wait_for_reload=True, timeout=120) + + +def test_install_os_without_reboot_does_not_reboot(eos_ssh_send_command): + device = eos_ssh_send_command(["show_boot", *_set_boot_options_effects()]) + with mock.patch.object(Driver, "reboot") as mock_reboot: + assert device.install_os(NEW_IMAGE, reboot=False) is True + mock_reboot.assert_not_called() + + +def test_install_os_raises_when_image_not_booted_after_reboot(eos_ssh_send_command): + # Device comes back still running the old image. The final side effect feeds + # self.hostname, which OSInstallError reads when building its message. + device = eos_ssh_send_command(["show_boot", *_set_boot_options_effects(), "show_boot", "show_hostname_json"]) + with mock.patch.object(Driver, "reboot"): + with pytest.raises(OSInstallError): + device.install_os(NEW_IMAGE) + + +# --------------------------------------------------------------------------- +# reboot / rollback / save +# --------------------------------------------------------------------------- + + +def test_reboot_sends_reload_now(eos_ssh_device): + eos_ssh_device.reboot() + eos_ssh_device.native.send_command_timing.assert_called_with("reload now") + + +def test_reboot_marks_session_disconnected(eos_ssh_device): + eos_ssh_device.reboot() + assert eos_ssh_device._connected is False + + +def test_reboot_tolerates_dropped_session(eos_ssh_device): + # The session dies mid-command by design; that must not surface as an error. + eos_ssh_device.native.send_command_timing.side_effect = OSError("Socket is closed") + eos_ssh_device.reboot() + assert eos_ssh_device._connected is False + + +def test_reboot_without_wait_does_not_poll(eos_ssh_device): + with mock.patch.object(Driver, "_wait_for_device_reboot") as mock_wait: + eos_ssh_device.reboot() + mock_wait.assert_not_called() + + +def test_reboot_wait_for_reload_polls_with_original_boot_time(eos_ssh_send_command): + device = eos_ssh_send_command(["show_version_json"]) + with mock.patch.object(Driver, "_wait_for_device_reboot") as mock_wait: + device.reboot(wait_for_reload=True, timeout=42) + mock_wait.assert_called_once_with(original_boot_time=BOOT_TIMESTAMP, timeout=42) + + +def test_reboot_warns_on_deprecated_confirm(eos_ssh_device, caplog): + eos_ssh_device.reboot(confirm=True) + assert "deprecated" in caplog.text + + +def test_vlans_are_cached(eos_ssh_send_command): + device = eos_ssh_send_command(["show_vlan_json"]) + assert device.vlans == ["1", "10", "11", "12", "9"] + assert device.vlans == ["1", "10", "11", "12", "9"] + assert device.native.send_command.call_count == 1 + + +def test_rollback(eos_ssh_send_command): + device = eos_ssh_send_command([""]) + device.rollback("good_checkpoint") + assert device.native.send_command.call_args[0][0] == "configure replace good_checkpoint force" + + +def test_rollback_raises_on_failure(eos_ssh_send_command): + device = eos_ssh_send_command(["% Invalid input"]) + with pytest.raises(RollbackError): + device.rollback("bad_checkpoint") + + +def test_save(eos_ssh_send_command): + device = eos_ssh_send_command([""]) + assert device.save() is True + assert device.native.send_command.call_args[0][0] == "copy running-config startup-config" + + +def test_checkpoint(eos_ssh_send_command): + device = eos_ssh_send_command([""]) + device.checkpoint("good_checkpoint") + assert device.native.send_command.call_args[0][0] == "copy running-config good_checkpoint" + + +def test_set_boot_options(eos_ssh_send_command): + # Side effects: _get_file_system, dir , install source, then boot_options readback. + device = eos_ssh_send_command( + ["dir", "dir", "", '{"softwareImage": "flash:/EOS-4.28.9M.swi"}'], + ) + device.set_boot_options("EOS-4.28.9M.swi") + calls = [call[0][0] for call in device.native.send_command.call_args_list] + assert "install source flash:EOS-4.28.9M.swi" in calls + + +def test_set_boot_options_uses_long_read_timeout(eos_ssh_send_command): + device = eos_ssh_send_command( + ["dir", "dir", "", '{"softwareImage": "flash:/EOS-4.28.9M.swi"}'], + ) + device.set_boot_options("EOS-4.28.9M.swi") + install_call = [c for c in device.native.send_command.call_args_list if "install source" in c[0][0]][0] + assert install_call[1]["read_timeout"] == 3600 + + +def test_set_boot_options_missing_image(eos_ssh_send_command): + from pyntc.errors import NTCFileNotFoundError + + # Third side effect feeds self.hostname, which NTCFileNotFoundError reads. + device = eos_ssh_send_command(["dir", "dir", "show_hostname_json"]) + with pytest.raises(NTCFileNotFoundError): + device.set_boot_options("not-on-the-box.swi") + + +def test_set_boot_options_raises_when_readback_mismatches(eos_ssh_send_command): + device = eos_ssh_send_command(["dir", "dir", "", '{"softwareImage": "flash:/EOS-4.28.5M.swi"}']) + with pytest.raises(CommandError): + device.set_boot_options("EOS-4.28.9M.swi") + + +def test_install_mode_remains_unimplemented(eos_ssh_device): + # EOSDevice does not implement install_mode; parity means neither does this driver. + with pytest.raises(NotImplementedError): + eos_ssh_device.install_mode # noqa: B018 + + +# --------------------------------------------------------------------------- +# API parity with EOSDevice +# --------------------------------------------------------------------------- + +# EOSDevice assigns native_ssh as an *instance* attribute inside open(); the SSH driver +# exposes it as a class-level property so inherited code resolves it. That is the only +# permitted addition to the public surface. +KNOWN_ADDITIONS = {"native_ssh"} + + +def _fixture(name): + path = os.path.join(os.path.dirname(__file__), "device_mocks", "eos_ssh", name) + with open(path) as handle: + return json.load(handle) + + +# Facts derived purely from show output. "vlans" is excluded: EOSDevice sources it from +# pyeapi's native.api("vlans"), which has no SSH equivalent by design. +SHARED_FACTS = ["boot_time", "hostname", "fqdn", "model", "os_version", "serial_number", "interfaces", "boot_options"] + + +@pytest.mark.parametrize("fact", SHARED_FACTS) +def test_facts_match_eapi_driver_for_identical_payload(fact): + """Both drivers must derive identical facts from identical device output. + + The two drivers no longer share fixture files, so this is the anti-drift guard: feed + the same documents to each and require the same answer. + """ + payloads = { + "show version": _fixture("show_version_json"), + "show hostname": _fixture("show_hostname_json"), + "show interfaces status": _fixture("show_interfaces_status_json"), + "show boot-config": _fixture("show_boot-config_json"), + } + + def fake_show(command, raw_text=False): + return payloads[command] + + with mock.patch("pyntc.devices.eos_ssh_device.ConnectHandler"): + ssh_device = EOSSSHDevice("host", "user", "password") + with mock.patch("pyeapi.client.Node", autospec=True): + with mock.patch("pyntc.devices.eos_device.eos_connect"): + eapi_device = EOSDevice("host", "user", "password") + + with mock.patch.object(ssh_device, "show", side_effect=fake_show): + with mock.patch.object(eapi_device, "show", side_effect=fake_show): + assert getattr(ssh_device, fact) == getattr(eapi_device, fact) + + +def _public_api(cls): + return {name for name in dir(cls) if not name.startswith("_")} + + +def test_public_api_matches_eos_device(): + assert _public_api(EOSSSHDevice) - KNOWN_ADDITIONS == _public_api(EOSDevice) + + +def test_no_eos_device_member_is_missing(): + assert _public_api(EOSDevice) - _public_api(EOSSSHDevice) == set() + + +@pytest.mark.parametrize("name", sorted(_public_api(EOSDevice))) +def test_member_parity(name): + eapi_attr = inspect.getattr_static(EOSDevice, name) + ssh_attr = inspect.getattr_static(EOSSSHDevice, name) + assert isinstance(ssh_attr, property) == isinstance(eapi_attr, property), f"{name} kind differs" + if callable(eapi_attr) and not isinstance(eapi_attr, property): + assert inspect.signature(ssh_attr) == inspect.signature(eapi_attr), f"{name} signature differs" diff --git a/tests/unit/test_devices/test_jnpr_device.py b/tests/unit/test_devices/test_jnpr_device.py index 334c2e83..4a4f085c 100644 --- a/tests/unit/test_devices/test_jnpr_device.py +++ b/tests/unit/test_devices/test_jnpr_device.py @@ -5,7 +5,7 @@ import mock import pytest -from jnpr.junos.exception import ConfigLoadError, RpcTimeoutError +from jnpr.junos.exception import ConfigLoadError, RpcError, RpcTimeoutError from pyntc.devices import JunosDevice, jnpr_device from pyntc.errors import ( @@ -61,6 +61,81 @@ "personality": "MX", } +# SRX3xx chassis-cluster variant of DEVICE_FACTS with model and cluster awareness +SRX3XX_FACTS = { + **DEVICE_FACTS, + "model": "SRX340", + "current_re": "node0", + "srx_cluster": { + "cluster_id": 1, + "node_id": 0, + }, +} + +# Realistic `show version` output from an SRX340 chassis-cluster (node0 and node1) +CHASSIS_CLUSTER_SHOW_VERSION = """ +node0: +---------- +Hostname: srx340-node0 +Model: srx340 +JUNOS Software Release [19.1R1.6] +Junos: 19.1R1.6 + +node1: +---------- +Hostname: srx340-node1 +Model: srx340 +JUNOS Software Release [19.1R1.6] +Junos: 19.1R1.6 +""" + +# Version mismatch variant: node0 upgraded, node1 still on old version +CHASSIS_CLUSTER_SHOW_VERSION_MISMATCH = """ +node0: +---------- +Junos: 19.1R1.6 + +node1: +---------- +Junos: 19.1R1.5 +""" + +# Realistic `show configuration chassis cluster` output +CHASSIS_CLUSTER_CONFIG = """ +chassis { + cluster { + disable-auto-image-copy; + reth-count 8; + redundancy-group 0 { + priority 100; + } + redundancy-group 1 { + priority 90; + } + } +} +""" + +# Realistic `show chassis cluster status` output (both RGs primary on node0) +CHASSIS_CLUSTER_STATUS = """ +Redundancy group: 0, Status: Up +node0 master primary +node1 backup secondary +Redundancy group: 1, Status: Up +node0 master primary +node1 backup secondary +""" + +# Variant: node0 secondary in RG 0 (not primary everywhere) +CHASSIS_CLUSTER_STATUS_SECONDARY_RG0 = """ +Redundancy group: 0, Status: Up +node0 backup secondary +node1 master primary +Redundancy group: 1, Status: Up +node0 master primary +node1 backup secondary +""" + class TestJnprDevice(unittest.TestCase): def setUp(self): @@ -1093,6 +1168,62 @@ def test_install_os_install_failure_reboot_false_raises_error(self): reboot=False, ) + def test_install_os_no_validate_parameter(self): + """Test no_validate parameter controls validation.""" + with ( + mock.patch.object(self.device, "_validate_multiple_device", return_value=False), + mock.patch.object(self.device, "_wait_for_device_reboot"), + mock.patch.object(self.device, "request_system_snapshot"), + mock.patch.object(self.device, "_verify_install_version"), + mock.patch.object(type(self.device), "uptime", new_callable=mock.PropertyMock) as mock_uptime, + ): + mock_uptime.return_value = 1000 + self.device.sw.install.return_value = True + + with self.subTest("no_validate=True (default)"): + self.device.install_os( + image_name="/var/tmp/jinstall-15.1R7-S2-signed.tgz", + checksum="c0ffee", + ) + install_kwargs = self.device.sw.install.call_args.kwargs + self.assertFalse(install_kwargs.get("validate")) + + with self.subTest("validate=True"): + self.device.sw.install.reset_mock() + self.device.install_os( + image_name="/var/tmp/jinstall-15.1R7-S2-signed.tgz", + checksum="c0ffee", + validate=True, + ) + install_kwargs = self.device.sw.install.call_args.kwargs + self.assertTrue(install_kwargs.get("validate")) + + def test_install_os_srx3xx_no_validate_warning_on_multi_device(self): + """Test warning is logged when no_validate is True on multi-device.""" + with ( + mock.patch.object(self.device, "_validate_multiple_device", return_value=True), + mock.patch.object(self.device, "_wait_for_nssu_completion"), + mock.patch.object(self.device, "request_system_snapshot"), + mock.patch.object(self.device, "_verify_install_version"), + mock.patch("pyntc.devices.jnpr_device.log") as mock_log, + mock.patch.object(type(self.device), "uptime", new_callable=mock.PropertyMock) as mock_uptime, + ): + self.device.native.facts = { + **DEVICE_FACTS, + "re_info": {"default": {"0": {"status": "OK"}, "1": {"status": "OK"}, "default": {"status": "OK"}}}, + } + mock_uptime.return_value = 1000 + self.device.sw.install.return_value = True + + self.device.install_os( + image_name="/var/tmp/jinstall-ex-3300-15.1R7-S2-domestic-signed.tgz", + checksum="c0ffee", + nssu=True, + ) + + # Should log warning about no_validate on multi-device + mock_log.warning.assert_called() + class TestJnprFreeSpace(unittest.TestCase): """Tests for JunOS pre-transfer free-space verification (NAPPS-1085).""" @@ -1295,5 +1426,265 @@ def test_remote_file_copy_keeps_url_intact_when_path_is_present(self, mock_start ) +class TestJnprDeviceICUUpgrade(unittest.TestCase): + """Tests for ICU (In-service Cluster Upgrade) on SRX chassis-cluster devices.""" + + def setUp(self): + self.mock_sw = mock.patch("pyntc.devices.jnpr_device.JunosNativeSW", autospec=True) + self.mock_fs = mock.patch("pyntc.devices.jnpr_device.JunosNativeFS", autospec=True) + self.mock_config = mock.patch("pyntc.devices.jnpr_device.JunosNativeConfig", autospec=True) + self.mock_device = mock.patch("pyntc.devices.jnpr_device.JunosNativeDevice", autospec=True) + + self.mock_sw.start() + self.mock_fs.start() + self.mock_config.start() + self.mock_device.start() + + self.device = JunosDevice("host", "user", "pass") + self.device.native.rpc = mock.MagicMock() + self.device.native.timeout = 120 + self.device.native.facts = {"hostname": "srx340"} + + def tearDown(self): + self.mock_sw.stop() + self.mock_fs.stop() + self.mock_config.stop() + self.mock_device.stop() + + def test_get_chassis_cluster_versions_parses_show_version(self): + """_get_chassis_cluster_versions() correctly parses show version output.""" + show_version_output = """ +node0: +---------- +Hostname: srx340-node0 +Model: srx340 +JUNOS Software Release [19.1R1.6] +Junos: 19.1R1.6 + +node1: +---------- +Hostname: srx340-node1 +Model: srx340 +JUNOS Software Release [19.1R1.6] +Junos: 19.1R1.6 +""" + with mock.patch.object(self.device, "show", return_value=show_version_output): + versions = self.device._get_chassis_cluster_versions() + + self.assertEqual(versions, {"0": "19.1R1.6", "1": "19.1R1.6"}) + + def test_get_chassis_cluster_versions_handles_single_node(self): + """_get_chassis_cluster_versions() handles single node output.""" + show_version_output = """ +node0: +---------- +Junos: 21.4R3-S5.3 +""" + with mock.patch.object(self.device, "show", return_value=show_version_output): + versions = self.device._get_chassis_cluster_versions() + + self.assertEqual(versions, {"0": "21.4R3-S5.3"}) + + def test_get_chassis_cluster_versions_returns_empty_on_error(self): + """_get_chassis_cluster_versions() returns empty dict on exception.""" + with mock.patch.object(self.device, "show", side_effect=Exception("parse error")): + versions = self.device._get_chassis_cluster_versions() + + self.assertEqual(versions, {}) + + def test_initiate_issu_upgrade_timeout_override_for_icu(self): + """_initiate_issu_upgrade() increases timeout to 1800s for ICU upgrades.""" + self.device.native.rpc.cli.return_value = "ISSU: Validating package" + + self.device._initiate_issu_upgrade("/var/tmp/image.tgz", is_icu=True, no_validate=True) + + # Verify timeout was increased + self.assertEqual(self.device.native.timeout, 120) # restored after call + + def test_initiate_issu_upgrade_handles_rpc_timeout(self): + """_initiate_issu_upgrade() handles RpcTimeoutError gracefully for device reboot.""" + self.device.native.rpc.cli.side_effect = RpcTimeoutError("device", "cli", 1800) + + result = self.device._initiate_issu_upgrade("/var/tmp/image.tgz", is_icu=True, no_validate=False) + + # Should not raise; RpcTimeoutError is expected during device reboot + self.assertIsNone(result) + + def test_initiate_issu_upgrade_raises_on_other_errors(self): + """_initiate_issu_upgrade() raises OSInstallError on non-timeout RPC errors.""" + error_response = mock.MagicMock() + self.device.native.rpc.cli.side_effect = RpcError(error_response) + + with self.assertRaises(OSInstallError): + self.device._initiate_issu_upgrade("/var/tmp/image.tgz", is_icu=True, no_validate=False) + + def test_get_icu_redundancy_groups(self): + """_get_icu_redundancy_groups() extracts RG numbers from config.""" + config_output = """ +chassis { + cluster { + disable-auto-image-copy; + reth-count 8; + redundancy-group 0 { + priority 100; + } + redundancy-group 1 { + priority 90; + } + } +} +""" + with mock.patch.object(self.device, "show", return_value=config_output): + rgs = self.device._get_icu_redundancy_groups() + + self.assertEqual(rgs, [0, 1]) + + def test_get_node_redundancy_status_primary(self): + """_get_node_redundancy_status() returns primary for primary node.""" + cluster_status = """ +Redundancy group: 0, Status: Up +node0 master primary +node1 backup secondary +Redundancy group: 1, Status: Up +node0 backup secondary +node1 master primary +""" + with mock.patch.object(self.device, "show", return_value=cluster_status): + status = self.device._get_node_redundancy_status("node0", 0) + + self.assertEqual(status, "primary") + + def test_get_node_redundancy_status_secondary(self): + """_get_node_redundancy_status() returns secondary for secondary node.""" + cluster_status = """ +Redundancy group: 0, Status: Up +node0 master primary +node1 backup secondary +""" + with mock.patch.object(self.device, "show", return_value=cluster_status): + status = self.device._get_node_redundancy_status("node1", 0) + + self.assertEqual(status, "secondary") + + def test_failover_redundancy_group(self): + """_failover_redundancy_group() executes failover RPC command.""" + self.device.native.rpc.cli.return_value = "Failover successful" + + self.device._failover_redundancy_group(0, "node1") + + self.device.native.rpc.cli.assert_called_once() + call_args = self.device.native.rpc.cli.call_args + self.assertIn("redundancy-group 0", call_args[1]["command"]) + self.assertIn("node 1", call_args[1]["command"]) + + def test_install_os_icu_retries_after_primary_node_rpc_error(self): + """ICU install retries failover after 'primary node error' RPC failure.""" + + self.device.native.facts = SRX3XX_FACTS.copy() + self.device._is_chassis_cluster = True + + # Create RpcError with proper response object containing error text + primary_node_error = RpcError(self.device.native, "test") + primary_node_error.rsp = "primary node error" # Set the response text + + initial_calls = [primary_node_error, None] # Second call (after failover) succeeds + call_iter = iter(initial_calls) + + def initiate_side_effect(*args, **kwargs): + result = next(call_iter) + if isinstance(result, Exception): + raise result + return result + + # Mock the redundancy groups and status to indicate we need failover + show_call_count = {"count": 0} + + def show_side_effect(cmd): + if "config" in cmd: + return CHASSIS_CLUSTER_CONFIG + # Initially secondary, then primary after failover + show_call_count["count"] += 1 + + # First call: show cluster status with node0 secondary + if show_call_count["count"] == 1: + return CHASSIS_CLUSTER_STATUS_SECONDARY_RG0 + # After failover: show cluster status with node0 primary + return CHASSIS_CLUSTER_STATUS + + with ( + mock.patch.object(self.device, "_initiate_issu_upgrade", side_effect=initiate_side_effect), + mock.patch.object(self.device, "show", side_effect=show_side_effect), + mock.patch.object(self.device, "_wait_for_device_reboot"), + mock.patch.object(self.device, "_post_install_checks"), + mock.patch.object(self.device, "_failover_redundancy_group"), + mock.patch("pyntc.devices.jnpr_device.time.sleep"), # Skip actual sleep + mock.patch.object( + type(self.device), "uptime", new_callable=mock.PropertyMock, return_value=1000 + ), # Pre-upgrade uptime + ): + # Should succeed despite initial primary node error (via failover retry) + self.device._install_os_icu( + image_name="/var/tmp/jinstall-srx340-19.1R1.6.tgz", + checksum="abc123", + reboot=True, + ) + + # Verify _initiate_issu_upgrade was called twice (initial + retry after failover) + self.assertEqual(self.device._initiate_issu_upgrade.call_count, 2) + + def test_install_os_icu_raises_device_not_active_if_failover_does_not_take(self): + """ICU raises DeviceNotActiveError if node still not primary after failover.""" + from pyntc.errors import DeviceNotActiveError + + self.device.native.facts = SRX3XX_FACTS.copy() + self.device._is_chassis_cluster = True + + # _initiate_issu_upgrade raises primary node error initially + primary_node_error = RpcError(self.device.native, "test") + primary_node_error.rsp = "primary node error" + + # Cluster status always shows node0 as secondary (failover didn't work) + def show_side_effect(cmd): + if "config" in cmd: + return CHASSIS_CLUSTER_CONFIG + # Always show secondary — failover didn't help + return CHASSIS_CLUSTER_STATUS_SECONDARY_RG0 + + with ( + mock.patch.object(self.device, "_initiate_issu_upgrade", side_effect=primary_node_error), + mock.patch.object(self.device, "show", side_effect=show_side_effect), + mock.patch.object(self.device, "_failover_redundancy_group"), + mock.patch("pyntc.devices.jnpr_device.time.sleep"), + mock.patch.object(type(self.device), "uptime", new_callable=mock.PropertyMock, return_value=1000), + ): + with self.assertRaises(DeviceNotActiveError): + self.device._install_os_icu( + image_name="/var/tmp/jinstall-srx340-19.1R1.6.tgz", + checksum="abc123", + reboot=True, + ) + + def test_install_os_icu_propagates_non_primary_node_errors(self): + """ICU propagates non-primary RPC errors as OSInstallError without failover.""" + self.device.native.facts = SRX3XX_FACTS.copy() + self.device._is_chassis_cluster = True + + # RPC error that is NOT a primary node error (e.g., validation failure) + non_primary_error = RpcError(self.device.native, "test") + non_primary_error.rsp = "validation failed" + + with ( + mock.patch.object(self.device, "_initiate_issu_upgrade", side_effect=non_primary_error), + mock.patch("pyntc.devices.jnpr_device.time.sleep"), + mock.patch.object(type(self.device), "uptime", new_callable=mock.PropertyMock, return_value=1000), + ): + with self.assertRaises(OSInstallError): + self.device._install_os_icu( + image_name="/var/tmp/jinstall-srx340-19.1R1.6.tgz", + checksum="abc123", + reboot=True, + ) + + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/test_infra.py b/tests/unit/test_infra.py index 0be3ff1a..c69490d1 100644 --- a/tests/unit/test_infra.py +++ b/tests/unit/test_infra.py @@ -5,7 +5,7 @@ import pytest from pyntc import ntc_device, ntc_device_by_name -from pyntc.devices import EOSDevice, IOSDevice, NXOSDevice, supported_devices +from pyntc.devices import EOSDevice, EOSSSHDevice, IOSDevice, NXOSDevice, supported_devices from pyntc.errors import ConfFileNotFoundError, UnsupportedDeviceError BAD_DEVICE_TYPE = "238nzsvkn3981" @@ -18,12 +18,13 @@ @mock.patch("pyntc.devices.ios_device.IOSDevice.open") @mock.patch("pyntc.devices.iosxr_device.IOSXRDevice.open") @mock.patch("pyntc.devices.nxos_device.NXOSDevice.open") +@mock.patch("pyntc.devices.eos_ssh_device.EOSSSHDevice.open") @mock.patch("pyntc.devices.jnpr_device.JunosNativeSW") @mock.patch("pyntc.devices.jnpr_device.JunosNativeDevice.open") @mock.patch("pyntc.devices.jnpr_device.JunosNativeDevice.timeout") @pytest.mark.parametrize("device_type,expected", supported_devices.items(), ids=list(supported_devices)) def test_device_creation( - j_timeout, j_open, j_nsw, nx_open, xr_open, i_open, a_open, f_mr, air_open, device_type, expected + j_timeout, j_open, j_nsw, eos_ssh_open, nx_open, xr_open, i_open, a_open, f_mr, air_open, device_type, expected ): # Skip f5 on python >3.11 if sys.version_info >= (3, 12) and device_type == "f5_tmos_icontrol": @@ -39,8 +40,9 @@ def test_unsupported_device(): @mock.patch("pyntc.devices.ios_device.IOSDevice.open") @mock.patch("pyntc.devices.nxos_device.NXOSDevice.open") +@mock.patch("pyntc.devices.eos_ssh_device.EOSSSHDevice.open") @mock.patch("pyntc.devices.jnpr_device.JunosDevice.open") -def test_device_by_name(j_open, nx_open, i_open): +def test_device_by_name(j_open, eos_ssh_open, nx_open, i_open): config_filepath = os.path.join(FIXTURES_DIR, ".ntc.conf.sample") nxos_device = ntc_device_by_name("test_nxos", filename=config_filepath) @@ -49,6 +51,9 @@ def test_device_by_name(j_open, nx_open, i_open): eos_device = ntc_device_by_name("test_eos", filename=config_filepath) assert isinstance(eos_device, EOSDevice) + eos_ssh_device = ntc_device_by_name("test_eos_ssh", filename=config_filepath) + assert isinstance(eos_ssh_device, EOSSSHDevice) + ios_device = ntc_device_by_name("test_ios", filename=config_filepath) assert isinstance(ios_device, IOSDevice)