mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-11-05 23:01:15 +02:00
refactor: ruff autofixes
This commit is contained in:
455 files changed
+12322
-14762
No files matched your search
@@ -70,22 +70,53 @@ MAX_NODE_FILE_BYTES = 50 * 1024 # 50 KiB
|
||||
|
||||
VALID_NODE_FIELDS = {
|
||||
# NodeBase
|
||||
"compute_id", "name", "node_type", "node_id",
|
||||
"console", "console_type", "console_auto_start",
|
||||
"aux", "aux_type", "properties", "label", "symbol",
|
||||
"x", "y", "z", "locked",
|
||||
"port_name_format", "port_segment_size", "first_port_name",
|
||||
"custom_adapters", "tags",
|
||||
"compute_id",
|
||||
"name",
|
||||
"node_type",
|
||||
"node_id",
|
||||
"console",
|
||||
"console_type",
|
||||
"console_auto_start",
|
||||
"aux",
|
||||
"aux_type",
|
||||
"properties",
|
||||
"label",
|
||||
"symbol",
|
||||
"x",
|
||||
"y",
|
||||
"z",
|
||||
"locked",
|
||||
"port_name_format",
|
||||
"port_segment_size",
|
||||
"first_port_name",
|
||||
"custom_adapters",
|
||||
"tags",
|
||||
# Node
|
||||
"template_id", "project_id", "node_directory", "status",
|
||||
"command_line", "width", "height", "ports", "console_host",
|
||||
"template_id",
|
||||
"project_id",
|
||||
"node_directory",
|
||||
"status",
|
||||
"command_line",
|
||||
"width",
|
||||
"height",
|
||||
"ports",
|
||||
"console_host",
|
||||
}
|
||||
|
||||
VALID_LINK_FIELDS = {
|
||||
"link_id", "project_id", "link_type", "nodes", "suspend",
|
||||
"link_style", "filters", "show_filters_icon",
|
||||
"capturing", "capture_file_name", "capture_file_path",
|
||||
"capture_compute_id", "wireshark",
|
||||
"link_id",
|
||||
"project_id",
|
||||
"link_type",
|
||||
"nodes",
|
||||
"suspend",
|
||||
"link_style",
|
||||
"filters",
|
||||
"show_filters_icon",
|
||||
"capturing",
|
||||
"capture_file_name",
|
||||
"capture_file_path",
|
||||
"capture_compute_id",
|
||||
"wireshark",
|
||||
}
|
||||
|
||||
LINK_DEFAULT_FIELDS = ["link_id", "link_type", "nodes"]
|
||||
@@ -93,6 +124,7 @@ LINK_DEFAULT_FIELDS = ["link_id", "link_type", "nodes"]
|
||||
|
||||
# ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _get_connector(gns3_ctx: dict[str, Any]):
|
||||
return Gns3Connector(
|
||||
url=gns3_ctx["server_url"],
|
||||
@@ -102,9 +134,7 @@ def _get_connector(gns3_ctx: dict[str, Any]):
|
||||
)
|
||||
|
||||
|
||||
def build_gns3_ctx(
|
||||
jwt_token: str | None = None, url: str | None = None
|
||||
) -> dict[str, Any] | None:
|
||||
def build_gns3_ctx(jwt_token: str | None = None, url: str | None = None) -> dict[str, Any] | None:
|
||||
"""
|
||||
Build a handler ``gns3_ctx`` for in-process copilot callers.
|
||||
|
||||
@@ -183,13 +213,14 @@ def _normalize_link_nodes(nodes) -> list[dict[str, Any]]:
|
||||
]
|
||||
raise ValueError(
|
||||
f"Unrecognized link nodes format. "
|
||||
f"Use standard [{{\"node_id\":\"..\",\"adapter_number\":0,\"port_number\":0}},...] "
|
||||
f"or compact [\"id\",0,0,\"id\",0,0], got: {nodes}"
|
||||
f'Use standard [{{"node_id":"..","adapter_number":0,"port_number":0}},...] '
|
||||
f'or compact ["id",0,0,"id",0,0], got: {nodes}'
|
||||
)
|
||||
|
||||
|
||||
# ── Node handlers ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def get_nodes_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
@@ -199,7 +230,7 @@ def get_nodes_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[
|
||||
fields = params.get("fields")
|
||||
if fields:
|
||||
if not isinstance(fields, list):
|
||||
return {"error": "fields must be a list of field names, e.g. [\"name\", \"status\"]"}
|
||||
return {"error": 'fields must be a list of field names, e.g. ["name", "status"]'}
|
||||
invalid = [f for f in fields if f not in VALID_NODE_FIELDS]
|
||||
if invalid:
|
||||
return {
|
||||
@@ -221,7 +252,7 @@ def get_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[s
|
||||
fields = params.get("fields")
|
||||
if fields:
|
||||
if not isinstance(fields, list):
|
||||
return {"error": "fields must be a list of field names, e.g. [\"name\", \"status\"]"}
|
||||
return {"error": 'fields must be a list of field names, e.g. ["name", "status"]'}
|
||||
invalid = [f for f in fields if f not in VALID_NODE_FIELDS]
|
||||
if invalid:
|
||||
return {
|
||||
@@ -235,12 +266,14 @@ def get_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[s
|
||||
|
||||
def _batch_lifecycle(project_id, node_ids, action, conn, action_label):
|
||||
"""Helper to run a lifecycle action on multiple nodes in parallel."""
|
||||
|
||||
def _act(nid):
|
||||
try:
|
||||
conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/{nid}/{action}")
|
||||
return {"node_id": nid, "status": "success", "message": f"Node {nid} {action_label}"}
|
||||
except Exception as e:
|
||||
return {"node_id": nid, "status": "error", "error": str(e)}
|
||||
|
||||
with ThreadPoolExecutor(max_workers=min(len(node_ids), BATCH_MAX_WORKERS)) as pool:
|
||||
return list(pool.map(_act, node_ids))
|
||||
|
||||
@@ -307,7 +340,7 @@ def create_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic
|
||||
|
||||
fields = params.get("fields")
|
||||
if fields is not None and not isinstance(fields, list):
|
||||
return {"error": "fields must be a list, e.g. [\"node_id\", \"name\"]"}
|
||||
return {"error": 'fields must be a list, e.g. ["node_id", "name"]'}
|
||||
|
||||
nodes = params.get("nodes")
|
||||
# Batch mode: nodes=[{template_id?, x, y, name?, compute_id?}]
|
||||
@@ -317,6 +350,7 @@ def create_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic
|
||||
return {"error": "nodes must be a non-empty array"}
|
||||
default_tid = params.get("template_id")
|
||||
conn = _get_connector(gns3_ctx)
|
||||
|
||||
def _create_one(node_data):
|
||||
tid = node_data.get("template_id", default_tid)
|
||||
if not tid:
|
||||
@@ -335,6 +369,7 @@ def create_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic
|
||||
return {"template_id": tid, "status": "success", "node": _filter_node_response(resp, fields)}
|
||||
except Exception as e:
|
||||
return {"template_id": tid, "status": "error", "error": str(e)}
|
||||
|
||||
if any(not node.get("name") for node in nodes):
|
||||
# The controller assigns default names (R-1, R-2, ...) and console
|
||||
# ports in request arrival order, and a parallel fan-out makes the
|
||||
@@ -375,12 +410,14 @@ def delete_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic
|
||||
if not isinstance(node_ids, list):
|
||||
return {"error": "node_ids must be a list"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
|
||||
def _del(nid):
|
||||
try:
|
||||
conn.http_call("delete", f"{conn.base_url}/projects/{project_id}/nodes/{nid}")
|
||||
return {"node_id": nid, "status": "success", "message": f"Node {nid} deleted"}
|
||||
except Exception as e:
|
||||
return {"node_id": nid, "status": "error", "error": str(e)}
|
||||
|
||||
with ThreadPoolExecutor(max_workers=min(len(node_ids), BATCH_MAX_WORKERS)) as pool:
|
||||
return list(pool.map(_del, node_ids))
|
||||
node_id = params.get("node_id")
|
||||
@@ -423,12 +460,16 @@ def get_node_console_info_handler(params: dict[str, Any], gns3_ctx: dict[str, An
|
||||
# the ~200-char JWT previously embedded here (dropped header segment →
|
||||
# "Missing 'alg' value in header" on the server).
|
||||
username = gns3_ctx.get("jwt_username")
|
||||
ticket = access_ticket_service.mint(
|
||||
username,
|
||||
token_version=gns3_ctx.get("jwt_token_version", 0),
|
||||
project_id=project_id,
|
||||
node_id=node_id,
|
||||
) if username else None
|
||||
ticket = (
|
||||
access_ticket_service.mint(
|
||||
username,
|
||||
token_version=gns3_ctx.get("jwt_token_version", 0),
|
||||
project_id=project_id,
|
||||
node_id=node_id,
|
||||
)
|
||||
if username
|
||||
else None
|
||||
)
|
||||
raw_url = f"{gns3_ctx['server_url']}/v3/projects/{project_id}/nodes/{node_id}/console/ws"
|
||||
if ticket:
|
||||
raw_url += f"?token={ticket}"
|
||||
@@ -499,7 +540,7 @@ def get_node_file_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> d
|
||||
total_lines = len(lines)
|
||||
|
||||
# Apply offset/limit
|
||||
selected = lines[offset: offset + limit] if offset < total_lines else []
|
||||
selected = lines[offset : offset + limit] if offset < total_lines else []
|
||||
has_more = (offset + limit) < total_lines or truncated
|
||||
content = "".join(selected)
|
||||
|
||||
@@ -581,7 +622,9 @@ def duplicate_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) ->
|
||||
return {"error": "project_id and node_id are required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
data = {k: v for k, v in params.items() if k not in ("project_id", "node_id") and v is not None}
|
||||
result = conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/duplicate", json_data=data).json()
|
||||
result = conn.http_call(
|
||||
"post", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/duplicate", json_data=data
|
||||
).json()
|
||||
return {"message": f"Node {node_id} duplicated", "node": result}
|
||||
|
||||
|
||||
@@ -617,6 +660,7 @@ def get_node_links_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) ->
|
||||
|
||||
# ── Link handlers ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def get_links_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
@@ -626,7 +670,7 @@ def get_links_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[
|
||||
fields = params.get("fields")
|
||||
if fields:
|
||||
if not isinstance(fields, list):
|
||||
return {"error": "fields must be a list, e.g. [\"link_id\", \"nodes\"]"}
|
||||
return {"error": 'fields must be a list, e.g. ["link_id", "nodes"]'}
|
||||
invalid = [f for f in fields if f not in VALID_LINK_FIELDS]
|
||||
if invalid:
|
||||
return {
|
||||
@@ -669,7 +713,7 @@ def create_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic
|
||||
|
||||
fields = params.get("fields")
|
||||
if fields is not None and not isinstance(fields, list):
|
||||
return {"error": "fields must be a list, e.g. [\"link_id\", \"nodes\"]"}
|
||||
return {"error": 'fields must be a list, e.g. ["link_id", "nodes"]'}
|
||||
|
||||
links = params.get("links")
|
||||
# Batch mode: links=[{nodes, link_type?, filters?, suspend?}]
|
||||
@@ -677,6 +721,7 @@ def create_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic
|
||||
if not isinstance(links, list) or not links:
|
||||
return {"error": "links must be a non-empty array"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
|
||||
def _create_one(link_data):
|
||||
raw_nodes = link_data.get("nodes")
|
||||
if not raw_nodes:
|
||||
@@ -694,6 +739,7 @@ def create_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic
|
||||
return {"status": "success", "link": _filter_link_response(resp, fields)}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
with ThreadPoolExecutor(max_workers=min(len(links), BATCH_MAX_WORKERS)) as pool:
|
||||
# pool.map keeps the submission order, so callers can correlate
|
||||
# results with the links they sent regardless of completion order
|
||||
@@ -725,12 +771,14 @@ def delete_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic
|
||||
if not isinstance(link_ids, list):
|
||||
return {"error": "link_ids must be a list"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
|
||||
def _del(lid):
|
||||
try:
|
||||
conn.http_call("delete", f"{conn.base_url}/projects/{project_id}/links/{lid}")
|
||||
return {"link_id": lid, "status": "success", "message": f"Link {lid} deleted"}
|
||||
except Exception as e:
|
||||
return {"link_id": lid, "status": "error", "error": str(e)}
|
||||
|
||||
with ThreadPoolExecutor(max_workers=min(len(link_ids), BATCH_MAX_WORKERS)) as pool:
|
||||
return list(pool.map(_del, link_ids))
|
||||
link_id = params.get("link_id")
|
||||
@@ -770,6 +818,7 @@ def reset_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict
|
||||
if not isinstance(link_ids, list):
|
||||
return {"error": "link_ids must be a list"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
|
||||
def _rst(lid):
|
||||
try:
|
||||
url = f"{conn.base_url}/projects/{project_id}/links/{lid}/reset"
|
||||
@@ -777,6 +826,7 @@ def reset_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict
|
||||
return {"link_id": lid, "status": "reset", "link": r}
|
||||
except Exception as e:
|
||||
return {"link_id": lid, "status": "error", "error": str(e)}
|
||||
|
||||
with ThreadPoolExecutor(max_workers=min(len(link_ids), BATCH_MAX_WORKERS)) as pool:
|
||||
return list(pool.map(_rst, link_ids))
|
||||
link_id = params.get("link_id")
|
||||
@@ -790,6 +840,7 @@ def reset_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict
|
||||
|
||||
def _batch_capture(project_id, link_ids, action, data_builder, conn):
|
||||
"""Helper for batch capture start/stop."""
|
||||
|
||||
def _act(lid):
|
||||
try:
|
||||
url = f"{conn.base_url}/projects/{project_id}/links/{lid}/capture/{action}"
|
||||
@@ -798,6 +849,7 @@ def _batch_capture(project_id, link_ids, action, data_builder, conn):
|
||||
return {"link_id": lid, "status": "success"}
|
||||
except Exception as e:
|
||||
return {"link_id": lid, "status": "error", "error": str(e)}
|
||||
|
||||
with ThreadPoolExecutor(max_workers=min(len(link_ids), BATCH_MAX_WORKERS)) as pool:
|
||||
return list(pool.map(_act, link_ids))
|
||||
|
||||
@@ -814,11 +866,13 @@ def start_capture_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> d
|
||||
dlt = params.get("data_link_type", "DLT_EN10MB")
|
||||
ws = params.get("wireshark", False)
|
||||
fname = params.get("capture_file_name")
|
||||
|
||||
def _build(lid):
|
||||
data = {"data_link_type": dlt, "wireshark": ws}
|
||||
if fname:
|
||||
data["capture_file_name"] = fname
|
||||
return {"json_data": data}
|
||||
|
||||
return _batch_capture(project_id, link_ids, "start", _build, conn)
|
||||
link_id = params.get("link_id")
|
||||
if not link_id:
|
||||
@@ -879,7 +933,11 @@ def download_capture_file_handler(params: dict[str, Any], gns3_ctx: dict[str, An
|
||||
if not isinstance(link_ids, list):
|
||||
return {"error": "link_ids must be a list"}
|
||||
results = [_download(lid) for lid in link_ids]
|
||||
return {"downloads": results, "count": len(results), "note": "Files are in pcap format. URLs include a 10-minute ticket."}
|
||||
return {
|
||||
"downloads": results,
|
||||
"count": len(results),
|
||||
"note": "Files are in pcap format. URLs include a 10-minute ticket.",
|
||||
}
|
||||
|
||||
link_id = params.get("link_id")
|
||||
if not link_id:
|
||||
@@ -946,12 +1004,18 @@ def link_marker_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic
|
||||
elif direction in ("tx", "rx"):
|
||||
body["direction"] = direction
|
||||
if not body:
|
||||
return {"error": "At least one update field is required (bpf, tag, enabled, direction, color, highlight_duration)"}
|
||||
return {
|
||||
"error": "At least one update field is required (bpf, tag, enabled, direction, color, highlight_duration)"
|
||||
}
|
||||
return conn.http_call("put", url, json_data=body).json()
|
||||
|
||||
# action == "delete"
|
||||
conn.http_call("delete", url)
|
||||
return {"message": f"Marker '{marker_name}' deleted from link {link_id}", "link_id": link_id, "marker_name": marker_name}
|
||||
return {
|
||||
"message": f"Marker '{marker_name}' deleted from link {link_id}",
|
||||
"link_id": link_id,
|
||||
"marker_name": marker_name,
|
||||
}
|
||||
|
||||
|
||||
def marker_definition_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
@@ -1002,7 +1066,9 @@ def marker_definition_handler(params: dict[str, Any], gns3_ctx: dict[str, Any])
|
||||
if params.get(opt) is not None:
|
||||
body[opt] = params[opt]
|
||||
if not body:
|
||||
return {"error": "At least one update field is required (bpf, tag, color, highlight_duration, data_link_type)"}
|
||||
return {
|
||||
"error": "At least one update field is required (bpf, tag, color, highlight_duration, data_link_type)"
|
||||
}
|
||||
return conn.http_call("put", url, json_data=body).json()
|
||||
|
||||
# action == "delete"
|
||||
|
||||
@@ -139,17 +139,11 @@ class Gns3Connector:
|
||||
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
|
||||
):
|
||||
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}"
|
||||
)
|
||||
self.session.headers["Authorization"] = f"Bearer {self.access_token}"
|
||||
|
||||
def _authenticate_v3(self) -> None:
|
||||
"""
|
||||
@@ -161,15 +155,10 @@ class Gns3Connector:
|
||||
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"
|
||||
)
|
||||
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_url = f"{self.base_url.replace('/v3', '')}/v3/access/users/authenticate"
|
||||
auth_data = {"username": self.user, "password": self.cred}
|
||||
|
||||
# Use temporary session for authentication
|
||||
@@ -177,21 +166,14 @@ class Gns3Connector:
|
||||
temp_session.headers["Content-Type"] = "application/json"
|
||||
|
||||
try:
|
||||
response = temp_session.post(
|
||||
auth_url, json=auth_data, verify=self.verify, timeout=10.0
|
||||
)
|
||||
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}"
|
||||
)
|
||||
self.session.headers["Authorization"] = f"Bearer {self.access_token}"
|
||||
else:
|
||||
raise HTTPError(
|
||||
f"v3 API authentication failed: {response.status_code} - "
|
||||
f"{response.text}"
|
||||
)
|
||||
raise HTTPError(f"v3 API authentication failed: {response.status_code} - {response.text}")
|
||||
except Exception as e:
|
||||
raise HTTPError(f"v3 API authentication error: {str(e)}") from e
|
||||
|
||||
@@ -205,9 +187,7 @@ class Gns3Connector:
|
||||
|
||||
try:
|
||||
# Decode token without verification to check expiry
|
||||
decoded: dict[str, Any] = jwt.decode(
|
||||
token, options={"verify_signature": False}
|
||||
)
|
||||
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)
|
||||
@@ -237,12 +217,7 @@ class Gns3Connector:
|
||||
"""
|
||||
|
||||
# Handle JWT authentication
|
||||
if (
|
||||
self.auth_type == "jwt"
|
||||
and not self.access_token
|
||||
and self.user
|
||||
and self.cred
|
||||
):
|
||||
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)
|
||||
@@ -285,15 +260,10 @@ class Gns3Connector:
|
||||
|
||||
try:
|
||||
# Only attempt parsing when Content-Type is JSON
|
||||
if (
|
||||
"application/json"
|
||||
in response.headers.get("Content-Type", "").lower()
|
||||
):
|
||||
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."
|
||||
)
|
||||
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)",
|
||||
|
||||
@@ -76,10 +76,7 @@ def _get_url_from_controller() -> Optional[str]:
|
||||
controller = Controller.instance()
|
||||
local_compute = controller.get_compute("local")
|
||||
|
||||
url = (
|
||||
f"{local_compute.protocol}://{local_compute.host}:"
|
||||
f"{local_compute.port}"
|
||||
)
|
||||
url = f"{local_compute.protocol}://{local_compute.host}:{local_compute.port}"
|
||||
logger.debug(
|
||||
"Got GNS3 URL from Controller: %s (protocol=%s, host=%s, port=%s)",
|
||||
url,
|
||||
@@ -98,9 +95,7 @@ def _get_url_from_controller() -> Optional[str]:
|
||||
logger.debug("Local compute not found in Controller: %s", str(e))
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Unexpected error getting URL from Controller: %s", str(e)
|
||||
)
|
||||
logger.warning("Unexpected error getting URL from Controller: %s", str(e))
|
||||
return None
|
||||
|
||||
|
||||
@@ -114,10 +109,7 @@ def _get_url_from_config() -> Optional[str]:
|
||||
from gns3server.config import Config
|
||||
|
||||
server_config = Config.instance().settings.Server
|
||||
url = (
|
||||
f"{server_config.protocol.value}://{server_config.host}:"
|
||||
f"{server_config.port}"
|
||||
)
|
||||
url = f"{server_config.protocol.value}://{server_config.host}:{server_config.port}"
|
||||
logger.debug(
|
||||
"Got GNS3 URL from Config: %s (protocol=%s, host=%s, port=%s)",
|
||||
url,
|
||||
@@ -137,9 +129,7 @@ def _get_url_from_config() -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def get_gns3_connector(
|
||||
jwt_token: Optional[str] = None, url: Optional[str] = None
|
||||
) -> Optional[Gns3Connector]:
|
||||
def get_gns3_connector(jwt_token: Optional[str] = None, url: Optional[str] = None) -> Optional[Gns3Connector]:
|
||||
"""Create and return a Gns3Connector instance with JWT authentication.
|
||||
|
||||
URL Resolution Strategy (in order):
|
||||
@@ -223,9 +213,7 @@ def get_gns3_connector(
|
||||
return connector
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to create Gns3Connector: %s", str(e), exc_info=True
|
||||
)
|
||||
logger.error("Failed to create Gns3Connector: %s", str(e), exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
@@ -284,9 +272,7 @@ async def get_gns3_connector_with_llm_config(
|
||||
url = _detect_url_for_api()
|
||||
|
||||
# Step 3: Get LLM config
|
||||
llm_config = get_llm_config(
|
||||
user_id=user_id, jwt_token=jwt_token, app=app
|
||||
)
|
||||
llm_config = get_llm_config(user_id=user_id, jwt_token=jwt_token, app=app)
|
||||
|
||||
if not llm_config:
|
||||
logger.warning(f"No LLM config found for user {user_id}")
|
||||
@@ -303,9 +289,7 @@ async def get_gns3_connector_with_llm_config(
|
||||
return {"connector": connector, "llm_config": llm_config}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to get GNS3 connector with LLM config: {e}", exc_info=True
|
||||
)
|
||||
logger.error(f"Failed to get GNS3 connector with LLM config: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
@@ -375,9 +359,7 @@ def get_gns3_server_host() -> str:
|
||||
logger.debug("Extracted GNS3 server host: %s from URL: %s", host, url)
|
||||
return host
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to extract host from URL %s: %s, using fallback", url, e
|
||||
)
|
||||
logger.warning("Failed to extract host from URL %s: %s, using fallback", url, e)
|
||||
return DEFAULT_GNS3_URL.split("://")[1].split(":")[0]
|
||||
|
||||
|
||||
@@ -428,9 +410,7 @@ def get_llm_config(user_id, jwt_token: str, app=None) -> Optional[dict]:
|
||||
# We're in an async context with a running loop
|
||||
# This shouldn't happen since this is a sync function
|
||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||
future = executor.submit(
|
||||
asyncio.run, get_user_llm_config_with_app(user_id, app)
|
||||
)
|
||||
future = executor.submit(asyncio.run, get_user_llm_config_with_app(user_id, app))
|
||||
return future.result(timeout=10)
|
||||
except RuntimeError:
|
||||
# No running event loop - we're in a sync context
|
||||
@@ -449,26 +429,19 @@ def get_llm_config(user_id, jwt_token: str, app=None) -> Optional[dict]:
|
||||
return future.result(timeout=10)
|
||||
else:
|
||||
# Loop exists but not running - use it
|
||||
return loop.run_until_complete(
|
||||
get_user_llm_config_with_app(user_id, app)
|
||||
)
|
||||
return loop.run_until_complete(get_user_llm_config_with_app(user_id, app))
|
||||
except RuntimeError:
|
||||
# No event loop exists - create a new one
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
return loop.run_until_complete(
|
||||
get_user_llm_config_with_app(user_id, app)
|
||||
)
|
||||
return loop.run_until_complete(get_user_llm_config_with_app(user_id, app))
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
# Fallback: No app provided, try API call (will mask group config API
|
||||
# keys)
|
||||
logger.warning(
|
||||
"No app provided for get_llm_config, group config API keys may be "
|
||||
"masked"
|
||||
)
|
||||
logger.warning("No app provided for get_llm_config, group config API keys may be masked")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -62,12 +62,8 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# Context variables for request-scoped data
|
||||
# Automatically cleaned up when request context ends
|
||||
_jwt_token_context: ContextVar[Optional[str]] = ContextVar(
|
||||
"_jwt_token_context", default=None
|
||||
)
|
||||
_llm_config_context: ContextVar[Optional[dict]] = ContextVar(
|
||||
"_llm_config_context", default=None
|
||||
)
|
||||
_jwt_token_context: ContextVar[Optional[str]] = ContextVar("_jwt_token_context", default=None)
|
||||
_llm_config_context: ContextVar[Optional[dict]] = ContextVar("_llm_config_context", default=None)
|
||||
|
||||
|
||||
def set_current_jwt_token(token: str) -> None:
|
||||
|
||||
@@ -90,20 +90,13 @@ class GNS3ProjectInfoTool(BaseTool):
|
||||
"""
|
||||
|
||||
# Log received input
|
||||
logger.info(
|
||||
"Received tool_input: %s, project_id: %s", tool_input, project_id
|
||||
)
|
||||
logger.info("Received tool_input: %s, project_id: %s", tool_input, project_id)
|
||||
|
||||
try:
|
||||
# Validate project_id parameter
|
||||
if not project_id:
|
||||
logger.error("project_id parameter is required.")
|
||||
return {
|
||||
"error": (
|
||||
"project_id parameter is required. Please provide a valid "
|
||||
"project UUID."
|
||||
)
|
||||
}
|
||||
return {"error": ("project_id parameter is required. Please provide a valid project UUID.")}
|
||||
|
||||
# Build handler context (JWT + server URL from request context)
|
||||
logger.debug("Connecting to GNS3 server...")
|
||||
@@ -111,17 +104,10 @@ class GNS3ProjectInfoTool(BaseTool):
|
||||
|
||||
if gns3_ctx is None:
|
||||
logger.error("Failed to create GNS3 connector")
|
||||
return {
|
||||
"error": (
|
||||
"Failed to connect to GNS3 server. Please check your "
|
||||
"configuration."
|
||||
)
|
||||
}
|
||||
return {"error": ("Failed to connect to GNS3 server. Please check your configuration.")}
|
||||
|
||||
# Use the provided project_id directly
|
||||
logger.info(
|
||||
f"Retrieving project info for project_id: {project_id}"
|
||||
)
|
||||
logger.info(f"Retrieving project info for project_id: {project_id}")
|
||||
inventory = fetch_project_inventory(gns3_ctx, project_id)
|
||||
|
||||
# Get node and link counts
|
||||
|
||||
@@ -100,18 +100,13 @@ class GNS3TopologyTool(BaseTool):
|
||||
"""
|
||||
|
||||
# Log received input
|
||||
logger.info(
|
||||
"Received tool_input: %s, project_id: %s", tool_input, project_id
|
||||
)
|
||||
logger.info("Received tool_input: %s, project_id: %s", tool_input, project_id)
|
||||
|
||||
try:
|
||||
# Validate project_id parameter
|
||||
if not project_id:
|
||||
logger.error("project_id parameter is required.")
|
||||
return {
|
||||
"error": "project_id parameter is required. "
|
||||
"Please provide a valid project UUID."
|
||||
}
|
||||
return {"error": "project_id parameter is required. Please provide a valid project UUID."}
|
||||
|
||||
# Build handler context (JWT + server URL)
|
||||
# jwt_token/url can be passed explicitly (e.g. from MCP handlers)
|
||||
@@ -121,10 +116,7 @@ class GNS3TopologyTool(BaseTool):
|
||||
|
||||
if gns3_ctx is None:
|
||||
logger.error("Failed to create GNS3 connector")
|
||||
return {
|
||||
"error": "Failed to connect to GNS3 server. Please check "
|
||||
"your configuration."
|
||||
}
|
||||
return {"error": "Failed to connect to GNS3 server. Please check your configuration."}
|
||||
|
||||
# Use the provided project_id directly
|
||||
logger.info(f"Retrieving topology for project_id: {project_id}")
|
||||
@@ -135,16 +127,13 @@ class GNS3TopologyTool(BaseTool):
|
||||
"project_id": inventory["project_id"],
|
||||
"name": inventory["name"],
|
||||
"status": inventory["status"],
|
||||
"nodes": self._clean_nodes_ports(
|
||||
copy.deepcopy(inventory["nodes_inventory"])
|
||||
),
|
||||
"nodes": self._clean_nodes_ports(copy.deepcopy(inventory["nodes_inventory"])),
|
||||
"links": inventory["links_summary"],
|
||||
}
|
||||
|
||||
# Log topology result
|
||||
logger.info(
|
||||
"Topology retrieved: project_id=%s, name=%s, nodes=%d, "
|
||||
"links=%d",
|
||||
"Topology retrieved: project_id=%s, name=%s, nodes=%d, links=%d",
|
||||
topology.get("project_id"),
|
||||
topology.get("name"),
|
||||
len(topology.get("nodes", {})),
|
||||
@@ -167,10 +156,7 @@ class GNS3TopologyTool(BaseTool):
|
||||
"""
|
||||
for node in data.values(): # Iterate through R-1, R-2, R-3, R-4
|
||||
if "ports" in node and isinstance(node["ports"], list):
|
||||
node["ports"] = [
|
||||
{"name": port["name"], "short_name": port["short_name"]}
|
||||
for port in node["ports"]
|
||||
]
|
||||
node["ports"] = [{"name": port["name"], "short_name": port["short_name"]} for port in node["ports"]]
|
||||
return data
|
||||
|
||||
|
||||
|
||||
@@ -38,9 +38,7 @@ from urllib.parse import urlparse
|
||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import _get_connector
|
||||
|
||||
|
||||
def build_nodes_inventory(
|
||||
nodes: list[dict[str, Any]], server_host: str | None
|
||||
) -> dict[str, Any]:
|
||||
def build_nodes_inventory(nodes: list[dict[str, Any]], server_host: str | None) -> dict[str, Any]:
|
||||
"""
|
||||
Build an inventory-style dict keyed by node name.
|
||||
|
||||
@@ -70,9 +68,7 @@ def build_nodes_inventory(
|
||||
return inventory
|
||||
|
||||
|
||||
def build_links_summary(
|
||||
nodes: list[dict[str, Any]], links: list[dict[str, Any]]
|
||||
) -> list[dict[str, str]]:
|
||||
def build_links_summary(nodes: list[dict[str, Any]], links: list[dict[str, Any]]) -> list[dict[str, str]]:
|
||||
"""
|
||||
Build a human/LLM-friendly link list resolving node and port names.
|
||||
|
||||
@@ -87,46 +83,40 @@ def build_links_summary(
|
||||
side_a = link["nodes"][0]
|
||||
side_b = link["nodes"][1]
|
||||
try:
|
||||
node_a = next(
|
||||
x for x in nodes if x.get("node_id") == side_a["node_id"]
|
||||
)
|
||||
node_a = next(x for x in nodes if x.get("node_id") == side_a["node_id"])
|
||||
port_a = str(
|
||||
next(
|
||||
p["name"]
|
||||
for p in (node_a.get("ports") or [])
|
||||
if p["port_number"] == side_a["port_number"]
|
||||
and p["adapter_number"] == side_a["adapter_number"]
|
||||
if p["port_number"] == side_a["port_number"] and p["adapter_number"] == side_a["adapter_number"]
|
||||
)
|
||||
)
|
||||
node_b = next(
|
||||
x for x in nodes if x.get("node_id") == side_b["node_id"]
|
||||
)
|
||||
node_b = next(x for x in nodes if x.get("node_id") == side_b["node_id"])
|
||||
port_b = str(
|
||||
next(
|
||||
p["name"]
|
||||
for p in (node_b.get("ports") or [])
|
||||
if p["port_number"] == side_b["port_number"]
|
||||
and p["adapter_number"] == side_b["adapter_number"]
|
||||
if p["port_number"] == side_b["port_number"] and p["adapter_number"] == side_b["adapter_number"]
|
||||
)
|
||||
)
|
||||
name_a = str(node_a["name"]) if node_a.get("name") else "Unknown"
|
||||
name_b = str(node_b["name"]) if node_b.get("name") else "Unknown"
|
||||
summary.append({
|
||||
"link_id": link.get("link_id"),
|
||||
"node_a": name_a,
|
||||
"port_a": port_a,
|
||||
"node_b": name_b,
|
||||
"port_b": port_b,
|
||||
})
|
||||
summary.append(
|
||||
{
|
||||
"link_id": link.get("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 lookups can't match data
|
||||
continue
|
||||
return summary
|
||||
|
||||
|
||||
def fetch_project_inventory(
|
||||
gns3_ctx: dict[str, Any], project_id: str
|
||||
) -> dict[str, Any]:
|
||||
def fetch_project_inventory(gns3_ctx: dict[str, Any], project_id: str) -> dict[str, Any]:
|
||||
"""
|
||||
Fetch a project's metadata, nodes and links and return the aggregated
|
||||
inventory — the equivalent of the old ``Project.get()`` +
|
||||
|
||||
Reference in new issue
Block a user