YueGuobin b71b323fd4 feat(agent): improve tiktoken initialization and device registration
- Configure tiktoken cache directory to isolate encoding files
- Add logging and timing for tiktoken initialization process
- Make Huawei CE and Ruijie Telnet device registration idempotent
- Add duplicate registration prevention with global flags
- Add logging for device type registration status
- Update .gitignore to exclude tiktoken cache files
2026-03-15 21:25:14 +08:00

458 lines
16 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
#
# Mypy type checking is disabled for this module due to Netmiko library
# limitations.
#
# Reason: Netmiko does not provide type stubs (py.typed marker), which
# causes mypy to generate errors when importing and using Netmiko classes.
# The main issues are:
#
# 1. Missing library stubs for 'netmiko.huawei.huawei' module
# 2. Dynamic attribute assignments on netmiko.ssh_dispatcher module
# (platforms, platforms_base, telnet_platforms, etc.) that mypy cannot
# detect
# 3. Inheritance from HuaweiBase triggers 'import-untyped' errors
#
# Since Netmiko is a third-party library without type annotations, and this
# module is a runtime driver that extends Netmiko's functionality, type
# checking would require maintaining separate type stub files which is
# beyond the scope of this project.
#
# Alternative solutions considered:
# - Adding type stubs for Netmiko (too extensive, requires ongoing
# maintenance)
# - Using 'type: ignore' on specific lines (too many annotations needed)
# - Disabling only specific mypy errors (still noisy, doesn't address
# root cause)
#
# Solution: Disable mypy for this entire file. Runtime testing and
# integration tests ensure correctness. Netmiko's own test suite validates
# base functionality.
#
# mypy: ignore-errors
"""
Custom Netmiko device driver for Huawei devices in GNS3 emulation environment.
This module provides a custom device type 'gns3_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 importlib
import logging
import re
import time
from netmiko.huawei.huawei import HuaweiBase
logger = logging.getLogger(__name__)
class GNS3HuaweiTelnetCE(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 GNS3HuaweiTelnetCE 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)
# Longer timeout for GNS3 emulation
read_timeout = kwargs.get("read_timeout", 30)
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")
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
# 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
_registered = False # Flag to prevent duplicate registration
def register_custom_device_type() -> None:
"""
Register the custom GNS3HuaweiTelnetCE device type with Netmiko.
This function adds 'gns3_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.
Note: This function is idempotent - multiple calls will only register once.
Returns:
None
"""
global _registered
# Prevent duplicate registration
if _registered:
logger.debug("Huawei CE device type already registered, skipping")
return
# Use importlib to avoid namespace conflicts
# Import the module using importlib to ensure we get the module,
# not a function
sd = importlib.import_module("netmiko.ssh_dispatcher")
# Register the device type in both mappers
# CLASS_MAPPER_BASE is used for base class definitions
sd.CLASS_MAPPER_BASE["gns3_huawei_telnet_ce"] = GNS3HuaweiTelnetCE
# CLASS_MAPPER is used by ConnectHandler for device type validation
sd.CLASS_MAPPER["gns3_huawei_telnet_ce"] = GNS3HuaweiTelnetCE
# 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)
# Mark as registered
_registered = True
logger.info("Successfully registered Huawei CE device type with Netmiko")
# 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
logger = logging.getLogger(__name__)
logger.warning(
f"Failed to register custom device type: {e}",
exc_info=True
)