YueGuobin db164e9d41 feat(copilot): add multi-vendor device support with custom Huawei driver
Add comprehensive multi-vendor support for GNS3 network automation,
   including a custom Netmiko driver for Huawei CloudEngine devices.

   Features:
   - Custom HuaweiTelnetCE driver for GNS3 emulation (no authentication)
   - Auto-commit before exit to prevent [Y/N/C] prompts
   - Dynamic device type detection from GNS3 node tags
   - Support for both Cisco IOS and Huawei devices
   - Proper VRP command handling (system-view, return confirmation)

   Implementation:
   - New package: utils/custom_netmiko/
     - huawei_ce.py: Huawei CloudEngine driver
     - tests/test_huawei_ce.py: Unit tests (9/9 passing)
     - README.md: Driver development guide
   - Updated tools for multi-vendor support:
     - display_tools_nornir.py: Dynamic group generation
     - config_tools_nornir.py: Multi-vendor config commands
     - get_gns3_device_port.py: Device port extraction
   - Documentation: multi-vendor-device-support.md

   Limitations:
   - huawei_telnet_ce driver requires devices without authentication
   - For devices with username/password, use standard huawei_telnet driver

   Co-Authored-By: Yue Guobin <yueguobin@outlook.com>"
2026-03-12 16:44:32 +08:00

403 lines
14 KiB
Python

# SPDX-License-Identifier: GPL-3.0-or-later
#
# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3
#
# This file is part of GNS3-Copilot project.
#
# GNS3-Copilot is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by the
# Free Software Foundation, either version 3 of the License, or (at your
# option) any later version.
#
# GNS3-Copilot is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
# for more details.
#
# You should have received a copy of the GNU General Public License
# along with GNS3-Copilot. If not, see <https://www.gnu.org/licenses/>.
#
# Copyright (C) 2025 Yue Guobin (岳国宾)
# Author: Yue Guobin (岳国宾)
#
# Project Home: https://github.com/yueguobin/gns3-copilot
#
"""
Custom Netmiko device driver for Huawei devices in GNS3 emulation environment.
This module provides a custom device type 'huawei_telnet_ce' for Huawei network
devices that connect via console without requiring authentication (username/password).
This is specifically designed for GNS3 emulated Huawei devices (e.g., CloudEngine
series) where the console connection directly enters the system view without
login prompts.
Key Features:
- Inherits from HuaweiBase for proper VRP command handling
- Skips authentication (no username/password required)
- Supports Huawei-specific config mode (system-view)
- Handles Huawei prompt patterns (<>, [], >)
"""
import re
import time
from typing import Optional
from netmiko.huawei.huawei import HuaweiBase
class HuaweiTelnetCE(HuaweiBase):
"""
Custom Huawei device driver for GNS3 emulation.
Inherits from HuaweiBase to leverage existing VRP-specific functionality:
- system-view configuration mode
- Huawei prompt patterns
- Config mode detection and exit
This driver overrides telnet_login to handle GNS3 devices that
don't require authentication.
"""
def __init__(
self,
*args,
**kwargs,
) -> None:
"""Initialize HuaweiTelnetCE connection."""
# Set default device type for proper initialization
# The '_telnet' suffix in device_type tells Netmiko to use Telnet protocol
kwargs.setdefault("device_type", "huawei_telnet")
# Huawei prompt patterns (inherited from HuaweiBase)
# User view: <HUAWEI>
# System view: [HUAWEI]
# Interface view: [HUAWEI-GigabitEthernet0/0/1]
super().__init__(*args, **kwargs)
def telnet_login(
self,
pri_prompt_terminator: str = r"<\S+>|>\s*$",
alt_prompt_terminator: str = r"\[\S+\]",
username_pattern: str = r"(?:user:|username|login|user name)",
pwd_pattern: str = r"assword",
delay_factor: float = 1.0,
max_loops: int = 10,
) -> str:
"""
Telnet login for GNS3 Huawei devices (no authentication).
Simplified login logic for devices that connect directly to
command line without username/password prompts.
Strategy:
1. Clear any existing buffer data
2. Send carriage returns to trigger prompt
3. Wait for Huawei prompt pattern
4. Return once prompt is detected
Args:
pri_prompt_terminator: Primary prompt pattern (e.g., <HUAWEI>)
alt_prompt_terminator: Alternate prompt pattern (e.g., [HUAWEI])
username_pattern: Not used (kept for signature compatibility)
pwd_pattern: Not used (kept for signature compatibility)
delay_factor: Delay factor for timing
max_loops: Maximum wait loops
Returns:
Output from the connection process
"""
delay_factor = self.select_delay_factor(delay_factor)
output = ""
return_msg = ""
# Step 1: Clear buffer - read any existing data to avoid interference
try:
initial_data = self.read_channel()
if initial_data:
return_msg += initial_data
except Exception:
# Ignore errors during initial read
pass
# Step 2: Send carriage returns and wait for prompt
for i in range(max_loops):
try:
# Send return to trigger prompt
self.write_channel(self.RETURN)
time.sleep(0.5 * delay_factor)
# Read response
new_output = self.read_channel()
output = new_output
return_msg += new_output
# Check for Huawei prompt patterns
# User view: <HUAWEI>, System view: [HUAWEI], or just >
if re.search(pri_prompt_terminator, output, flags=re.M):
return return_msg
if re.search(alt_prompt_terminator, output, flags=re.M):
return return_msg
except EOFError:
self.remote_conn.close()
msg = f"Connection failed (EOF): {self.host}"
raise self.connection_error(msg) from None
except Exception:
# Continue trying on other exceptions
pass
# Step 3: Final attempt - return what we have
# Even if we couldn't detect the prompt clearly, the connection
# might still be usable
return return_msg
def session_preparation(self) -> None:
"""
Prepare the session after connection is established.
Inherited from HuaweiBase, but ensures proper initialization
for GNS3 emulation environment.
"""
# Wait for prompt to stabilize
time.sleep(0.5 * self.global_delay_factor)
# Disable paging using Huawei-specific command
try:
self.disable_paging(command="screen-length 0 temporary")
except Exception:
# If disable_paging fails, try the parent implementation
super().disable_paging()
# Ensure we're in a clean state
try:
if hasattr(self, 'base_prompt') and self.base_prompt:
self._test_channel_read(pattern=self.base_prompt)
else:
# If base_prompt is not set yet, just read to clear buffer
self.read_channel()
except Exception:
pass
def disable_paging(
self,
command: str = "screen-length 0 temporary",
**kwargs,
) -> str:
"""
Disable paging for Huawei devices.
Uses Huawei-specific command 'screen-length 0 temporary'
which disables paging for the current session only.
Args:
command: Command to disable paging
Returns:
Output from the disable paging command
"""
return super().disable_paging(command=command, **kwargs)
def send_config_set(
self,
config_commands: str | list[str],
**kwargs,
) -> str:
"""
Send configuration commands to Huawei device.
Overrides the parent method to handle Huawei-specific behavior:
- Uses Huawei-specific prompts (<...> for user view, [...] for system view)
- Handles the 'return' confirmation prompt
- Uses read_channel_timing for proper output collection
Args:
config_commands: Configuration commands to send
**kwargs: Additional arguments (exit_config_mode, read_timeout, etc.)
Returns:
Output from configuration commands
"""
# Convert string to list
if isinstance(config_commands, str):
config_commands = [config_commands]
# Get parameters with Huawei-specific defaults
exit_config_mode = kwargs.get("exit_config_mode", True)
read_timeout = kwargs.get("read_timeout", 30) # Longer timeout for GNS3 emulation
delay_factor = self.global_delay_factor
strip_prompt = kwargs.get("strip_prompt", False)
strip_command = kwargs.get("strip_command", False)
config_mode_command = kwargs.get("config_command", "system-view")
cmd_verify = kwargs.get("cmd_verify", False) # Disable cmd_verify for Huawei
output = ""
# Enter config mode if needed
if kwargs.get("enter_config_mode", True):
output += self.config_mode(config_command=config_mode_command)
# Send all configuration commands
# Use cmd_verify=False mode: send all commands, then read all output at once
for cmd in config_commands:
self.write_channel(f"{cmd}{self.RETURN}")
# Small delay between commands
time.sleep(delay_factor * 0.05)
# Use read_channel_timing to collect all output
# This method keeps reading until there is no new data for 'last_read' seconds
# This is the proper Netmiko way to handle command output
output += self.read_channel_timing(read_timeout=read_timeout, last_read=2.0)
# Exit config mode if requested
if exit_config_mode:
# For Huawei devices, commit configuration before exiting
# This avoids the "Uncommitted configurations" [Y/N/C] prompt
try:
# Send commit command (in system view [HUAWEI])
self.write_channel(f"commit{self.RETURN}")
time.sleep(0.5 * self.global_delay_factor)
commit_output = self.read_channel()
output += commit_output
except Exception:
# If commit fails, continue with exit (might not support commit)
pass
# Now exit config mode
output += self.exit_config_mode()
if strip_prompt:
output = self.strip_prompt(output)
if strip_command:
output = self.strip_command(config_commands, output)
return output
def exit_config_mode(
self, exit_config: str = "return", pattern: str = r"<\S+>|>\s*$"
) -> str:
r"""
Exit configuration mode for Huawei devices.
Huawei devices display a confirmation prompt when using 'return':
Return to user view? [y/n]:
This method automatically answers 'y' to the prompt.
Args:
exit_config: Command to exit config mode (default: "return")
pattern: Pattern to detect user view prompt (default: r"<\S+>|>\s*$")
Returns:
Output from exiting config mode
"""
# Check if we're currently in config mode
if not self.check_config_mode():
return ""
output = ""
# Send the exit command (write_channel returns None, don't concatenate)
self.write_channel(f"{exit_config}{self.RETURN}")
time.sleep(0.5 * self.global_delay_factor)
# Look for the confirmation prompt
# Huawei prompt: "Return to user view? [y/n]:"
prompt_pattern = r"\[y/n\]"
max_loops = 20 # More loops for slower devices
for _ in range(max_loops):
new_output = self.read_channel()
output += new_output
# If we see the confirmation prompt, send 'y'
if re.search(prompt_pattern, new_output):
self.write_channel(f"y{self.RETURN}") # Returns None
time.sleep(0.5 * self.global_delay_factor)
# Clear the confirmation response
new_output = self.read_channel()
output += new_output
# Check if we've exited to user view
if re.search(pattern, new_output):
return output
# Final read to get remaining output
new_output = self.read_channel()
output += new_output
return output
# Register the custom device type with Netmiko
def register_custom_device_type() -> None:
"""
Register the custom HuaweiTelnetCE device type with Netmiko.
This function adds 'huawei_telnet_ce' to both Netmiko's CLASS_MAPPER
and CLASS_MAPPER_BASE so it can be used like any other built-in device type.
Additionally, it updates the static 'platforms' and 'telnet_platforms' lists
which are used by ConnectHandler for device type validation.
IMPORTANT: This function should be called BEFORE initializing Nornir or
running any Netmiko tasks. Call it explicitly at the appropriate time.
Returns:
None
"""
# Use importlib to avoid namespace conflicts
import importlib
# Import the module using importlib to ensure we get the module, not a function
sd = importlib.import_module("netmiko.ssh_dispatcher")
# Add to both mappers
# CLASS_MAPPER_BASE is used for base class definitions
sd.CLASS_MAPPER_BASE["huawei_telnet_ce"] = HuaweiTelnetCE
sd.CLASS_MAPPER_BASE["huawei_ce"] = HuaweiTelnetCE
# CLASS_MAPPER is used by ConnectHandler for device type validation
sd.CLASS_MAPPER["huawei_telnet_ce"] = HuaweiTelnetCE
sd.CLASS_MAPPER["huawei_ce"] = HuaweiTelnetCE
# CRITICAL: Netmiko may also look for types with _telnet suffix
# Add this variant to ensure compatibility
sd.CLASS_MAPPER["huawei_telnet_ce_telnet"] = HuaweiTelnetCE
sd.CLASS_MAPPER_BASE["huawei_telnet_ce_telnet"] = HuaweiTelnetCE
# CRITICAL: Update the static platforms lists
# These lists are computed at module import time and won't automatically update
# when CLASS_MAPPER is modified. We need to manually rebuild them.
# Recalculate platforms list
sd.platforms = list(sd.CLASS_MAPPER.keys())
sd.platforms.sort()
# Recalculate platforms_base list
sd.platforms_base = list(sd.CLASS_MAPPER_BASE.keys())
sd.platforms_base.sort()
# Recalculate telnet_platforms list
sd.telnet_platforms = [x for x in sd.platforms if "telnet" in x]
# Rebuild the platform strings used in error messages
sd.platforms_str = "\n" + "\n".join(sd.platforms_base)
sd.telnet_platforms_str = "\n" + "\n".join(sd.telnet_platforms)
# Auto-register on import
# This ensures the device type is available when the module is imported
# NOTE: For Nornir scenarios, you may need to call this explicitly
# before InitNornir to ensure proper timing
try:
register_custom_device_type()
except Exception as e:
# Log but don't fail on import
import logging
logger = logging.getLogger(__name__)
logger.warning(f"Failed to register custom device type: {e}", exc_info=True)