mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-11-04 22:41:12 +02:00
feat(copilot): add VPCS Telnet driver with Netmiko and ANSI code stripping
Implement custom VPCS driver and unified tool architecture:
- Add VPCSTelnet custom driver (vpcs_telnet.py)
- No authentication (direct console access like VPCS behavior)
- Simple prompt pattern matching (PC\d+>)
- Automatic ANSI escape code stripping for clean output
- Config mode methods return empty (VPCS has no config modes)
- Replace vpcs_tools_telnetlib3.py with vpcs_tools_netmiko.py
- Migrate from telnetlib3 to Netmiko + Nornir architecture
- Unified tool architecture matching config/display tools
- Improved code consistency and maintainability
- Add comprehensive test coverage (test_vpcs_telnet.py)
- 30 unit tests covering all VPCS driver functionality
- Tests for ANSI code stripping, telnet_login, send_command
- Tests for device registration and initialization
- Update VPCS built-in template (services/templates.py)
- Add platform:vpcs and device_type:gns3_vpcs_telnet tags
- Automatic driver selection without manual configuration
- Update documentation (docs/)
- multi-vendor-device-support.md: VPCS driver documentation
- netmiko_devices.md: Add VPCS to supported devices list
- README.md: Update multi-vendor support description
This commit is contained in:
1 parent
bbe57f34b9
commit
e2e2e23cf7
12 files changed
+1767
-521
No files matched your search
@@ -30,13 +30,13 @@ This package provides various tools for interacting with GNS3 network simulator:
|
||||
- Device configuration command execution
|
||||
- Display command execution
|
||||
- Multiple device command execution using Nornir
|
||||
- VPCS device configuration using telnetlib3
|
||||
- VPCS device configuration using Netmiko
|
||||
- Node and link management
|
||||
|
||||
Main modules:
|
||||
- config_tools_nornir: Multiple device configuration command execution tool using Nornir
|
||||
- display_tools_nornir: Multiple device command execution tool using Nornir
|
||||
- vpcs_tools_telnetlib3: VPCS device configuration tool using telnetlib3
|
||||
- vpcs_tools_netmiko: VPCS device configuration tool using Netmiko
|
||||
- gns3_create_node: GNS3 node creation tool
|
||||
- gns3_create_link: GNS3 link creation tool
|
||||
- gns3_start_node: GNS3 node startup tool
|
||||
@@ -59,7 +59,6 @@ from .gns3_start_node import GNS3StartNodeTool
|
||||
from .gns3_stop_node import GNS3StopNodeTool
|
||||
from .gns3_suspend_node import GNS3SuspendNodeTool
|
||||
from .gns3_update_node_name import GNS3UpdateNodeNameTool
|
||||
from .vpcs_tools_telnetlib3 import VPCSMultiCommands
|
||||
|
||||
# Dynamic version management
|
||||
try:
|
||||
@@ -77,7 +76,6 @@ __url__ = "https://github.com/yueguobin/gns3-copilot"
|
||||
__all__ = [
|
||||
"ExecuteMultipleDeviceConfigCommands",
|
||||
"ExecuteMultipleDeviceCommands",
|
||||
"VPCSMultiCommands",
|
||||
"GNS3CreateNodeTool",
|
||||
"GNS3LinkTool",
|
||||
"GNS3StartNodeTool",
|
||||
|
||||
@@ -0,0 +1,611 @@
|
||||
# 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
|
||||
#
|
||||
|
||||
"""
|
||||
This module provides a tool to execute commands on VPCS devices
|
||||
in a GNS3 topology using Nornir with Netmiko.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from langchain.tools import BaseTool
|
||||
from langchain_core.callbacks import CallbackManagerForToolRun
|
||||
from netmiko.exceptions import ReadTimeout
|
||||
from nornir import InitNornir
|
||||
from nornir.core.task import AggregatedResult
|
||||
from nornir.core.task import Result
|
||||
from nornir.core.task import Task
|
||||
from nornir_netmiko.tasks import netmiko_multiline
|
||||
|
||||
from gns3server.agent.gns3_copilot.gns3_client import get_gns3_server_host
|
||||
from gns3server.agent.gns3_copilot.utils import get_device_ports_from_topology
|
||||
|
||||
# Import custom Netmiko device types for GNS3 emulation
|
||||
# This registers gns3_vpcs_telnet and other custom device types
|
||||
# NOTE: Must be imported BEFORE any Nornir operations to ensure device types are registered
|
||||
from gns3server.agent.gns3_copilot.utils import custom_netmiko # noqa: F401
|
||||
|
||||
# Explicitly register VPCS device type to ensure it is available
|
||||
try:
|
||||
from gns3server.agent.gns3_copilot.utils.custom_netmiko.vpcs_telnet import (
|
||||
register_custom_device_type as register_vpcs_device_type,
|
||||
)
|
||||
|
||||
# Register VPCS device type
|
||||
register_vpcs_device_type()
|
||||
|
||||
# CRITICAL: Update netmiko.ssh_dispatcher platforms lists
|
||||
import importlib
|
||||
|
||||
sd = importlib.import_module("netmiko.ssh_dispatcher")
|
||||
|
||||
# Recalculate platforms lists to include custom device types
|
||||
sd.platforms = list(sd.CLASS_MAPPER.keys())
|
||||
sd.platforms.sort()
|
||||
|
||||
sd.platforms_base = list(sd.CLASS_MAPPER_BASE.keys())
|
||||
sd.platforms_base.sort()
|
||||
|
||||
sd.telnet_platforms = [x for x in sd.platforms if "telnet" in x]
|
||||
|
||||
# Update 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)
|
||||
except Exception:
|
||||
# Fail silently - the import-time registration should have worked
|
||||
pass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Suppress nornir INFO logs in console (reduce verbosity)
|
||||
logging.getLogger("nornir.core").setLevel(logging.WARNING)
|
||||
logging.getLogger("nornir").setLevel(logging.WARNING)
|
||||
|
||||
|
||||
def _get_nornir_defaults() -> dict[str, Any]:
|
||||
"""Get Nornir default configuration."""
|
||||
return {"data": {"location": "gns3"}}
|
||||
|
||||
|
||||
class VPCSCommands(BaseTool):
|
||||
"""
|
||||
A tool for VPCS (Virtual PC Simulator) devices.
|
||||
|
||||
**VPCS-SPECIFIC TOOL** - This tool ONLY works with VPCS virtual PC devices.
|
||||
|
||||
**IMPORTANT DISTINCTION:**
|
||||
Unlike network devices (routers/switches), VPCS devices are lightweight virtual PCs.
|
||||
Commands like 'ip' are basic PC IP config, NOT network device config.
|
||||
|
||||
**Allowed VPCS Commands:**
|
||||
- IP configuration: ip <address>/<mask> <gateway>
|
||||
- View configuration: ip, show ip
|
||||
- Connectivity testing: ping <destination>
|
||||
- Display ARP: arp
|
||||
- Display version: version
|
||||
- Save/Load: save, load
|
||||
"""
|
||||
|
||||
name: str = "execute_vpcs_commands"
|
||||
description: str = """
|
||||
**VPCS VIRTUAL PC TOOL** - Configure and test Virtual PC Simulator devices.
|
||||
|
||||
This tool ONLY works with VPCS devices, NOT routers/switches.
|
||||
|
||||
**IMPORTANT: VPCS vs Network Devices**
|
||||
- VPCS = Lightweight virtual PCs for lab testing (NOT network infra)
|
||||
- 'ip' command on VPCS = Basic PC IP config (like 'ipconfig' on Windows)
|
||||
- NOT the same as configuring router interfaces or routing protocols
|
||||
|
||||
**When to Use This Tool:**
|
||||
- Configure IP addresses on virtual PCs: ip 10.10.0.12/24 10.10.0.254
|
||||
- Test connectivity from PCs: ping 10.10.0.254
|
||||
- View PC IP configuration: ip, show ip
|
||||
- Display ARP table: arp
|
||||
- Check PC version: version
|
||||
|
||||
**Input Format:**
|
||||
{
|
||||
"project_id": "<PROJECT_UUID>",
|
||||
"device_configs": [
|
||||
{
|
||||
"device_name": "PC1",
|
||||
"commands": ["ip", "ping 10.10.0.254"]
|
||||
},
|
||||
{
|
||||
"device_name": "PC2",
|
||||
"commands": ["show ip"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
**Returns:** PC command outputs for IP config and connectivity testing.
|
||||
|
||||
**Note:** For network devices, use execute_multiple_device_commands.
|
||||
"""
|
||||
|
||||
def _run(
|
||||
self,
|
||||
tool_input: str | bytes | list[Any] | dict[str, Any],
|
||||
run_manager: CallbackManagerForToolRun | None = None,
|
||||
**kwargs: Any,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Execute commands on multiple VPCS devices in GNS3 topology.
|
||||
|
||||
Args:
|
||||
tool_input: JSON string with project_id and VPCS commands.
|
||||
|
||||
Returns:
|
||||
List of dicts with device names and command outputs.
|
||||
"""
|
||||
# Log received input
|
||||
logger.debug("Received input: %s", tool_input)
|
||||
|
||||
# Validate input
|
||||
device_configs_list, project_id = self._validate_tool_input(tool_input)
|
||||
if (
|
||||
isinstance(device_configs_list, list)
|
||||
and len(device_configs_list) > 0
|
||||
and "error" in device_configs_list[0]
|
||||
):
|
||||
return device_configs_list
|
||||
|
||||
# Create a mapping of device names to their commands
|
||||
device_configs_map = self._configs_map(device_configs_list)
|
||||
|
||||
# Prepare device hosts data
|
||||
try:
|
||||
hosts_data = self._prepare_device_hosts_data(
|
||||
device_configs_list, project_id
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.error("Failed to prepare device hosts data: %s", e)
|
||||
return [{"error": str(e)}]
|
||||
|
||||
# Check if any devices have errors (e.g., missing device)
|
||||
error_devices = {
|
||||
name: data
|
||||
for name, data in hosts_data.items()
|
||||
if "error" in data
|
||||
}
|
||||
if error_devices:
|
||||
logger.error(
|
||||
"Devices with configuration errors: %s",
|
||||
list(error_devices.keys())
|
||||
)
|
||||
return [
|
||||
{
|
||||
"device_name": name,
|
||||
"status": "failed",
|
||||
"error": data["error"]
|
||||
}
|
||||
for name, data in error_devices.items()
|
||||
]
|
||||
|
||||
# Initialize Nornir
|
||||
try:
|
||||
dynamic_nr = self._initialize_nornir(hosts_data)
|
||||
except ValueError as e:
|
||||
logger.error("Failed to initialize Nornir: %s", e)
|
||||
return [{"error": str(e)}]
|
||||
|
||||
results = []
|
||||
|
||||
# Execute all devices concurrently in a single run
|
||||
try:
|
||||
task_result = dynamic_nr.run(
|
||||
task=self._run_vpcs_commands,
|
||||
device_configs_map=device_configs_map,
|
||||
)
|
||||
|
||||
# Process results for all devices
|
||||
results = self._process_task_results(
|
||||
device_configs_list,
|
||||
hosts_data,
|
||||
task_result,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# Overall execution failed
|
||||
logger.error("Error executing commands on all VPCS devices: %s", e)
|
||||
return [{"error": f"Execution error: {str(e)}"}]
|
||||
|
||||
logger.debug(
|
||||
"VPCS command execution completed. Results: %s",
|
||||
json.dumps(results, indent=2, ensure_ascii=False),
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
def _run_vpcs_commands(
|
||||
self, task: Task, device_configs_map: dict[str, list[str]]
|
||||
) -> Result:
|
||||
"""Execute VPCS commands with single retry."""
|
||||
device_name = task.host.name
|
||||
commands = device_configs_map.get(device_name, [])
|
||||
|
||||
if not commands:
|
||||
return Result(
|
||||
host=task.host, result="No commands to execute"
|
||||
)
|
||||
|
||||
try:
|
||||
# Use netmiko_multiline for VPCS commands
|
||||
# This works well with simple commands and ping output
|
||||
_result = task.run(
|
||||
task=netmiko_multiline,
|
||||
commands=commands,
|
||||
read_timeout=30,
|
||||
)
|
||||
return Result(host=task.host, result=_result.result)
|
||||
|
||||
except ReadTimeout as e:
|
||||
logger.error(
|
||||
"ReadTimeout occurred for VPCS device %s: %s",
|
||||
device_name,
|
||||
str(e),
|
||||
)
|
||||
return Result(
|
||||
host=task.host,
|
||||
result=f"Command failed (ReadTimeout): {str(e)}",
|
||||
failed=True,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# Retry once for transient failures
|
||||
logger.warning(
|
||||
"First attempt failed for VPCS device %s, retrying: %s",
|
||||
device_name,
|
||||
str(e),
|
||||
)
|
||||
try:
|
||||
_result = task.run(
|
||||
task=netmiko_multiline,
|
||||
commands=commands,
|
||||
read_timeout=30,
|
||||
)
|
||||
return Result(host=task.host, result=_result.result)
|
||||
except Exception as retry_e:
|
||||
logger.error(
|
||||
"VPCS command failed for device %s: %s (Exception: %s)",
|
||||
device_name,
|
||||
str(retry_e),
|
||||
type(retry_e).__name__,
|
||||
)
|
||||
return Result(
|
||||
host=task.host,
|
||||
result=f"Command failed: {str(retry_e)}",
|
||||
failed=True,
|
||||
)
|
||||
|
||||
def _validate_tool_input(
|
||||
self, tool_input: str | bytes | list[Any] | dict[str, Any]
|
||||
) -> tuple[list[dict[str, Any]], str | None]:
|
||||
"""
|
||||
Validate VPCS command input.
|
||||
|
||||
Args:
|
||||
tool_input: Input from LangChain/LangGraph tool call.
|
||||
|
||||
Returns:
|
||||
Tuple of (device_configs_list, project_id) or (error_list, None)
|
||||
"""
|
||||
parsed_input = None
|
||||
|
||||
# Compatibility Check and Parsing
|
||||
if isinstance(tool_input, (str, bytes, bytearray)):
|
||||
try:
|
||||
parsed_input = json.loads(tool_input)
|
||||
logger.info("Successfully parsed tool input from JSON string.")
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(
|
||||
"Invalid JSON string received as tool input: %s", e
|
||||
)
|
||||
return (
|
||||
[{"error": f"Invalid JSON string input from model: {e}"}],
|
||||
None,
|
||||
)
|
||||
else:
|
||||
parsed_input = tool_input
|
||||
logger.info(
|
||||
"Using tool input directly as type: %s",
|
||||
type(parsed_input).__name__,
|
||||
)
|
||||
|
||||
# Handle new format: {"project_id": "...", "device_configs": [...]}
|
||||
if isinstance(parsed_input, dict):
|
||||
project_id = parsed_input.get("project_id")
|
||||
device_configs = parsed_input.get("device_configs")
|
||||
|
||||
# Validate project_id
|
||||
if not project_id:
|
||||
error_msg = "Missing required 'project_id' field in input"
|
||||
logger.error(error_msg)
|
||||
return ([{"error": error_msg}], None)
|
||||
|
||||
if not self._validate_project_id(project_id):
|
||||
error_msg = f"Invalid project_id: {project_id}. Expected UUID."
|
||||
logger.error(error_msg)
|
||||
return ([{"error": error_msg}], None)
|
||||
|
||||
# Validate device_configs
|
||||
if not isinstance(device_configs, list):
|
||||
error_msg = "'device_configs' must be an array"
|
||||
logger.error(error_msg)
|
||||
return ([{"error": error_msg}], None)
|
||||
|
||||
if not device_configs:
|
||||
logger.warning("Device configs list is empty.")
|
||||
return [], project_id
|
||||
|
||||
return device_configs, project_id
|
||||
|
||||
else:
|
||||
error_msg = (
|
||||
"Tool input must be JSON with project_id and device_configs, "
|
||||
f"got {type(parsed_input).__name__}"
|
||||
)
|
||||
logger.error(error_msg)
|
||||
return ([{"error": error_msg}], None)
|
||||
|
||||
def _validate_project_id(self, project_id: str) -> bool:
|
||||
"""
|
||||
Validate project_id format (UUID).
|
||||
|
||||
Args:
|
||||
project_id: The project ID to validate
|
||||
|
||||
Returns:
|
||||
True if valid UUID format, False otherwise
|
||||
"""
|
||||
uuid_pattern = (
|
||||
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
|
||||
)
|
||||
return bool(re.match(uuid_pattern, project_id, re.IGNORECASE))
|
||||
|
||||
def _configs_map(
|
||||
self, device_config_list: list[dict[str, Any]]
|
||||
) -> dict[str, list[str]]:
|
||||
"""
|
||||
Create a mapping of device names to their command lists.
|
||||
|
||||
Args:
|
||||
device_config_list: List of device configurations
|
||||
|
||||
Returns:
|
||||
Dictionary mapping device names to command lists
|
||||
"""
|
||||
return {
|
||||
config["device_name"]: config["commands"]
|
||||
for config in device_config_list
|
||||
}
|
||||
|
||||
def _prepare_device_hosts_data(
|
||||
self, device_configs_list: list[dict[str, Any]], project_id: str
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""
|
||||
Prepare Nornir inventory hosts data for VPCS devices.
|
||||
|
||||
Args:
|
||||
device_configs_list: List of device configurations
|
||||
project_id: GNS3 project ID
|
||||
|
||||
Returns:
|
||||
Dictionary mapping device names to their host data
|
||||
|
||||
Raises:
|
||||
ValueError: If device not found in topology or missing port
|
||||
"""
|
||||
# Get GNS3 server host
|
||||
gns3_host = get_gns3_server_host()
|
||||
|
||||
# Extract all device names from input
|
||||
device_names = [config["device_name"] for config in device_configs_list]
|
||||
|
||||
# Get device port mappings from topology
|
||||
device_ports = get_device_ports_from_topology(
|
||||
device_names, project_id=project_id
|
||||
)
|
||||
|
||||
# Build Nornir inventory hosts data
|
||||
hosts_data = {}
|
||||
for device_name in device_names:
|
||||
if device_name not in device_ports:
|
||||
logger.error("Device '%s' not found in topology", device_name)
|
||||
hosts_data[device_name] = {
|
||||
"error": f"Device '{device_name}' not found in topology"
|
||||
}
|
||||
continue
|
||||
|
||||
port = device_ports[device_name]["port"]
|
||||
|
||||
# VPCS devices use gns3_vpcs_telnet device type
|
||||
hosts_data[device_name] = {
|
||||
"port": port,
|
||||
"platform": "vpcs",
|
||||
"groups": ["vpcs_devices"], # All VPCS devices share one group
|
||||
"connection_options": {
|
||||
"netmiko": {
|
||||
"extras": {
|
||||
"device_type": "gns3_vpcs_telnet",
|
||||
"fast_cli": False,
|
||||
"global_delay_factor": 2.0,
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
return hosts_data
|
||||
|
||||
def _initialize_nornir(
|
||||
self, hosts_data: dict[str, dict[str, Any]]
|
||||
) -> "Nornir":
|
||||
"""
|
||||
Initialize Nornir with VPCS device inventory.
|
||||
|
||||
Args:
|
||||
hosts_data: Dictionary of device host data
|
||||
|
||||
Returns:
|
||||
Initialized Nornir instance
|
||||
|
||||
Raises:
|
||||
ValueError: If initialization fails
|
||||
"""
|
||||
try:
|
||||
defaults = _get_nornir_defaults()
|
||||
gns3_host = get_gns3_server_host()
|
||||
|
||||
# Create a single generic group for shared configuration
|
||||
groups_data = {
|
||||
"vpcs_devices": {
|
||||
"hostname": gns3_host,
|
||||
"timeout": 30,
|
||||
"username": "", # VPCS doesn't require authentication
|
||||
"password": "", # VPCS doesn't require authentication
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
"Initializing Nornir for VPCS: host=%s, devices=%d",
|
||||
gns3_host,
|
||||
len(hosts_data),
|
||||
)
|
||||
|
||||
return InitNornir(
|
||||
inventory={
|
||||
"plugin": "DictInventory",
|
||||
"options": {
|
||||
"hosts": hosts_data,
|
||||
"groups": groups_data,
|
||||
"defaults": defaults,
|
||||
},
|
||||
},
|
||||
runner={
|
||||
"plugin": "threaded",
|
||||
"options": {"num_workers": 10},
|
||||
},
|
||||
logging={"enabled": False},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to initialize Nornir: %s", e)
|
||||
raise ValueError(f"Failed to initialize Nornir: {e}") from e
|
||||
|
||||
def _process_task_results(
|
||||
self,
|
||||
device_configs_list: list[dict[str, Any]],
|
||||
hosts_data: dict[str, dict[str, Any]],
|
||||
task_result: AggregatedResult,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Process Nornir task results into the format expected by the tool.
|
||||
|
||||
Args:
|
||||
device_configs_list: Original device configurations list
|
||||
hosts_data: Device hosts data
|
||||
task_result: Nornir aggregated task result
|
||||
|
||||
Returns:
|
||||
List of result dictionaries
|
||||
"""
|
||||
results = []
|
||||
|
||||
for device_config in device_configs_list:
|
||||
device_name = device_config["device_name"]
|
||||
|
||||
# Check if device had an error during preparation
|
||||
if device_name in hosts_data and "error" in hosts_data[device_name]:
|
||||
results.append({
|
||||
"device_name": device_name,
|
||||
"status": "error",
|
||||
"output": hosts_data[device_name]["error"],
|
||||
"commands": device_config["commands"],
|
||||
})
|
||||
continue
|
||||
|
||||
# Get result from Nornir task
|
||||
if device_name in task_result:
|
||||
host_result = task_result[device_name]
|
||||
|
||||
if host_result.failed:
|
||||
# Task failed
|
||||
error_msg = str(host_result.result) if host_result.result else "Unknown error"
|
||||
results.append({
|
||||
"device_name": device_name,
|
||||
"status": "error",
|
||||
"output": error_msg,
|
||||
"commands": device_config["commands"],
|
||||
})
|
||||
else:
|
||||
# Task succeeded
|
||||
results.append({
|
||||
"device_name": device_name,
|
||||
"status": "success",
|
||||
"output": host_result.result,
|
||||
"commands": device_config["commands"],
|
||||
})
|
||||
else:
|
||||
# Device not in task result (shouldn't happen)
|
||||
results.append({
|
||||
"device_name": device_name,
|
||||
"status": "error",
|
||||
"output": f"Device '{device_name}' not in task results",
|
||||
"commands": device_config["commands"],
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Example usage
|
||||
import sys
|
||||
|
||||
command_groups = json.dumps(
|
||||
{
|
||||
"project_id": "<PROJECT_UUID>",
|
||||
"device_configs": [
|
||||
{
|
||||
"device_name": "PC1",
|
||||
"commands": [
|
||||
"ip 10.10.0.12/24 10.10.0.254",
|
||||
"ping 10.10.0.254",
|
||||
],
|
||||
},
|
||||
{
|
||||
"device_name": "PC2",
|
||||
"commands": ["ip 10.10.0.13/24 10.10.0.254"],
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
exe_cmd = VPCSCommands()
|
||||
result = exe_cmd._run(tool_input=command_groups)
|
||||
print("Execution results:")
|
||||
print(json.dumps(result, indent=2, ensure_ascii=False))
|
||||
@@ -1,489 +0,0 @@
|
||||
# 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
|
||||
#
|
||||
"""
|
||||
|
||||
Multi-device VPCS command execution tool using telnetlib3 with threading.
|
||||
Supports concurrent command execution across VPCS devices.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import threading
|
||||
from time import sleep
|
||||
from typing import Any
|
||||
|
||||
from langchain.tools import BaseTool
|
||||
from langchain_core.callbacks import CallbackManagerForToolRun
|
||||
from telnetlib3 import Telnet
|
||||
|
||||
from gns3server.agent.gns3_copilot.gns3_client import get_gns3_server_host
|
||||
from gns3server.agent.gns3_copilot.utils import get_device_ports_from_topology
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VPCSMultiCommands(BaseTool):
|
||||
"""
|
||||
A tool for VPCS devices to view PC configs and test connectivity.
|
||||
|
||||
**VPCS-SPECIFIC TOOL** - This tool ONLY works with VPCS virtual PC devices.
|
||||
|
||||
**IMPORTANT DISTINCTION:**
|
||||
Unlike network devices (routers/switches), VPCS devices are lightweight virtual PCs.
|
||||
Commands like 'ip' are basic PC IP config, NOT network device config.
|
||||
|
||||
**Allowed VPCS Commands:**
|
||||
- IP configuration: ip <address>/<mask> <gateway> (Basic PC IP setup)
|
||||
- View configuration: ip, show ip
|
||||
- Connectivity testing: ping <destination>
|
||||
- Display ARP: arp
|
||||
- Display version: version
|
||||
- Save/Load: save, load
|
||||
|
||||
**Usage Context:**
|
||||
This tool is used in lab environments where students need to configure virtual PC IP
|
||||
addresses and test network connectivity. It does NOT configure network infra.
|
||||
"""
|
||||
|
||||
name: str = "execute_vpcs_multi_commands"
|
||||
description: str = """
|
||||
**VPCS VIRTUAL PC TOOL** - Configure and test Virtual PC Simulator devices.
|
||||
|
||||
This tool ONLY works with VPCS devices, NOT routers/switches.
|
||||
|
||||
**IMPORTANT: VPCS vs Network Devices**
|
||||
- VPCS = Lightweight virtual PCs for lab testing (NOT network infra)
|
||||
- 'ip' command on VPCS = Basic PC IP config (like 'ipconfig' on Windows)
|
||||
- NOT the same as configuring router interfaces or routing protocols
|
||||
|
||||
**When to Use This Tool:**
|
||||
- Configure IP addresses on virtual PCs: ip 10.10.0.12/24 10.10.0.254
|
||||
- Test connectivity from PCs: ping 10.10.0.254
|
||||
- View PC IP configuration: ip, show ip
|
||||
- Display ARP table: arp
|
||||
- Check PC version: version
|
||||
|
||||
**Input Format:**
|
||||
{
|
||||
"project_id": "<PROJECT_UUID>",
|
||||
"device_configs": [
|
||||
{
|
||||
"device_name": "PC1",
|
||||
"commands": ["ip", "ping 10.10.0.254"]
|
||||
},
|
||||
{
|
||||
"device_name": "PC2",
|
||||
"commands": ["show ip"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
**Returns:** PC command outputs for IP config and connectivity testing.
|
||||
|
||||
**Note:** For network devices, use execute_multiple_device_commands.
|
||||
"""
|
||||
|
||||
def _connect_and_execute_commands(
|
||||
self,
|
||||
device_name: str,
|
||||
commands: list[str],
|
||||
results_list: list[Any],
|
||||
index: int,
|
||||
device_ports: dict[str, Any],
|
||||
gns3_host: str,
|
||||
) -> None:
|
||||
"""
|
||||
Internal method to connect to VPCS device and execute commands.
|
||||
|
||||
VPCS devices are lightweight virtual PCs used in GNS3 labs for testing
|
||||
network connectivity and basic IP configuration.
|
||||
"""
|
||||
|
||||
logger.info(
|
||||
"Starting connection for device '%s' with %d commands",
|
||||
device_name,
|
||||
len(commands),
|
||||
)
|
||||
|
||||
# Check if device has port information
|
||||
if device_name not in device_ports:
|
||||
logger.warning(
|
||||
"Device '%s' not found in topology or missing console port",
|
||||
device_name,
|
||||
)
|
||||
results_list[index] = {
|
||||
"device_name": device_name,
|
||||
"status": "error",
|
||||
"output": (
|
||||
f"Device '{device_name}' not found in topology "
|
||||
"or missing console port"
|
||||
),
|
||||
"commands": commands,
|
||||
}
|
||||
return
|
||||
|
||||
port = device_ports[device_name]["port"]
|
||||
host = gns3_host
|
||||
|
||||
logger.info(
|
||||
"Connecting to device '%s' at %s:%d",
|
||||
device_name,
|
||||
host,
|
||||
port,
|
||||
)
|
||||
|
||||
tn = Telnet()
|
||||
try:
|
||||
tn.open(host=host, port=port, timeout=30)
|
||||
logger.info(
|
||||
"Successfully connected to device '%s' at %s:%d",
|
||||
device_name,
|
||||
host,
|
||||
port,
|
||||
)
|
||||
|
||||
# Initialize connection
|
||||
tn.write(b"\n")
|
||||
sleep(0.5)
|
||||
tn.write(b"\n")
|
||||
sleep(0.5)
|
||||
tn.write(b"\n")
|
||||
sleep(0.5)
|
||||
tn.write(b"\n")
|
||||
sleep(0.5)
|
||||
tn.expect([rb"PC\d+>"])
|
||||
logger.info("Connection initialized for device '%s'", device_name)
|
||||
|
||||
# Execute all commands and merge output
|
||||
combined_output = ""
|
||||
for i, command in enumerate(commands):
|
||||
logger.info(
|
||||
"Executing command %d/%d on device '%s': %s",
|
||||
i + 1,
|
||||
len(commands),
|
||||
device_name,
|
||||
command,
|
||||
)
|
||||
tn.write(command.encode(encoding="ascii") + b"\n")
|
||||
sleep(5)
|
||||
tn.expect([rb"PC\d+>"])
|
||||
output = tn.read_very_eager().decode("utf-8")
|
||||
combined_output += output
|
||||
|
||||
# Add result to list
|
||||
results_list[index] = {
|
||||
"device_name": device_name,
|
||||
"status": "success",
|
||||
"output": combined_output,
|
||||
"commands": commands,
|
||||
}
|
||||
logger.info(
|
||||
"Successfully executed all %d commands on device '%s'",
|
||||
len(commands),
|
||||
device_name,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error executing commands on device '%s': %s",
|
||||
device_name,
|
||||
str(e),
|
||||
)
|
||||
results_list[index] = {
|
||||
"device_name": device_name,
|
||||
"status": "error",
|
||||
"output": str(e),
|
||||
"commands": commands,
|
||||
}
|
||||
finally:
|
||||
tn.close()
|
||||
logger.debug("Connection closed for device '%s'", device_name)
|
||||
|
||||
def _validate_project_id(self, project_id: str) -> bool:
|
||||
"""
|
||||
Validate project_id format (UUID).
|
||||
|
||||
Args:
|
||||
project_id: The project ID to validate
|
||||
|
||||
Returns:
|
||||
True if valid UUID format, False otherwise
|
||||
"""
|
||||
uuid_pattern = (
|
||||
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
|
||||
)
|
||||
is_valid = bool(re.match(uuid_pattern, project_id, re.IGNORECASE))
|
||||
if not is_valid:
|
||||
logger.warning(
|
||||
"project_id '%s' is not a valid UUID format", project_id
|
||||
)
|
||||
return is_valid
|
||||
|
||||
def _validate_tool_input(
|
||||
self, tool_input: str | bytes | list[Any] | dict[str, Any]
|
||||
) -> tuple[list[dict[str, Any]], str]:
|
||||
"""
|
||||
Validate device command input and extract project_id and device_configs.
|
||||
|
||||
Args:
|
||||
tool_input: Input from the LangChain/LangGraph tool call.
|
||||
|
||||
Returns:
|
||||
Tuple of (device_configs_list, project_id) or (error_list, "")
|
||||
"""
|
||||
|
||||
parsed_input = None
|
||||
|
||||
# Compatibility Check and Parsing ---
|
||||
# Check if the input is a string (or bytes) which needs to be parsed.
|
||||
if isinstance(tool_input, (str, bytes, bytearray)):
|
||||
# Handle models that return a raw JSON string.
|
||||
try:
|
||||
parsed_input = json.loads(tool_input)
|
||||
logger.info("Successfully parsed tool input from JSON string.")
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(
|
||||
"Invalid JSON string received as tool input: %s", e
|
||||
)
|
||||
return ([{"error": f"Invalid JSON input: {e}"}], "")
|
||||
else:
|
||||
# Handle standard models where the framework has already parsed the JSON.
|
||||
parsed_input = tool_input
|
||||
logger.info(
|
||||
"Using tool input directly as type: %s",
|
||||
type(parsed_input).__name__,
|
||||
)
|
||||
|
||||
# Validate input is a dictionary
|
||||
if not isinstance(parsed_input, dict):
|
||||
error_msg = (
|
||||
"Tool input must be a JSON object containing 'project_id' "
|
||||
f"and 'device_configs', but got {type(parsed_input).__name__}"
|
||||
)
|
||||
logger.error(error_msg)
|
||||
return ([{"error": error_msg}], "")
|
||||
|
||||
# Extract and validate project_id
|
||||
project_id = parsed_input.get("project_id")
|
||||
if not project_id:
|
||||
error_msg = "Missing required field 'project_id' in input"
|
||||
logger.error(error_msg)
|
||||
return ([{"error": error_msg}], "")
|
||||
|
||||
# Validate project_id format
|
||||
if not self._validate_project_id(project_id):
|
||||
error_msg = f"Invalid project_id format: {project_id}. Expected UUID."
|
||||
logger.error(error_msg)
|
||||
return ([{"error": error_msg}], "")
|
||||
|
||||
# Extract and validate device_configs
|
||||
device_configs = parsed_input.get("device_configs")
|
||||
if device_configs is None:
|
||||
error_msg = "Missing required field 'device_configs' in input"
|
||||
logger.error(error_msg)
|
||||
return ([{"error": error_msg}], "")
|
||||
|
||||
# Validate device_configs is a list
|
||||
if not isinstance(device_configs, list):
|
||||
error_msg = (
|
||||
f"'device_configs' must be a list, "
|
||||
f"but got {type(device_configs).__name__}"
|
||||
)
|
||||
logger.error(error_msg)
|
||||
return ([{"error": error_msg}], "")
|
||||
|
||||
# Handle empty list
|
||||
if not device_configs:
|
||||
logger.warning("Device configs list is empty.")
|
||||
return [], ""
|
||||
|
||||
# Validate each item in device_configs
|
||||
for i, item in enumerate(device_configs):
|
||||
if not isinstance(item, dict):
|
||||
error_msg = (
|
||||
f"Item at index {i} must be a dictionary, "
|
||||
f"got {type(item).__name__}"
|
||||
)
|
||||
logger.error(error_msg)
|
||||
return ([{"error": error_msg}], "")
|
||||
|
||||
# Validate required fields in each device config
|
||||
if "device_name" not in item:
|
||||
error_msg = (
|
||||
f"Item at index {i} missing required field 'device_name'"
|
||||
)
|
||||
logger.error(error_msg)
|
||||
return ([{"error": error_msg}], "")
|
||||
|
||||
if "commands" not in item:
|
||||
error_msg = (
|
||||
f"Item at index {i} missing required field 'commands'"
|
||||
)
|
||||
logger.error(error_msg)
|
||||
return ([{"error": error_msg}], "")
|
||||
|
||||
if not isinstance(item["commands"], list):
|
||||
error_msg = (
|
||||
f"'commands' in item at index {i} must be a list, "
|
||||
f"but got {type(item['commands']).__name__}"
|
||||
)
|
||||
logger.error(error_msg)
|
||||
return ([{"error": error_msg}], "")
|
||||
|
||||
logger.info(
|
||||
"Input validated successfully. project_id=%s, device_configs_count=%d",
|
||||
project_id,
|
||||
len(device_configs),
|
||||
)
|
||||
return device_configs, project_id
|
||||
|
||||
def _run(
|
||||
self,
|
||||
tool_input: str,
|
||||
run_manager: CallbackManagerForToolRun | None = None,
|
||||
**kwargs: Any,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Execute commands on multiple VPCS virtual PC devices concurrently.
|
||||
|
||||
VPCS devices are lightweight virtual machines that simulate basic PC network
|
||||
functionality for lab testing. This is NOT network device configuration.
|
||||
|
||||
Args:
|
||||
tool_input: JSON string containing project_id and device_configs with
|
||||
VPCS commands
|
||||
|
||||
Returns:
|
||||
List of execution results for each VPCS device
|
||||
"""
|
||||
|
||||
# Log received input
|
||||
logger.info("Received input: %s", tool_input)
|
||||
|
||||
# Validate tool input and extract project_id and device_configs
|
||||
device_configs, project_id = self._validate_tool_input(tool_input)
|
||||
|
||||
# Check if validation returned an error
|
||||
if (
|
||||
isinstance(device_configs, list)
|
||||
and len(device_configs) > 0
|
||||
and "error" in device_configs[0]
|
||||
):
|
||||
return device_configs
|
||||
|
||||
# Extract all device names from input using set comprehension
|
||||
device_names = {config["device_name"] for config in device_configs}
|
||||
|
||||
# Get device port mapping with project_id
|
||||
device_ports = get_device_ports_from_topology(
|
||||
list(device_names), project_id=project_id
|
||||
)
|
||||
logger.info(
|
||||
"Retrieved port mappings for %d devices: %s",
|
||||
len(device_ports),
|
||||
list(device_ports.keys()),
|
||||
)
|
||||
|
||||
# Get GNS3 server host from connector factory
|
||||
gns3_host = get_gns3_server_host()
|
||||
logger.info("Using GNS3 server host: %s", gns3_host)
|
||||
|
||||
# Initialize results list (pre-allocate space for concurrent writes)
|
||||
results: list[dict[str, Any]] = [{} for _ in range(len(device_configs))]
|
||||
threads = []
|
||||
|
||||
# Create thread for each command group
|
||||
logger.info("Starting parallel execution for %d devices", len(device_configs))
|
||||
for i, cmd_group in enumerate(device_configs):
|
||||
thread = threading.Thread(
|
||||
target=self._connect_and_execute_commands,
|
||||
args=(
|
||||
cmd_group["device_name"],
|
||||
cmd_group["commands"],
|
||||
results,
|
||||
i,
|
||||
device_ports,
|
||||
gns3_host,
|
||||
),
|
||||
)
|
||||
threads.append(thread)
|
||||
thread.start()
|
||||
|
||||
# Wait for all threads to complete
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
|
||||
# Count successful and failed executions
|
||||
success_count = sum(1 for r in results if r.get("status") == "success")
|
||||
error_count = sum(1 for r in results if r.get("status") == "error")
|
||||
|
||||
logger.info(
|
||||
"Multi-device command execution completed. Total: %d, Success: %d, "
|
||||
"Error: %d",
|
||||
len(results),
|
||||
success_count,
|
||||
error_count,
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Example usage
|
||||
command_groups = json.dumps(
|
||||
{
|
||||
"project_id": "<PROJECT_UUID>",
|
||||
"device_configs": [
|
||||
{
|
||||
"device_name": "PC1",
|
||||
"commands": [
|
||||
"ip 10.10.0.12/24 10.10.0.254",
|
||||
"ping 10.10.0.254",
|
||||
],
|
||||
},
|
||||
{
|
||||
"device_name": "PC2",
|
||||
"commands": ["ip 10.10.0.13/24 10.10.0.254"],
|
||||
},
|
||||
{
|
||||
"device_name": "PC3",
|
||||
"commands": [
|
||||
"ip 10.20.0.22/24 10.20.0.254",
|
||||
"ping 10.20.0.254",
|
||||
],
|
||||
},
|
||||
{
|
||||
"device_name": "PC4",
|
||||
"commands": ["ip 10.20.0.23/24 10.20.0.254"],
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
exe_cmd = VPCSMultiCommands()
|
||||
result = exe_cmd._run(tool_input=command_groups)
|
||||
print("Execution results:")
|
||||
print(json.dumps(result, indent=2, ensure_ascii=False))
|
||||
Reference in new issue
Block a user