YueGuobin 8ba1f064d2
feat: add packet filter management tool for GNS3-Copilot fault injection
Add comprehensive packet filter management functionality to GNS3-Copilot,
enabling AI-powered fault injection scenarios with network simulation
capabilities like latency, packet loss, and corruption.

## Changes

### New Features
- **GNS3PacketFilterTool**: New LangChain tool for managing packet filters
  on GNS3 links with support for delay, packet loss, corruption,
  frequency_drop, and BPF filtering
  - Actions: get_available, set, get, clear
  - Integrated into troubleshooting_injection mode for fault scenarios

### API Integration
- **Link.available_filters()**: Added method to custom_gns3fy.py Link class
  - Queries available filter types for specific links
  - API v3+ only (raises ValueError for v2 connectors)
  - Returns filter definitions with parameters and constraints

### Tool Integration
- Added GNS3PacketFilterTool to TROUBLESHOOTING_INJECTION_MODE_TOOLS
- Positioned as 3rd tool in fault injection workflow
- Optimized for troubleshooting practice scenarios

## Files Modified
- gns3server/agent/gns3_copilot/agent/gns3_copilot.py
- gns3server/agent/gns3_copilot/gns3_client/custom_gns3fy.py
- gns3server/agent/gns3_copilot/tools_v2/__init__.py

## Files Added
- gns3server/agent/gns3_copilot/tools_v2/gns3_packet_filter.py

## Testing
- All validation tests passed
- Version checking verified (v3+ only)
- Tool integration confirmed in troubleshooting mode
2026-05-24 22:31:28 +08:00

3093 lines
93 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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
#
"""
Adapted gns3fy module for GNS3-Copilot
This module is based on the upstream gns3fy project
(https://github.com/davidban77/gns3fy).
Modifications made for GNS3-Copilot:
- Adjusted pydantic usages and dataclass configuration to reduce dependency
conflicts with langchain (pydantic version/api differences)
- Kept the original API surface where possible but simplified
validators/config
- Added JWT token authentication support
- Integrated with context-aware connector factory
Note: This file is adapted from upstream gns3fy for compatibility with
GNS3-Copilot's architecture.
Upstream: https://github.com/davidban77/gns3fy
"""
import os
import time
from collections.abc import Callable
from dataclasses import field
from functools import wraps
from math import cos
from math import pi
from math import sin
from typing import Any
from typing import ParamSpec
from typing import TypeVar
from typing import cast
from urllib.parse import urlparse
import jwt
import requests
import urllib3
from pydantic import ConfigDict
from pydantic import field_validator
from pydantic.dataclasses import dataclass
from requests import HTTPError
P = ParamSpec("P")
R = TypeVar("R")
F = TypeVar("F", bound=Callable[..., Any])
config = ConfigDict(validate_assignment=True, extra="ignore")
NODE_TYPES = [
"cloud",
"nat",
"ethernet_hub",
"ethernet_switch",
"frame_relay_switch",
"atm_switch",
"docker",
"dynamips",
"vpcs",
"traceng",
"virtualbox",
"vmware",
"iou",
"qemu",
]
CONSOLE_TYPES = [
"vnc",
"telnet",
"http",
"https",
"spice",
"spice+agent",
"none",
"null",
]
LINK_TYPES = ["ethernet", "serial"]
class Gns3Connector:
"""
Connector to be use for interaction against GNS3 server controller API.
**Attributes:**
- `url` (str): URL of the GNS3 server (**required**)
- `user` (str): User used for authentication
- `cred` (str): Password used for authentication
- `jwt_token` (str): JWT token for direct authentication (API v3)
- `verify` (bool): Whether or not to verify SSL
- `api_version` (int): GNS3 server REST API version
- `api_calls`: Counter of amount of `http_calls` has been performed
- `base_url`: url passed + api_version
- `session`: Requests Session object
**Returns:**
`Gns3Connector` instance
**Example:**
```python
>>> # API v2 with basic auth
>>> server = Gns3Connector(
... url="http://<address>:3080", user="admin", cred="password",
... api_version=2
... )
>>> # API v3 with username/password (auto-fetches JWT token)
>>> server = Gns3Connector(
... url="http://<address>:3080", user="admin", cred="password",
... api_version=3
... )
>>> # API v3 with direct JWT token
>>> server = Gns3Connector(
... url="http://<address>:3080",
... jwt_token="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
... api_version=3
... )
>>> print(server.get_version())
{'local': False, 'version': '2.2.0b4'}
```
"""
access_token: str | None
token_expiry: float | None
def __init__(
self,
url: str | None = None,
user: str | None = None,
cred: str | None = None,
jwt_token: str | None = None,
verify: bool = False,
api_version: int = 2,
) -> None:
# Disable SSL warnings
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
if url is None:
raise ValueError("URL is required for Gns3Connector")
self.url = url.strip("/") # Store original URL for reference
self.base_url = f"{self.url}/v{api_version}"
self.user = user
self.cred = cred
self.headers = {"Content-Type": "application/json"}
self.verify = verify
self.api_calls = 0
# v3 authentication attributes
# If jwt_token is provided directly, use it; otherwise will be
# fetched via username/password
self.access_token = jwt_token
self.token_expiry = None
self.auth_type = "basic" if api_version == 2 else "jwt"
self.api_version = api_version
# Create session object
self._create_session()
def _create_session(self) -> None:
"""
Creates the requests.Session object and applies the necessary parameters
"""
self.session = requests.Session() # pragma: no cover
self.session.headers["Accept"] = "application/json" # pragma: no cover
# Set authentication based on API version
if (
self.auth_type == "basic"
and self.user is not None
and self.cred is not None
):
self.session.auth = (self.user, self.cred) # pragma: no cover
elif self.auth_type == "jwt" and self.access_token:
self.session.headers["Authorization"] = (
f"Bearer {self.access_token}"
)
def _authenticate_v3(self) -> None:
"""
Performs v3 API authentication using username and password to get JWT token.
Skips authentication if a JWT token is already provided.
"""
# If token is already provided, skip authentication
if self.access_token:
return
if not self.user or not self.cred:
raise ValueError(
"Username and password are required for v3 authentication "
"when no JWT token is provided"
)
# Construct authentication URL (v3 API uses different base URL)
auth_url = (
f"{self.base_url.replace('/v3', '')}/v3/access/users/authenticate"
)
auth_data = {"username": self.user, "password": self.cred}
# Use temporary session for authentication
temp_session = requests.Session()
temp_session.headers["Content-Type"] = "application/json"
try:
response = temp_session.post(
auth_url, json=auth_data, verify=self.verify, timeout=10.0
)
if response.status_code == 200:
auth_result = response.json()
self.access_token = auth_result["access_token"]
# Update session with new token
self.session.headers["Authorization"] = (
f"Bearer {self.access_token}"
)
# print(f"Successfully authenticated to v3 API, token obtained")
else:
raise HTTPError(
f"v3 API authentication failed: {response.status_code} - "
f"{response.text}"
)
except Exception as e:
raise HTTPError(f"v3 API authentication error: {str(e)}") from e
def _is_token_expired(self) -> bool:
"""
Check if the JWT token is expired (basic implementation)
"""
token = self.access_token
if not token:
return True
try:
# Decode token without verification to check expiry
decoded: dict[str, Any] = jwt.decode(
token, options={"verify_signature": False}
)
exp = decoded.get("exp")
if exp is not None:
return time.time() > float(exp)
return False
except (jwt.PyJWTError, ValueError, TypeError):
return True
def _refresh_token(self) -> None:
"""
Refresh the JWT token (for now, just re-authenticate)
"""
print("Refreshing v3 API token...")
self._authenticate_v3()
def http_call(
self,
method: str,
url: str,
data: Any | None = None,
json_data: dict[str, Any] | list[Any] | None = None,
headers: dict[str, str] | None = None,
verify: bool = False,
params: dict[str, Any] | None = None,
) -> requests.Response:
"""
Executes HTTP operations and handles GNS3-specific error logic.
"""
# Handle JWT authentication
if (
self.auth_type == "jwt"
and not self.access_token
and self.user
and self.cred
):
self._authenticate_v3()
# Get request function (e.g., session.get, session.post)
caller = getattr(self.session, method.lower())
# Prepare request parameters, avoiding multiple repeated calls to caller
kwargs: dict[str, Any] = {
"headers": headers,
"params": params,
"verify": verify,
"timeout": 10.0, # Fixed 10-second timeout for all GNS3 API requests
}
if data is not None:
kwargs["data"] = data
elif json_data is not None:
kwargs["json"] = json_data
# Execute request
_response: requests.Response = caller(url, **kwargs)
self.api_calls += 1
try:
_response.raise_for_status()
except HTTPError as e:
# Throw enhanced error
raise self._extract_gns3_error(e) from e
return _response
def _extract_gns3_error(self, e: HTTPError) -> HTTPError:
"""
Extract GNS3-specific JSON error information from HTTPError.
If parsing fails, return the original error.
"""
# e.response might be None, need explicit check
response = e.response
if response is None:
return e
try:
# Only attempt parsing when Content-Type is JSON
if (
"application/json"
in response.headers.get("Content-Type", "").lower()
):
error_json = response.json()
status = error_json.get("status", "Unknown Status")
message = error_json.get(
"message", "No message provided in JSON."
)
# Construct a more descriptive new error
new_err = HTTPError(
f"{status}: {message} (Original {response.status_code} Error)",
response=response,
)
return new_err
except Exception:
# If JSON parsing fails, return error with original text
return HTTPError(
f"Original Error: {str(e)}. GNS3 response text: {response.text}",
response=response,
)
return e
def get_version(self) -> dict[str, Any]:
"""
Returns the version information of GNS3 server
"""
response = self.http_call("get", url=f"{self.base_url}/version")
return cast(dict[str, Any], response.json())
def projects_summary(
self, is_print: bool = True
) -> list[tuple[str, str, int, int, str]] | None:
"""
Returns a summary of the projects in the server. If `is_print` is `False`, it
will return a list of tuples like:
`[(name, project_id, total_nodes, total_links, status) ...]`
"""
_projects_summary = []
for _p in self.get_projects():
# Retrieve the project stats
_stats = self.http_call(
"get", f"{self.base_url}/projects/{_p['project_id']}/stats"
).json()
if is_print:
print(
f"{_p['name']}: {_p['project_id']} -- Nodes: {_stats['nodes']} -- "
f"Links: {_stats['links']} -- Status: {_p['status']}"
)
_projects_summary.append(
(
_p["name"],
_p["project_id"],
_stats["nodes"],
_stats["links"],
_p["status"],
)
)
return _projects_summary if not is_print else None
def get_projects(self) -> list[dict[str, Any]]:
"""
Returns the list of the projects on the server
"""
response = self.http_call(
"get", url=f"{self.base_url}/projects"
).json()
return cast(list[dict[str, Any]], response)
def get_project(
self, name: str | None = None, project_id: str | None = None
) -> dict[str, Any] | None:
"""
Retrieves a project from either a name or ID
**Required Attributes:**
- `name` or `project_id`
"""
if project_id:
_response = self.http_call(
"get", url=f"{self.base_url}/projects/{project_id}"
)
return cast(dict[str, Any], _response.json())
elif name:
try:
return next(
p for p in self.get_projects() if p["name"] == name
)
except StopIteration:
# Project not found
return None
else:
raise ValueError("Must provide either a name or project_id")
def templates_summary(
self, is_print: bool = True
) -> list[tuple[str, str, str, bool, str, str]] | None:
"""
Returns a summary of the templates in the server. If `is_print` is `False`, it
will return a list of tuples like:
`[(name, template_id, template_type, builtin, console_type, category) ...]`
"""
_templates_summary = []
for _t in self.get_templates():
if "console_type" not in _t:
_t["console_type"] = "N/A"
if is_print:
print(
f"{_t['name']}: {_t['template_id']} -- Type: {_t['template_type']}"
f" -- Builtin: {_t['builtin']} -- Console: {_t['console_type']} -- "
f"Category: {_t['category']}"
)
_templates_summary.append(
(
_t["name"],
_t["template_id"],
_t["template_type"],
_t["builtin"],
_t["console_type"],
_t["category"],
)
)
return _templates_summary if not is_print else None
def get_templates(self) -> list[dict[str, Any]]:
"""
Returns the templates defined on the server.
"""
_response_data = self.http_call(
"get", url=f"{self.base_url}/templates"
).json()
return cast(list[dict[str, Any]], _response_data)
def get_template(
self, name: str | None = None, template_id: str | None = None
) -> dict[str, Any] | None:
"""
Retrieves a template from either a name or ID
**Required Attributes:**
- `name` or `template_id`
"""
if template_id:
_response_json = self.http_call(
"get", url=f"{self.base_url}/templates/{template_id}"
).json()
return cast(dict[str, Any], _response_json)
elif name:
try:
return next(
t for t in self.get_templates() if t["name"] == name
)
except StopIteration:
# Template name not found
return None
else:
raise ValueError("Must provide either a name or template_id")
def update_template(
self,
name: str | None = None,
template_id: str | None = None,
**kwargs: Any,
) -> dict[str, Any]:
"""
Updates a template by giving its name or UUID. For more information [API INFO]
(http://api.gns3.net/en/2.2/api/v2/controller/template/
templatestemplateid.html#put-v2-templates-template-id)
**Required Attributes:**
- `name` or `template_id`
**Optional Attributes (can be passed via kwargs):**
- `tags` (list): List of tags for the template (e.g.,
["device_type:cisco_ios_telnet", "platform:cisco_ios"])
- Any other template attributes supported by GNS3 API
"""
# Get existing template
_template = self.get_template(name=name, template_id=template_id)
# Type check: handle case where get_template might return None
if _template is None:
raise ValueError(
f"Template not found (name={name}, id={template_id})"
)
# Update local dictionary and send request
_template.update(**kwargs)
response = self.http_call(
"put",
url=f"{self.base_url}/templates/{_template['template_id']}",
json_data=_template,
)
# Return JSON and handle Any type errors
return cast(dict[str, Any], response.json())
def create_template(self, **kwargs: Any) -> dict[str, Any]:
"""
Creates a template by giving its attributes. For more information [API INFO]
(http://api.gns3.net/en/2.2/api/v2/controller/template/
templates.html#post-v2-templates)
**Required Attributes:**
- `name`
- `compute_id` by default is 'local'
- `template_type`
**Optional Attributes (can be passed via kwargs):**
- `tags` (list): List of tags for the template (e.g.,
["device_type:cisco_ios_telnet", "platform:cisco_ios"])
- Any other template attributes supported by GNS3 API
**Example:**
```python
>>> connector.create_template(
... name="cisco_router",
... template_type="dynamips",
... tags=["device_type:cisco_ios_telnet", "platform:cisco_ios"]
... )
```
"""
# kwargs["name"] might raise KeyError at runtime, for more robust
# code we can use get first
template_name = kwargs.get("name")
if not template_name:
raise ValueError(
"Attribute 'name' is required to create a template"
)
# Check if template already exists
_template = self.get_template(name=kwargs["name"])
if _template:
raise ValueError(f"Template already used: {kwargs['name']}")
# Set default values
if "compute_id" not in kwargs:
kwargs["compute_id"] = "local"
# Send request
response = self.http_call(
"post", url=f"{self.base_url}/templates", json_data=kwargs
)
# Return and convert type
return cast(dict[str, Any], response.json())
def delete_template(
self, name: str | None = None, template_id: str | None = None
) -> None:
"""
Deletes a template by giving its attributes. For more information [API INFO]
(http://api.gns3.net/en/2.2/api/v2/controller/template/
templatestemplateid.html#id16)
**Required Attributes:**
- `name` or `template_id`
"""
# Logic handling: if only name is given, need to first get template_id
if name and not template_id:
_template = self.get_template(name=name)
# Type narrowing: check if _template is None
if _template is None:
raise ValueError(f"Template with name '{name}' not found.")
template_id = _template["template_id"]
# Final check: ensure template_id has a value at this point
if not template_id:
raise ValueError(
"Must provide either a 'name' or 'template_id' to delete a template."
)
self.http_call(
"delete", url=f"{self.base_url}/templates/{template_id}"
)
def get_nodes(self, project_id: str) -> list[dict[str, Any]]:
"""
Retieves the nodes defined on the project
**Required Attributes:**
- `project_id`
"""
_response_data = self.http_call(
"get", url=f"{self.base_url}/projects/{project_id}/nodes"
).json()
return cast(list[dict[str, Any]], _response_data)
def get_node(self, project_id: str, node_id: str) -> dict[str, Any]:
"""
Returns the node by locating its ID.
**Required Attributes:**
- `project_id`
- `node_id`
"""
_url = f"{self.base_url}/projects/{project_id}/nodes/{node_id}"
_response_data = self.http_call("get", _url).json()
return cast(dict[str, Any], _response_data)
def get_links(self, project_id: str) -> list[dict[str, Any]]:
"""
Retrieves the links defined in the project.
**Required Attributes:**
- `project_id`
"""
_response_data = self.http_call(
"get", url=f"{self.base_url}/projects/{project_id}/links"
).json()
return cast(list[dict[str, Any]], _response_data)
def get_link(self, project_id: str, link_id: str) -> dict[str, Any]:
"""
Returns the link by locating its ID.
**Required Attributes:**
- `project_id`
- `link_id`
"""
_url = f"{self.base_url}/projects/{project_id}/links/{link_id}"
_response_data = self.http_call("get", _url).json()
return cast(dict[str, Any], _response_data)
def create_project(self, **kwargs: Any) -> dict[str, Any]:
"""
Pass a dictionary type object with the project parameters to be created.
**Required Attributes:**
- `name`
**Returns**
JSON project information
"""
_url = f"{self.base_url}/projects"
if "name" not in kwargs:
raise ValueError("Parameter 'name' is mandatory")
_response = self.http_call("post", _url, json_data=kwargs)
return cast(dict[str, Any], _response.json())
def delete_project(self, project_id: str) -> None:
"""
Deletes a project from server.
**Required Attributes:**
- `project_id`
"""
_url = f"{self.base_url}/projects/{project_id}"
self.http_call("delete", _url)
return None
def get_computes(self) -> list[dict[str, Any]]:
"""
Returns a list of computes.
**Returns:**
List of dictionaries of the computes attributes like cpu/memory usage
"""
_url = f"{self.base_url}/computes"
_response_data = self.http_call("get", _url).json()
return cast(list[dict[str, Any]], _response_data)
def get_compute(self, compute_id: str = "local") -> dict[str, Any]:
"""
Returns a compute.
**Returns:**
Dictionary of the compute attributes like cpu/memory usage
"""
_url = f"{self.base_url}/computes/{compute_id}"
_response_data = self.http_call("get", _url).json()
return cast(dict[str, Any], _response_data)
def get_compute_images(
self, emulator: str, compute_id: str = "local"
) -> list[dict[str, Any]]:
"""
Returns a list of images available for a compute.
**Required Attributes:**
- `emulator`: the likes of 'qemu', 'iou', 'docker' ...
- `compute_id` By default is 'local'
**Returns:**
List of dictionaries with images available for the compute for the specified
emulator
"""
_url = f"{self.base_url}/computes/{compute_id}/{emulator}/images"
_response_data = self.http_call("get", _url).json()
return cast(list[dict[str, Any]], _response_data)
def upload_compute_image(
self, emulator: str, file_path: str, compute_id: str = "local"
) -> None:
"""
uploads an image for use by a compute.
**Required Attributes:**
- `emulator`: the likes of 'qemu', 'iou', 'docker' ...
- `file_path`: path of file to be uploaded
- `compute_id` By default is 'local'
"""
if not os.path.exists(file_path):
raise FileNotFoundError(f"Could not find file: {file_path}")
_filename = os.path.basename(file_path)
_url = f"{self.base_url}/computes/{compute_id}/{emulator}/images/{_filename}"
with open(file_path, "rb") as f:
self.http_call("post", _url, data=f)
return None
def get_compute_ports(self, compute_id: str = "local") -> dict[str, Any]:
"""
Returns ports used and configured by a compute.
**Required Attributes:**
- `compute_id` By default is 'local'
**Returns:**
Dictionary of `console_ports` used and range, as well as the `udp_ports`
"""
_url = f"{self.base_url}/computes/{compute_id}/ports"
_response_data = self.http_call("get", _url).json()
return cast(dict[str, Any], _response_data)
def verify_connector_and_id(f: F) -> F:
"""
Main checker for connector object and respective object's ID for their retrieval
or actions methods.
"""
@wraps(f)
def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any:
_conn = self.connector
_project_id = self.project_id
if _conn is None:
raise ValueError("Gns3Connector not assigned under 'connector'")
if _project_id is None:
raise ValueError("Need to submit project_id")
# Checks for Node
if self.__class__.__name__ == "Node":
if not self.node_id:
if not self.name:
raise ValueError("Need to either submit node_id or name")
# Try to retrieve the node_id
_url = f"{_conn.base_url}/projects/{_project_id}/nodes"
_response = _conn.http_call("get", _url)
extracted = [
node
for node in _response.json()
if node["name"] == self.name
]
if len(extracted) > 1: # pragma: no cover
raise ValueError(
"Multiple nodes found with same name. Need to submit node_id"
)
self.node_id = extracted[0]["node_id"]
# Checks for Link
if self.__class__.__name__ == "Link":
if not self.link_id:
raise ValueError("Need to submit link_id")
return f(self, *args, **kwargs)
return cast(F, wrapper)
@dataclass(config=config)
class Link:
"""
GNS3 Link API object. For more information visit: [Links Endpoint API information](
http://api.gns3.net/en/2.2/api/v2/controller/link/projectsprojectidlinks.html)
**Attributes:**
- `link_id` (str): Link UUID (**required** to be set when using `get` method)
- `link_type` (enum): Possible values: ethernet, serial
- `link_style` (dict): Describes the visual style of the link
- `project_id` (str): Project UUID (**required**)
- `connector` (object): `Gns3Connector` instance used for interaction (**required**)
- `suspend` (bool): Suspend the link
- `nodes` (list): List of the Nodes and ports (**required** when using `create`
method, see Features/Link creation on the docs)
- `filters` (dict): Packet filter. This allow to simulate latency and errors
- `capturing` (bool): Read only property. True if a capture running on the link
- `capture_file_path` (str): Read only property. The full path of the capture file
if capture is running
- `capture_file_name` (str): Read only property. The name of the capture file if
capture is running
**Returns:**
`Link` instance
**Example:**
```python
>>> link = Link(project_id=<pr_id>, link_id=<link_id> connector=<Gns3Connector
instance>)
>>> link.get()
>>> print(link.link_type)
'ethernet'
```
"""
link_id: str | None = None
link_type: str | None = None
link_style: Any | None = None
project_id: str | None = None
suspend: bool | None = None
nodes: list[Any] | None = None
filters: dict | None = None
capturing: bool | None = None
capture_file_path: str | None = None
capture_file_name: str | None = None
capture_compute_id: str | None = None
connector: Any | None = field(default=None, repr=False)
@field_validator("link_type")
@classmethod
def _valid_link_type(cls, value: str | None) -> str | None:
if value not in LINK_TYPES and value is not None:
raise ValueError(f"Not a valid link_type - {value}")
return value
@field_validator("suspend")
@classmethod
def _valid_suspend(cls, value: bool | None) -> bool | None:
if type(value) is not bool and value is not None:
raise ValueError(f"Not a valid suspend - {value}")
return value
@field_validator("filters")
@classmethod
def _valid_filters(
cls, value: dict[str, Any] | None
) -> dict[str, Any] | None:
if type(value) is not dict and value is not None:
raise ValueError(f"Not a valid filters - {value}")
return value
def _update(self, data_dict: dict[str, Any]) -> None:
for k, v in data_dict.items():
if k in self.__dict__.keys():
self.__setattr__(k, v)
@verify_connector_and_id
def get(self) -> None:
"""
Retrieves the information from the link endpoint.
**Required Attributes:**
- `project_id`
- `connector`
- `link_id`
"""
_conn = self.connector
_project_id = self.project_id
if _conn is None:
raise ValueError("Gns3Connector not assigned under 'connector'")
if _project_id is None:
raise ValueError("Need to submit project_id")
_url = f"{_conn.base_url}/projects/{_project_id}/links/{self.link_id}"
_response = _conn.http_call("get", _url)
# Update object
self._update(_response.json())
@verify_connector_and_id
def delete(self) -> None:
"""
Deletes a link endpoint from the project. It sets to `None` the attributes
`link_id` when executed sucessfully
**Required Attributes:**
- `project_id`
- `connector`
- `link_id`
"""
_conn = self.connector
_project_id = self.project_id
_link_id = self.link_id
if _conn is None:
raise ValueError("Gns3Connector not assigned under 'connector'")
if _project_id is None:
raise ValueError("Need to submit project_id")
if _link_id is None:
raise ValueError(
"Link ID is missing. The link might have already been deleted."
)
_url = f"{_conn.base_url}/projects/{_project_id}/links/{self.link_id}"
_conn.http_call("delete", _url)
self.project_id = None
self.link_id = None
def create(self) -> None:
"""
Creates a link endpoint
**Required Attributes:**
- `project_id`
- `connector`
- `nodes`
"""
if not self.connector:
raise ValueError("Gns3Connector not assigned under 'connector'")
if not self.project_id:
raise ValueError("Need to submit project_id")
_url = f"{self.connector.base_url}/projects/{self.project_id}/links"
data = {
k: v
for k, v in self.__dict__.items()
if k not in ("connector", "__initialised__")
if v is not None
}
_response = self.connector.http_call("post", _url, json_data=data)
# Now update it
self._update(_response.json())
@verify_connector_and_id
def update(self, **kwargs: Any) -> None:
"""
Updates the link instance by passing the keyword arguments of the attributes
you want updated
Example:
```python
link1.update(suspend=True)
```
This will update the link `suspend` attribute to `True`
**Required Attributes:**
- `project_id`
- `connector`
- `link_id`
"""
if not self.connector:
raise ValueError("Gns3Connector not assigned under 'connector'")
if not self.project_id:
raise ValueError("Need to submit project_id")
_url = (
f"{self.connector.base_url}/projects/{self.project_id}/links/"
f"{self.link_id}"
)
# TODO: Verify that the passed kwargs are supported ones
_response = self.connector.http_call("put", _url, json_data=kwargs)
# Update object
self._update(_response.json())
@verify_connector_and_id
def available_filters(self) -> list[dict[str, Any]]:
"""
Gets the list of available packet filters for this link.
**NOTE:** This endpoint is only available in GNS3 API v3 or later.
Attempting to call this method with a v2 connector will raise an error.
**Required Attributes:**
- `project_id`
- `connector` (must be API v3 or later)
- `link_id`
**Returns:**
List of available filter types with their parameters (e.g., frequency_drop,
packet_loss, delay, corrupt, bpf).
**Example:**
```python
>>> link = Link(project_id=<pr_id>, link_id=<link_id>, connector=<connector>)
>>> filters = link.available_filters()
>>> print(filters)
[
{
"type": "frequency_drop",
"name": "Frequency drop",
"description": "It will drop everything with a -1 frequency...",
"parameters": [...]
},
...
]
```
"""
_conn = self.connector
_project_id = self.project_id
if _conn is None:
raise ValueError("Gns3Connector not assigned under 'connector'")
if _project_id is None:
raise ValueError("Need to submit project_id")
# Check API version - available_filters endpoint is only available in v3+
if not hasattr(_conn, "api_version") or _conn.api_version < 3:
raise ValueError(
"The available_filters() method requires GNS3 API v3 or later. "
f"Current connector version: v{getattr(_conn, 'api_version', 2)}. "
"Please use api_version=3 when creating the Gns3Connector."
)
_url = (
f"{_conn.base_url}/projects/{_project_id}/links/{self.link_id}/"
"available_filters"
)
_response = _conn.http_call("get", _url)
return cast(list[dict[str, Any]], _response.json())
@dataclass(config=config)
class Node:
"""
GNS3 Node API object. For more information visit: [Node Endpoint API information](
http://api.gns3.net/en/2.2/api/v2/controller/node/projectsprojectidnodes.html)
**Attributes:**
- `name` (str): Node name (**required** when using `create` method)
- `project_id` (str): Project UUID (**required**)
- `node_id` (str): Node UUID (**required** when using `get` method)
- `compute_id` (str): Compute identifier (**required**, default=local)
- `node_type` (enum): frame_relay_switch, atm_switch, docker, dynamips, vpcs,
traceng, virtualbox, vmware, iou, qemu (**required** when using `create` method)
- `connector` (object): `Gns3Connector` instance used for interaction (**required**)
- `template_id`: Template UUID from the which the node is from.
- `template`: Template name from the which the node is from.
- `node_directory` (str): Working directory of the node. Read only
- `status` (enum): Possible values: stopped, started, suspended
- `ports` (list): List of node ports, READ only
- `port_name_format` (str): Formating for port name {0} will be replace by port
number
- `port_segment_size` (int): Size of the port segment
- `first_port_name` (str): Name of the first port
- `properties` (dict): Properties specific to an emulator
- `locked` (bool): Whether the element locked or not
- `label` (dict): TBC
- `console` (int): Console TCP port
- `console_host` (str): Console host
- `console_auto_start` (bool): Automatically start the console when the node has
started
- `command_line` (str): Command line use to start the node
- `custom_adapters` (list): TBC
- `height` (int): Height of the node, READ only
- `width` (int): Width of the node, READ only
- `symbol` (str): Symbol of the node
- `x` (int): X position of the node
- `y` (int): Y position of the node
- `z (int): Z position of the node
**Returns:**
`Node` instance
**Example:**
```python
>>> alpine = Node(name="alpine1", node_type="docker", template="alpine",
project_id=<pr_id>, connector=<Gns3Connector instance>)
>>> alpine.create()
>>> print(alpine.node_id)
'SOME-UUID-GENERATED'
```
"""
name: str | None = None
project_id: str | None = None
node_id: str | None = None
compute_id: str = "local"
node_type: str | None = None
node_directory: str | None = None
status: str | None = None
ports: list | None = None
port_name_format: str | None = None
port_segment_size: int | None = None
first_port_name: str | None = None
locked: bool | None = None
label: Any | None = None
console: int | None = None
console_host: str | None = None
console_type: str | None = None
console_auto_start: bool | None = None
command_line: str | None = None
custom_adapters: list[Any] | None = None
height: int | None = None
width: int | None = None
symbol: str | None = None
x: int | None = None
y: int | None = None
z: int | None = None
template_id: str | None = None
properties: Any | None = None
tags: list[str] | None = None
template: str | None = None
links: list[Link] = field(default_factory=list, repr=False)
connector: Any | None = field(default=None, repr=False)
@field_validator("node_type")
@classmethod
def _valid_node_type(cls, value: Any) -> Any:
if value not in NODE_TYPES and value is not None:
raise ValueError(f"Not a valid node_type - {value}")
return value
@field_validator("console_type")
@classmethod
def _valid_console_type(cls, value: Any) -> Any:
if value not in CONSOLE_TYPES and value is not None:
raise ValueError(f"Not a valid console_type - {value}")
return value
@field_validator("status")
@classmethod
def _valid_status(cls, value: Any) -> Any:
if (
value not in ("stopped", "started", "suspended")
and value is not None
):
raise ValueError(f"Not a valid status - {value}")
return value
def _update(self, data_dict: dict[str, Any]) -> None:
for k, v in data_dict.items():
if k in self.__dict__:
setattr(self, k, v)
@verify_connector_and_id
def get(self, get_links: bool = True) -> None:
"""
Retrieves the node information. When `get_links` is `True` it also retrieves the
links respective to the node.
**Required Attributes:**
- `project_id`
- `connector`
- `node_id`
"""
if not self.connector:
raise ValueError("Gns3Connector not assigned under 'connector'")
if not self.project_id:
raise ValueError("Need to submit project_id")
_url = (
f"{self.connector.base_url}/projects/{self.project_id}/nodes/"
f"{self.node_id}"
)
_response = self.connector.http_call("get", _url)
# Update object
self._update(_response.json())
if get_links:
self.get_links()
@verify_connector_and_id
def get_links(self) -> None:
"""
Retrieves the links of the respective node. They will be saved at the `links`
attribute
**Required Attributes:**
- `project_id`
- `connector`
- `node_id`
"""
if not self.connector:
raise ValueError("Gns3Connector not assigned under 'connector'")
if not self.project_id:
raise ValueError("Need to submit project_id")
_url = (
f"{self.connector.base_url}/projects/{self.project_id}/nodes"
f"/{self.node_id}/links"
)
_response = self.connector.http_call("get", _url)
# Create the Link array but cleanup cache if there is one
if self.links:
self.links = []
for _link in _response.json():
self.links.append(Link(connector=self.connector, **_link))
@verify_connector_and_id
def start(self) -> bool | None:
"""
Starts the node.
**Required Attributes:**
- `project_id`
- `connector`
- `node_id`
"""
_conn = self.connector
assert _conn is not None
_project_id = self.project_id
assert _project_id is not None
_url = f"{_conn.base_url}/projects/{_project_id}/nodes/{self.node_id}/start"
if "v2" in _url.lower(): # api_version 2
_response = _conn.http_call(
"post",
_url,
)
# Update object or perform get if change was not reflected
if _response.json().get("status") == "started":
self._update(_response.json())
else:
self.get() # pragma: no cover
return True
else:
# api_version 3
_response = _conn.http_call(
"post", _url, json_data={"additionalProp1": {}}
)
# successful response code 204
if _response.status_code in (204,):
self.get()
return True
else:
try:
error_detail = _response.json()
except Exception:
error_detail = getattr(
_response, "text", "No response body"
)
_msg = (
"Failed to start node: "
f"{getattr(_response, 'status_code', 'Unknown Status')}, "
f"Detail: {error_detail}"
)
raise RuntimeError(_msg) from None
@verify_connector_and_id
def stop(self) -> bool | None:
"""
Stops the node.
**Required Attributes:**
- `project_id`
- `connector`
- `node_id`
"""
_conn = self.connector
assert _conn is not None
_project_id = self.project_id
assert _project_id is not None
_url = f"{_conn.base_url}/projects/{_project_id}/nodes/{self.node_id}/stop"
if "v2" in _url.lower(): # api_version 2
_response = _conn.http_call(
"post",
_url,
)
# Update object or perform get if change was not reflected
if _response.json().get("status") == "stopped":
self._update(_response.json())
else:
self.get() # pragma: no cover
return True
else:
# api_version 3
_response = _conn.http_call(
"post", _url, json_data={"additionalProp1": {}}
)
# successful response code 204
if _response.status_code in (204,):
self.get()
return True
else:
try:
error_detail = _response.json()
except Exception:
error_detail = _response.text
_msg = (
f"Failed to stop node: {_response.status_code}, "
f"Detail: {error_detail}"
)
raise RuntimeError(_msg) from None
@verify_connector_and_id
def reload(self) -> bool | None:
"""
Reloads the node.
**Required Attributes:**
- `project_id`
- `connector`
- `node_id`
"""
_conn = self.connector
assert _conn is not None
_project_id = self.project_id
assert _project_id is not None
_node_id = self.node_id
assert _node_id is not None
_url = (
f"{_conn.base_url}/projects/{_project_id}/nodes/{_node_id}/reload"
)
_response = _conn.http_call("post", _url)
if "v2" in _url.lower(): # api_version 2
_response = _conn.http_call(
"post",
_url,
)
# Update object or perform get if change was not reflected
if _response.json().get("status") == "started":
self._update(_response.json())
else:
self.get() # pragma: no cover
return True
else:
# api_version 3
_response = _conn.http_call(
"post", _url, json_data={"additionalProp1": {}}
)
# successful response code 204
if _response.status_code in (204,):
self.get()
return True
else:
try:
error_detail = _response.json()
except Exception:
error_detail = _response.text
_msg = (
f"Failed to reload node: {_response.status_code}, "
f"Detail: {error_detail}"
)
raise RuntimeError(_msg) from None
@verify_connector_and_id
def suspend(self) -> None:
"""
Suspends the node.
**Required Attributes:**
- `project_id`
- `connector`
- `node_id`
"""
_conn = self.connector
assert _conn is not None
_project_id = self.project_id
assert _project_id is not None
_node_id = self.node_id
assert _node_id is not None
_url = (
f"{_conn.base_url}/projects/{_project_id}/nodes/{_node_id}/suspend"
)
_response = _conn.http_call("post", _url)
# Update object or perform get if change was not reflected
if _response.json().get("status") == "suspended":
self._update(_response.json())
else:
self.get() # pragma: no cover
@verify_connector_and_id
def update(self, **kwargs: Any) -> None:
"""
Updates the node instance by passing the keyword arguments of the attributes
you want updated
Example:
```python
router01.update(name="router01-CSX")
```
This will update the project `auto_close` attribute to `True`
**Required Attributes:**
- `project_id`
- `connector`
"""
_conn = self.connector
assert _conn is not None
_project_id = self.project_id
assert _project_id is not None
_node_id = self.node_id
assert _node_id is not None
_url = f"{_conn.base_url}/projects/{_project_id}/nodes/{_node_id}"
# TODO: Verify that the passed kwargs are supported ones
_response = _conn.http_call("put", _url, json_data=kwargs)
# Update object
self._update(_response.json())
def create(self) -> None:
"""
Creates a node.
By default it will fetch the nodes properties for creation based on the
`template` or `template_id` attribute supplied. This can be overriden/updated
by sending a dictionary of the properties under `extra_properties`.
**Required Node instance attributes:**
- `project_id`
- `connector`
- `compute_id`: Defaults to "local"
- `template` or `template_id` - if not passed as arguments
"""
if self.node_id:
raise ValueError("Node already created")
if not self.connector:
raise ValueError("Gns3Connector not assigned under 'connector'")
if not self.project_id:
raise ValueError("Node object needs to have project_id attribute")
if not self.template_id:
if self.template:
_template = self.connector.get_template(name=self.template)
if _template is None:
raise ValueError(f"Template {self.template} not found")
self.template_id = self.connector.get_template(
name=self.template
).get("template_id")
else:
raise ValueError("Need either 'template' of 'template_id'")
cached_data = {
k: v
for k, v in self.__dict__.items()
if k
not in (
"project_id",
"template",
"template_id",
"links",
"connector",
"__initialised__",
)
if v is not None
}
_url = (
f"{self.connector.base_url}/projects/{self.project_id}/"
f"templates/{self.template_id}"
)
_response = self.connector.http_call(
"post",
_url,
json_data={"x": 0, "y": 0, "compute_id": self.compute_id},
)
self._update(_response.json())
# Update the node attributes based on cached data
self.update(**cached_data)
@verify_connector_and_id
def delete(self) -> None:
"""
Deletes the node from the project. It sets to `None` the attributes `node_id`
and `name` when executed successfully
**Required Attributes:**
- `project_id`
- `connector`
- `node_id`
"""
_conn = self.connector
assert _conn is not None
_project_id = self.project_id
assert _project_id is not None
_node_id = self.node_id
assert _node_id is not None
_url = f"{_conn.base_url}/projects/{_project_id}/nodes/{_node_id}"
_conn.http_call("delete", _url)
self.project_id = None
self.node_id = None
self.name = None
@verify_connector_and_id
def get_file(self, path: str) -> str:
"""
Retrieve a file in the node directory.
**Required Attributes:**
- `project_id`
- `connector`
- `path`: Node's relative path of the file
"""
_conn = self.connector
assert _conn is not None
_project_id = self.project_id
assert _project_id is not None
_node_id = self.node_id
assert _node_id is not None
_url = f"{_conn.base_url}/projects/{_project_id}/nodes/{_node_id}/files/{path}"
return cast(str, _conn.http_call("get", _url).text)
@verify_connector_and_id
def write_file(self, path: str, data: Any) -> None:
"""
Places a file content on a specified node file path. Used mainly for docker
images.
Example to update an alpine docker network interfaces:
```python
>>> data = '''
auto eth0
iface eth0 inet dhcp
'''
>>> alpine_node.write_file(path='/etc/network/interfaces', data=data)
```
**Required Attributes:**
- `project_id`
- `connector`
- `path`: Node's relative path of the file
- `data`: Data to be included in the file
"""
_conn = self.connector
assert _conn is not None
_project_id = self.project_id
assert _project_id is not None
_node_id = self.node_id
assert _node_id is not None
_url = f"{_conn.base_url}/projects/{_project_id}/nodes/{_node_id}/files/{path}"
_conn.http_call("post", _url, data=data)
@dataclass(config=config)
class Project:
"""
GNS3 Project API object. For more information visit: [Project Endpoint API
information](http://api.gns3.net/en/2.2/api/v2/controller/project/projects.html)
**Attributes:**
- `name`: Project name (**required** when using `create` method)
- `project_id` (str): Project UUID (**required**)
- `connector` (object): `Gns3Connector` instance used for interaction (**required**)
- `status` (enum): Possible values: opened, closed
- `path` (str): Path of the project on the server
- `filename` (str): Project filename
- `auto_start` (bool): Project start when opened
- `auto_close` (bool): Project auto close when client cut off the notifications feed
- `auto_open` (bool): Project open when GNS3 start
- `drawing_grid_size` (int): Grid size for the drawing area for drawings
- `grid_size` (int): Grid size for the drawing area for nodes
- `scene_height` (int): Height of the drawing area
- `scene_width` (int): Width of the drawing area
- `show_grid` (bool): Show the grid on the drawing area
- `show_interface_labels` (bool): Show interface labels on the drawing area
- `show_layers` (bool): Show layers on the drawing area
- `snap_to_grid` (bool): Snap to grid on the drawing area
- `supplier` (dict): Supplier of the project
- `variables` (list): Variables required to run the project
- `zoom` (int): Zoom of the drawing area
- `stats` (dict): Project stats
-.`drawings` (list): List of drawings present on the project
- `nodes` (list): List of `Node` instances present on the project
- `links` (list): List of `Link` instances present on the project
**Returns:**
`Project` instance
**Example:**
```python
>>> lab = Project(name="lab", connector=<Gns3Connector instance>)
>>> lab.create()
>>> print(lab.status)
'opened'
```
"""
name: str | None = None
project_id: str | None = None
status: str | None = None
locked: bool | None = None
path: str | None = None
filename: str | None = None
auto_start: bool | None = None
auto_close: bool | None = None
auto_open: bool | None = None
drawing_grid_size: int | None = None
grid_size: int | None = None
scene_height: int | None = None
scene_width: int | None = None
show_grid: bool | None = None
show_interface_labels: bool | None = None
show_layers: bool | None = None
snap_to_grid: bool | None = None
supplier: Any | None = None
variables: list | None = None
zoom: int | None = None
stats: dict[str, Any] | None = None
snapshots: list[dict] | None = None
drawings: list[dict] | None = None
nodes: list[Node] = field(default_factory=list, repr=False)
links: list[Link] = field(default_factory=list, repr=False)
connector: Any | None = field(default=None, repr=False)
@field_validator("status")
@classmethod
def _valid_status(cls, value: Any) -> Any:
if value != "opened" and value != "closed" and value is not None:
raise ValueError("status must be opened or closed")
return value
def _update(self, data_dict: dict[str, Any]) -> None:
for k, v in data_dict.items():
if k in self.__dict__:
setattr(self, k, v)
def get(
self,
get_links: bool = True,
get_nodes: bool = True,
get_stats: bool = True,
) -> None:
"""
Retrieves the projects information.
- `get_links`: When true it also queries for the links inside the project
- `get_nodes`: When true it also queries for the nodes inside the project
- `get_stats`: When true it also queries for the stats inside the project
It `get_stats` is set to `True`, it also verifies if snapshots and drawings are
inside the project and stores them in their respective attributes
(`snapshots` and `drawings`)
**Required Attributes:**
- `connector`
- `project_id` or `name`
"""
if not self.connector:
raise ValueError("Gns3Connector not assigned under 'connector'")
# Get projects if no ID was provided by the name
if not self.project_id:
if not self.name:
raise ValueError("Need to submit either project_id or name")
_url = f"{self.connector.base_url}/projects"
# Get all projects and filter the respective project
_response = self.connector.http_call("get", _url)
# Filter the respective project
for _project in _response.json():
if _project.get("name") == self.name:
self.project_id = _project.get("project_id")
# Get project
_url = f"{self.connector.base_url}/projects/{self.project_id}"
_response = self.connector.http_call("get", _url)
# Update object
self._update(_response.json())
if get_stats:
self.get_stats()
if self.stats is not None:
if self.stats.get("snapshots", 0) > 0:
self.get_snapshots()
if self.stats.get("drawings", 0) > 0:
self.get_drawings()
if get_nodes:
self.get_nodes()
if get_links:
self.get_links()
def create(self) -> None:
"""
Creates the project.
**Required Attributes:**
- `name`
- `connector`
"""
if not self.name:
raise ValueError("Need to submit project name")
if not self.connector:
raise ValueError("Gns3Connector not assigned under 'connector'")
_url = f"{self.connector.base_url}/projects"
data = {
k: v
for k, v in self.__dict__.items()
if k
not in (
"stats",
"nodes",
"links",
"connector",
"__initialised__",
)
if v is not None
}
_response = self.connector.http_call("post", _url, json_data=data)
# Now update it
self._update(_response.json())
@verify_connector_and_id
def update(self, **kwargs: Any) -> None:
"""
Updates the project instance by passing the keyword arguments of the attributes
you want updated
Example:
```python
lab.update(auto_close=True)
```
This will update the project `auto_close` attribute to `True`
**Required Attributes:**
- `project_id`
- `connector`
"""
_conn = self.connector
assert _conn is not None
_project_id = self.project_id
assert _project_id is not None
_url = f"{_conn.base_url}/projects/{_project_id}"
# TODO: Verify that the passed kwargs are supported ones
_response = _conn.http_call("put", _url, json_data=kwargs)
# Update object
self._update(_response.json())
@verify_connector_and_id
def delete(self) -> None:
"""
Deletes the project from the server. It sets to `None` the attributes
`project_id` and `name` when executed successfully
**Required Attributes:**
- `project_id`
- `connector`
"""
_conn = self.connector
assert _conn is not None
_project_id = self.project_id
assert _project_id is not None
_url = f"{_conn.base_url}/projects/{_project_id}"
_conn.http_call("delete", _url)
self.project_id = None
self.name = None
@verify_connector_and_id
def close(self) -> None:
"""
Closes the project on the server.
**Required Attributes:**
- `project_id`
- `connector`
"""
_conn = self.connector
assert _conn is not None
_project_id = self.project_id
assert _project_id is not None
_url = f"{_conn.base_url}/projects/{_project_id}/close"
_response = _conn.http_call("post", _url)
# Update object
if _response.status_code == 204:
self.status = "closed"
@verify_connector_and_id
def open(self) -> None:
"""
Opens the project on the server.
**Required Attributes:**
- `project_id`
- `connector`
"""
_conn = self.connector
assert _conn is not None
_project_id = self.project_id
assert _project_id is not None
_url = f"{_conn.base_url}/projects/{_project_id}/open"
_response = _conn.http_call("post", _url)
# Update object
self._update(_response.json())
@verify_connector_and_id
def get_stats(self) -> None:
"""
Retrieve the stats of the project.
**Required Attributes:**
- `project_id`
- `connector`
"""
_conn = self.connector
assert _conn is not None
_project_id = self.project_id
assert _project_id is not None
_url = f"{_conn.base_url}/projects/{_project_id}/stats"
_response = _conn.http_call("get", _url)
# Update object
self.stats = _response.json()
@verify_connector_and_id
def get_file(self, path: str) -> str:
"""
Retrieve a file in the project directory. Beware you have warranty to be able to
access only to file global to the project.
**Required Attributes:**
- `project_id`
- `connector`
- `path`: Project's relative path of the file
"""
_conn = self.connector
assert _conn is not None
_project_id = self.project_id
assert _project_id is not None
_url = f"{_conn.base_url}/projects/{_project_id}/files/{path}"
return cast(str, _conn.http_call("get", _url).text)
@verify_connector_and_id
def write_file(self, path: str, data: Any) -> None:
"""
Places a file content on a specified project file path. Beware you have warranty
to be able to access only to file global to the project.
Example to create a README.txt for the project:
```python
>>> data = '''
This is a README description!
'''
>>> project.write_file(path='README.txt', data=data)
```
**Required Attributes:**
- `project_id`
- `connector`
- `path`: Project's relative path of the file
- `data`: Data to be included in the file
"""
_conn = self.connector
assert _conn is not None
_project_id = self.project_id
assert _project_id is not None
_url = f"{_conn.base_url}/projects/{_project_id}/files/{path}"
_conn.http_call("post", _url, data=data)
@verify_connector_and_id
def get_nodes(self) -> None:
"""
Retrieve the nodes of the project.
**Required Attributes:**
- `project_id`
- `connector`
"""
_conn = self.connector
assert _conn is not None
_project_id = self.project_id
assert _project_id is not None
_url = f"{_conn.base_url}/projects/{_project_id}/nodes"
_response = _conn.http_call("get", _url)
# Create the Nodes array but cleanup cache if there is one
if self.nodes:
self.nodes = []
for _node in _response.json():
_n = Node(connector=self.connector, **_node)
_n.project_id = self.project_id
self.nodes.append(_n)
@verify_connector_and_id
def get_links(self) -> None:
"""
Retrieve the links of the project.
**Required Attributes:**
- `project_id`
- `connector`
"""
_conn = self.connector
assert _conn is not None
_project_id = self.project_id
assert _project_id is not None
_url = f"{_conn.base_url}/projects/{_project_id}/links"
_response = _conn.http_call("get", _url)
# Create the Nodes array but cleanup cache if there is one
if self.links:
self.links = []
for _link in _response.json():
_l = Link(connector=self.connector, **_link)
_l.project_id = self.project_id
self.links.append(_l)
@verify_connector_and_id
def start_nodes(self, poll_wait_time: int = 5) -> None:
"""
Starts all the nodes inside the project.
- `poll_wait_time` is used as a delay when performing the next query of the
nodes status.
**Required Attributes:**
- `project_id`
- `connector`
"""
_conn = self.connector
assert _conn is not None
_project_id = self.project_id
assert _project_id is not None
_url = f"{_conn.base_url}/projects/{_project_id}/nodes/start"
_conn.http_call("post", _url)
# Update object
time.sleep(poll_wait_time)
self.get_nodes()
@verify_connector_and_id
def stop_nodes(self, poll_wait_time: int = 5) -> None:
"""
Stops all the nodes inside the project.
- `poll_wait_time` is used as a delay when performing the next query of the
nodes status.
**Required Attributes:**
- `project_id`
- `connector`
"""
_conn = self.connector
assert _conn is not None
_project_id = self.project_id
assert _project_id is not None
_url = f"{_conn.base_url}/projects/{_project_id}/nodes/stop"
_conn.http_call("post", _url)
# Update object
time.sleep(poll_wait_time)
self.get_nodes()
@verify_connector_and_id
def reload_nodes(self, poll_wait_time: int = 5) -> None:
"""
Reloads all the nodes inside the project.
- `poll_wait_time` is used as a delay when performing the next query of the
nodes status.
**Required Attributes:**
- `project_id`
- `connector`
"""
_conn = self.connector
assert _conn is not None
_project_id = self.project_id
assert _project_id is not None
_url = f"{_conn.base_url}/projects/{_project_id}/nodes/reload"
_conn.http_call("post", _url)
# Update object
time.sleep(poll_wait_time)
self.get_nodes()
@verify_connector_and_id
def suspend_nodes(self, poll_wait_time: int = 5) -> None:
"""
Suspends all the nodes inside the project.
- `poll_wait_time` is used as a delay when performing the next query of the
nodes status.
**Required Attributes:**
- `project_id`
- `connector`
"""
_conn = self.connector
assert _conn is not None
_project_id = self.project_id
assert _project_id is not None
_url = f"{_conn.base_url}/projects/{_project_id}/nodes/suspend"
_conn.http_call("post", _url)
# Update object
time.sleep(poll_wait_time)
self.get_nodes()
def nodes_summary(
self, is_print: bool = True
) -> list[tuple[Any, ...]] | None:
"""
Returns a summary of the nodes insode the project. If `is_print` is `False`, it
will return a list of tuples like:
`[(node_name, node_status, node_console, node_id) ...]`
**Required Attributes:**
- `project_id`
- `connector`
"""
if not self.nodes:
self.get_nodes()
_nodes_summary = []
for _n in self.nodes:
if is_print:
print(
f"{_n.name}: {_n.status} -- Console: {_n.console} -- "
f"ID: {_n.node_id}"
)
_nodes_summary.append((_n.name, _n.status, _n.console, _n.node_id))
return _nodes_summary if not is_print else None
def nodes_inventory(self) -> dict[str | None, Any]:
"""
Returns an inventory-style dictionary of the nodes
Example:
`{
"router01": {
"server": "127.0.0.1",
"name": "router01",
"node_id": uuid,
"console_port": 5077,
"type": "vEOS",
"ports": "[port detila]",
"x": 100,
"y": 200
}
}`
**Required Attributes:**
- `project_id`
- `connector`
"""
if not self.nodes:
self.get_nodes()
_nodes_inventory = {}
conn = self.connector
if not conn:
raise ValueError(
"Gns3Connector not assigned. Please set the connector first."
)
_server = urlparse(conn.base_url).hostname
for _n in self.nodes:
_nodes_inventory.update(
{
_n.name: {
"server": _server,
"name": _n.name,
"node_id": _n.node_id,
"console_port": _n.console,
"console_type": _n.console_type,
"type": _n.node_type,
"ports": _n.ports,
"status": _n.status,
# "template": _n.template,
"x": _n.x,
"y": _n.y,
"tags": _n.tags if _n.tags else [],
}
}
)
return _nodes_inventory
def links_summary(
self, is_print: bool = True
) -> list[dict[str, str]] | None:
"""
Returns a summary of the links inside the project. If `is_print` is False,
it will return a list of dicts like:
`[{"link_id": "xxx", "node_a": "R1", "port_a": "Eth0/0", "node_b": "R2", "port_b": "Eth0/0"}, ...]`
**Required Attributes:**
- `project_id`
- `connector`
"""
# Ensure data is loaded
if not self.nodes:
self.get_nodes()
if not self.links:
self.get_links()
# If None, program errors here instead of continuing
assert self.links is not None, "Links must be loaded"
assert self.nodes is not None, "Nodes must be loaded"
_links_summary: list[dict[str, str]] = []
for _l in self.links:
if not _l.nodes:
continue
_side_a = _l.nodes[0]
_side_b = _l.nodes[1]
try:
# Add type-safe lookup logic
_node_a = next(
x for x in self.nodes if x.node_id == _side_a["node_id"]
)
# Ensure getting str to resolve [return-value] error
_port_a = str(
next(
x["name"]
for x in (_node_a.ports or [])
if x["port_number"] == _side_a["port_number"]
and x["adapter_number"] == _side_a["adapter_number"]
)
)
_node_b = next(
x for x in self.nodes if x.node_id == _side_b["node_id"]
)
_port_b = str(
next(
x["name"]
for x in (_node_b.ports or [])
if x["port_number"] == _side_b["port_number"]
and x["adapter_number"] == _side_b["adapter_number"]
)
)
# Ensure name is not None
name_a = str(_node_a.name) if _node_a.name else "Unknown"
name_b = str(_node_b.name) if _node_b.name else "Unknown"
endpoint_a = f"{name_a}: {_port_a}"
endpoint_b = f"{name_b}: {_port_b}"
if is_print:
print(f"{endpoint_a} ---- {endpoint_b}")
_links_summary.append({
"link_id": _l.link_id,
"node_a": name_a,
"port_a": _port_a,
"node_b": name_b,
"port_b": _port_b
})
except (StopIteration, KeyError, AttributeError):
# Prevent errors when list comprehension can't match data
continue
return _links_summary if not is_print else None
def _search_node(self, key: str, value: Any) -> Any | None:
"Performs a search based on a key and value"
# Retrive nodes if neccesary
if not self.nodes:
self.get_nodes()
try:
return [_p for _p in self.nodes if getattr(_p, key) == value][0]
except IndexError:
return None
def get_node(
self, name: str | None = None, node_id: str | None = None
) -> Any | None:
"""
Returns the Node object by searching for the `name` or the `node_id`.
**Required Attributes:**
- `project_id`
- `connector`
**Required keyword arguments:**
- `name` or `node_id`
**NOTE:** Run method `get_nodes()` manually to refresh list of nodes if
necessary
"""
if node_id:
return self._search_node(key="node_id", value=node_id)
elif name:
return self._search_node(key="name", value=name)
else:
raise ValueError("name or node_ide must be provided")
def _search_link(self, key: str, value: Any) -> Any | None:
"Performs a search based on a key and value"
# Retrive links if neccesary
if not self.links:
self.get_links()
try:
return next(_p for _p in self.links if getattr(_p, key) == value)
except StopIteration:
return None
def get_link(self, link_id: str) -> Any | None:
"""
Returns the Link object by locating its ID
**Required Attributes:**
- `project_id`
- `connector`
- `link_id`
**NOTE:** Run method `get_links()` manually to refresh list of links if
necessary
"""
return self._search_link(key="link_id", value=link_id)
def create_node(self, **kwargs: Any) -> None:
"""
Creates a node. To know available parameters see `Node` object, specifically
the `create` method. The most basic example would be:
```python
project.create_node(name='test-switch01', template='Ethernet switch')
```
**Required Project instance attributes:**
- `project_id`
- `connector`
**Required keyword aguments:**
- `template` or `template_id`
"""
if not self.nodes:
self.get_nodes()
_node = Node(
project_id=self.project_id, connector=self.connector, **kwargs
)
_node.create()
self.nodes.append(_node)
print(
f"Created: {_node.name} -- Type: {_node.node_type} -- "
f"Console: {_node.console}"
)
def create_link(
self, node_a: str, port_a: str, node_b: str, port_b: str
) -> None:
"""
Creates a link.
**Required Attributes:**
- `project_id`
- `connector`
- `node_a`: Node name of the A side
- `port_a`: Port name of the A side (must match the `name` attribute of the
port)
- `node_b`: Node name of the B side
- `port_b`: Port name of the B side (must match the `name` attribute of the
port)
"""
if not self.nodes:
self.get_nodes()
if not self.links:
self.get_links()
_node_a = self.get_node(name=node_a)
if not _node_a:
raise ValueError(f"node_a: {node_a} not found")
try:
_port_a = [_p for _p in _node_a.ports if _p["name"] == port_a][0]
except IndexError:
raise ValueError(f"port_a: {port_a} not found") from None
_node_b = self.get_node(name=node_b)
if not _node_b:
raise ValueError(f"node_b: {node_b} not found")
try:
_port_b = [_p for _p in _node_b.ports if _p["name"] == port_b][0]
except IndexError:
raise ValueError(f"port_b: {port_b} not found") from None
_matches = []
for _l in self.links:
if not _l.nodes:
continue
if (
_l.nodes[0]["node_id"] == _node_a.node_id
and _l.nodes[0]["adapter_number"] == _port_a["adapter_number"]
and _l.nodes[0]["port_number"] == _port_a["port_number"]
):
_matches.append(_l)
elif (
_l.nodes[1]["node_id"] == _node_b.node_id
and _l.nodes[1]["adapter_number"] == _port_b["adapter_number"]
and _l.nodes[1]["port_number"] == _port_b["port_number"]
):
_matches.append(_l) # pragma: no cover
if _matches:
raise ValueError(
f"At least one port is used, ID: {_matches[0].link_id}"
)
# Now create the link!
_link = Link(
project_id=self.project_id,
connector=self.connector,
nodes=[
{
"node_id": _node_a.node_id,
"adapter_number": _port_a["adapter_number"],
"port_number": _port_a["port_number"],
"label": {"text": _port_a.get("short_name") or _port_a["name"]},
},
{
"node_id": _node_b.node_id,
"adapter_number": _port_b["adapter_number"],
"port_number": _port_b["port_number"],
"label": {"text": _port_b.get("short_name") or _port_b["name"]},
},
],
)
_link.create()
self.links.append(_link)
print(f"Created Link-ID: {_link.link_id} -- Type: {_link.link_type}")
def delete_link(
self, node_a: str, port_a: str, node_b: str, port_b: str
) -> None:
"""
Deletes a link.
**Required Attributes:**
- `project_id`
- `connector`
- `node_a`: Node name of the A side
- `port_a`: Port name of the A side (must match the `name` attribute of the
port)
- `node_b`: Node name of the B side
- `port_b`: Port name of the B side (must match the `name` attribute of the
port)
"""
if not self.nodes:
self.get_nodes() # pragma: no cover
if not self.links:
self.get_links() # pragma: no cover
# checking link info
_node_a = self.get_node(name=node_a)
if not _node_a:
raise ValueError(f"node_a: {node_a} not found")
try:
_port_a = [_p for _p in _node_a.ports if _p["name"] == port_a][0]
except IndexError:
raise ValueError(f"port_a: {port_a} not found") from None
_node_b = self.get_node(name=node_b)
if not _node_b:
raise ValueError(f"node_b: {node_b} not found")
try:
_port_b = [_p for _p in _node_b.ports if _p["name"] == port_b][0]
except IndexError:
raise ValueError(f"port_b: {port_b} not found") from None
_matches = []
for _l in self.links:
if not _l.nodes:
continue
if (
_l.nodes[0]["node_id"] == _node_a.node_id
and _l.nodes[0]["adapter_number"] == _port_a["adapter_number"]
and _l.nodes[0]["port_number"] == _port_a["port_number"]
):
_matches.append(_l)
elif (
_l.nodes[1]["node_id"] == _node_b.node_id
and _l.nodes[1]["adapter_number"] == _port_b["adapter_number"]
and _l.nodes[1]["port_number"] == _port_b["port_number"]
):
_matches.append(_l)
if not _matches:
raise ValueError(
f"Link not found: {node_a, port_a, node_b, port_b}"
) # pragma: no cover
# now to delete the link via GNS3_api
_link = _matches[0]
self.links.remove(_link)
_link_id = _link.link_id
_link.delete()
print(
f"Deleted Link-ID: {_link_id} From node {node_a}, port: {port_a} <--> "
f"to node {node_b}, port: {port_b}"
)
@verify_connector_and_id
def get_snapshots(self) -> None:
"""
Retrieves list of snapshots of the project
**Required Project instance attributes:**
- `project_id`
- `connector`
"""
_conn = self.connector
assert _conn is not None
_project_id = self.project_id
assert _project_id is not None
_url = f"{_conn.base_url}/projects/{_project_id}/snapshots"
_response = _conn.http_call("get", _url)
self.snapshots = _response.json()
def _search_snapshot(self, key: str, value: Any) -> dict[str, Any] | None:
"Performs a search based on a key and value"
if not self.snapshots:
self.get_snapshots()
try:
return next(
_p for _p in (self.snapshots or []) if _p[key] == value
)
except StopIteration:
return None
def get_snapshot(
self, name: str | None = None, snapshot_id: str | None = None
) -> dict[str, Any] | None:
"""
Returns the Snapshot by searching for the `name` or the `snapshot_id`.
**Required Attributes:**
- `project_id`
- `connector`
**Required keyword arguments:**
- `name` or `snapshot_id`
"""
if snapshot_id:
return self._search_snapshot(key="snapshot_id", value=snapshot_id)
elif name:
return self._search_snapshot(key="name", value=name)
else:
raise ValueError("name or snapshot_id must be provided")
@verify_connector_and_id
def create_snapshot(self, name: str) -> None:
"""
Creates a snapshot of the project
**Required Project instance attributes:**
- `project_id`
- `connector`
**Required keyword aguments:**
- `name`
"""
_conn = self.connector
assert _conn is not None
_project_id = self.project_id
assert _project_id is not None
self.get_snapshots()
_snapshot = self.get_snapshot(name=name)
if _snapshot:
raise ValueError("Snapshot already created")
_url = f"{_conn.nector.base_url}/projects/{_project_id}/snapshots"
_response = _conn.http_call("post", _url, json_data={"name": name})
_snapshot = _response.json()
if self.snapshots is None:
self.snapshots = []
self.snapshots.append(_snapshot)
print(f"Created snapshot: {_snapshot['name']}")
@verify_connector_and_id
def delete_snapshot(
self, name: str | None = None, snapshot_id: str | None = None
) -> None:
"""
Deletes a snapshot of the project
**Required Project instance attributes:**
- `project_id`
- `connector`
**Required keyword aguments:**
- `name` or `snapshot_id`
"""
_conn = self.connector
assert _conn is not None
_project_id = self.project_id
assert _project_id is not None
self.get_snapshots()
_snapshot = self.get_snapshot(name=name, snapshot_id=snapshot_id)
if not _snapshot:
raise ValueError("Snapshot not found")
_url = (
f"{_conn.base_url}/projects/{_project_id}/snapshots/"
f"{_snapshot['snapshot_id']}"
)
_conn.http_call("delete", _url)
self.get_snapshots()
@verify_connector_and_id
def restore_snapshot(
self, name: str | None = None, snapshot_id: str | None = None
) -> None:
"""
Restore a snapshot from disk
**Required Project instance attributes:**
- `project_id`
- `connector`
**Required keyword aguments:**
- `name` or `snapshot_id`
"""
_conn = self.connector
assert _conn is not None
_project_id = self.project_id
assert _project_id is not None
self.get_snapshots()
_snapshot = self.get_snapshot(name=name, snapshot_id=snapshot_id)
if not _snapshot:
raise ValueError("Snapshot not found")
_url = (
f"{_conn.base_url}/projects/{_project_id}/snapshots/"
f"{_snapshot['snapshot_id']}/restore"
)
_conn.http_call("post", _url)
# Update the whole project
self.get()
def arrange_nodes_circular(self, radius: int = 120) -> None:
"""
Re-arrgange the existing nodes
in a circular fashion
**Attributes:**
- project instance created
**Example**
```python
>>> proj = Project(name='project_name', connector=Gns3connector)
>>> proj.arrange_nodes()
```
"""
self.get()
if self.status != "opened":
self.open() # pragma: no cover
_angle = (2 * pi) / len(self.nodes)
# The Y Axis is inverted in GNS3, so the -Y is UP
for index, n in enumerate(self.nodes):
_x = int(radius * (sin(_angle * index)))
_y = int(radius * (-cos(_angle * index)))
n.update(x=_x, y=_y)
def get_drawing(
self, drawing_id: str | None = None
) -> dict[str, Any] | None:
"""
Returns the drawing by searching for the `svg` or the `drawing_id`.
**Required Attributes:**
- `project_id`
- `connector`
**Required keyword arguments:**
- `svg` or `drawing_id`
"""
if not self.drawings:
self.get_drawings()
try:
return next(
_drawing
for _drawing in (self.drawings or [])
if _drawing["drawing_id"] == drawing_id
)
except (StopIteration, KeyError, TypeError):
return None
@verify_connector_and_id
def get_drawings(self) -> None:
"""
Retrieves list of drawings of the project
**Required Project instance attributes:**
- `project_id`
- `connector`
"""
_conn = self.connector
assert _conn is not None
_project_id = self.project_id
assert _project_id is not None
_url = f"{_conn.base_url}/projects/{_project_id}/drawings"
_response = _conn.http_call("get", _url)
self.drawings = _response.json()
@verify_connector_and_id
def create_drawing(
self,
svg: str,
x: int = 0,
y: int = 0,
z: int = 0,
locked: bool = False,
rotation: int = 0,
) -> dict[str, Any]:
"""
Creates a new drawing in the project
API: POST /v2/projects/{project_id}/drawings
Required Project instance attributes:
- `project_id`
- `connector`
Required parameters:
- `svg`: SVG content string
Optional parameters:
- `x`: X coordinate (default: 0)
- `y`: Y coordinate (default: 0)
- `z`: Z layer (default: 0)
- `locked`: Whether to lock the drawing (default: False)
- `rotation`: Rotation angle in degrees, range -359 to 359 (default: 0)
"""
_conn = self.connector
assert _conn is not None
_project_id = self.project_id
assert _project_id is not None
_url = f"{_conn.base_url}/projects/{_project_id}/drawings"
# Prepare request body
request_body = {
"svg": svg,
"x": x,
"y": y,
"z": z,
"locked": locked,
"rotation": rotation,
}
# Send POST request to create drawing
_response = _conn.http_call("post", _url, json_data=request_body)
# Refresh drawings list
self.get_drawings()
return cast(dict[str, Any], _response.json())
@verify_connector_and_id
def update_drawing(
self,
drawing_id: str,
svg: str | None = None,
locked: bool | None = None,
x: int | None = None,
y: int | None = None,
z: int | None = None,
) -> dict[str, Any]:
_conn = self.connector
assert _conn is not None
_project_id = self.project_id
assert _project_id is not None
_url = f"{_conn.base_url}/projects/{_project_id}/drawings/{drawing_id}"
# Ensure data exists
if not self.drawings:
self.get_drawings()
# Type guard: inform Mypy that self.drawings is now an iterable list
# Use or [] with next to find target object
current_drawing = next(
(
d
for d in (self.drawings or [])
if d.get("drawing_id") == drawing_id
),
None,
)
if current_drawing is None:
raise ValueError(
f"Drawing with ID {drawing_id} not found in project."
)
# If parameter is None, get original value from current object
# This way, Mypy won't report errors for list comprehensions of each field
final_svg = svg if svg is not None else current_drawing.get("svg")
final_locked = (
locked if locked is not None else current_drawing.get("locked")
)
final_x = x if x is not None else current_drawing.get("x")
final_y = y if y is not None else current_drawing.get("y")
final_z = z if z is not None else current_drawing.get("z")
# Execute update
response = _conn.http_call(
"put",
_url,
json_data={
"svg": final_svg,
"locked": final_locked,
"x": final_x,
"y": final_y,
"z": final_z,
},
)
# Update local cache
self.get_drawings()
return cast(dict[str, Any], response.json())
@verify_connector_and_id
def delete_drawing(self, drawing_id: str | None = None) -> None:
"""
Deletes a drawing of the project
**Required Project instance attributes:**
- `project_id`
- `connector`
**Required keyword aguments:**
- `drawing_id`
"""
_conn = self.connector
assert _conn is not None
_project_id = self.project_id
assert _project_id is not None
self.get_drawings()
_drawing = self.get_drawing(drawing_id=drawing_id)
if not _drawing:
raise ValueError("drawing not found")
_url = (
f"{_conn.base_url}/projects/{_project_id}/drawings/"
f"{_drawing['drawing_id']}"
)
_conn.http_call("delete", _url)
self.get_drawings()
@verify_connector_and_id
def get_locked(self) -> bool:
"""
Retrieve locked status of the project.
Returns whether the project is locked or not.
API: GET /v3/projects/{project_id}/locked
Required Attributes:
- `project_id`
- `connector`
Returns:
bool: True if project is locked, False otherwise
Raises:
ValueError: If called with GNS3 API v2 (not supported)
Note:
This method is only available in GNS3 v3 API
"""
_conn = self.connector
assert _conn is not None
_project_id = self.project_id
assert _project_id is not None
# Check API version - only v3 supports lock operations
if _conn.api_version != 3:
raise ValueError(
"Project lock/unlock operations are only supported in GNS3 API v3. "
f"Current API version: v{_conn.api_version}"
)
_url = f"{_conn.base_url}/projects/{_project_id}/locked"
_response = _conn.http_call("get", _url)
locked_status = cast(bool, _response.json())
# Update the locked attribute
self.locked = locked_status
return locked_status
@verify_connector_and_id
def lock_project(self) -> None:
"""
Lock all drawings and nodes in the project.
API: POST /v3/projects/{project_id}/lock
Required Attributes:
- `project_id`
- `connector`
Raises:
ValueError: If called with GNS3 API v2 (not supported)
Note:
This method is only available in GNS3 v3 API
Returns 204 on success (no content)
"""
_conn = self.connector
assert _conn is not None
_project_id = self.project_id
assert _project_id is not None
# Check API version - only v3 supports lock operations
if _conn.api_version != 3:
raise ValueError(
"Project lock/unlock operations are only supported in GNS3 API v3. "
f"Current API version: v{_conn.api_version}"
)
_url = f"{_conn.base_url}/projects/{_project_id}/lock"
_conn.http_call("post", _url)
# Update the locked attribute
self.locked = True
@verify_connector_and_id
def unlock_project(self) -> None:
"""
Unlock all drawings and nodes in the project.
API: POST /v3/projects/{project_id}/unlock
Required Attributes:
- `project_id`
- `connector`
Raises:
ValueError: If called with GNS3 API v2 (not supported)
Note:
This method is only available in GNS3 v3 API
Returns 204 on success (no content)
"""
_conn = self.connector
assert _conn is not None
_project_id = self.project_id
assert _project_id is not None
# Check API version - only v3 supports lock operations
if _conn.api_version != 3:
raise ValueError(
"Project lock/unlock operations are only supported in GNS3 API v3. "
f"Current API version: v{_conn.api_version}"
)
_url = f"{_conn.base_url}/projects/{_project_id}/unlock"
_conn.http_call("post", _url)
# Update the locked attribute
self.locked = False