Merge pull request #2880 from markparonyan/harden-ci

ci: mypy, ruff, pytest
This commit is contained in:
Jeremy Grossmann 2026-09-27 18:52:08 +02:00 committed by GitHub
commit d99b89f1e9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
463 changed files with 12751 additions and 14769 deletions

View File

@ -33,7 +33,7 @@ from gns3server.services import auth_service
from gns3server.services.authentication import DEFAULT_JWT_SECRET_KEY from gns3server.services.authentication import DEFAULT_JWT_SECRET_KEY
token = auth_service.create_access_token(test_user.username, secret_key=DEFAULT_JWT_SECRET_KEY) token = auth_service.create_access_token(test_user.username, secret_key=DEFAULT_JWT_SECRET_KEY)
response = await client.get(url, headers={"Authorization": f"Bearer {token}"}) # specific user response = await client.get(url, headers={"Authorization": f"Bearer {token}"}) # specific user
response = await client.get(url, headers={"Authorization": "Bearer invalid_token"}) # 401 response = await client.get(url, headers={"Authorization": "Bearer invalid_token"}) # 401
``` ```

View File

@ -1,2 +1,9 @@
[run]
source = gns3server
branch = True
[report]
fail_under = 55
[paths] [paths]
source = gns3server source = gns3server

View File

@ -21,26 +21,23 @@ jobs:
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6 uses: actions/setup-python@v6
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
cache: pip cache: pip
- name: Install dependencies - name: Install dependencies
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
python -m pip install .[ai-features,dev] python -m pip install .[ai-features,dev]
- name: Install sharkd (marker replay engine) - name: Install sharkd (marker replay engine)
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install -y wireshark-common sudo apt-get install -y wireshark-common
- name: Lint with flake8 - name: Run all checks
run: | run: |
# stop the build if there are Python syntax errors or undefined names just test
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
- name: Test with pytest
run: |
python -m pytest -vv

4
.gitignore vendored
View File

@ -37,6 +37,10 @@ pip-log.txt
.tox .tox
.cache .cache
nosetests.xml nosetests.xml
coverage.xml
htmlcov/
.mypy_cache/
.ruff_cache/
# Translations # Translations
*.mo *.mo

View File

@ -126,34 +126,37 @@ flake8 gns3server/utils/
import os # Never used import os # Never used
# Good: Delete this line # Good: Delete this line
# F841: Mark as intentionally unused # F841: Mark as intentionally unused
# Bad # Bad
def foo(): def foo():
x = 1 # Never used x = 1 # Never used
# Good: Use or mark as intentionally unused # Good: Use or mark as intentionally unused
def foo(): def foo():
x = 1 x = 1
_ = x # Mark as intentionally unused _ = x # Mark as intentionally unused
# F824: Remove unnecessary global declaration # F824: Remove unnecessary global declaration
# Bad # Bad
def foo(): def foo():
global _fernet # Never assigned in this scope global _fernet # Never assigned in this scope
# Good: Remove global statement (if only reading) # Good: Remove global statement (if only reading)
def foo(): def foo():
# Just read the global variable, no global declaration needed # Just read the global variable, no global declaration needed
pass pass
# E501: Break long lines # E501: Break long lines
# Bad # Bad
raise RuntimeError("Encryption not initialized. Call init_encryption() first.") raise RuntimeError("Encryption not initialized. Call init_encryption() first.")
# Good # Good
raise RuntimeError( raise RuntimeError("Encryption not initialized. Call init_encryption() first.")
"Encryption not initialized. Call init_encryption() first."
)
``` ```
#### Ruff (Alternative) #### Ruff (Alternative)
@ -177,6 +180,7 @@ ruff format gns3server/ # Format code
def process_data(input_data: dict[str, Any]) -> list[dict[str, Any]]: def process_data(input_data: dict[str, Any]) -> list[dict[str, Any]]:
pass pass
# 2. Error handling # 2. Error handling
try: try:
result = tool.invoke(args) result = tool.invoke(args)

View File

@ -1,7 +1,10 @@
pytest==9.1.1 pytest==9.1.1
flake8==7.3.0
pytest-timeout==2.4.0 pytest-timeout==2.4.0
pytest-asyncio==1.4.0 pytest-asyncio==1.4.0
httpx==0.28.1 httpx==0.28.1
httpx_ws==0.7.2 # upgrading leads to failures in tests httpx_ws==0.7.2 # upgrading leads to failures in tests
pytest-random-order==1.2.0 # opt-in: --random-order --random-order-bucket=global (legacy suites are not shuffle-clean yet, see .claude/skills/gns3-api-test-writing) pytest-random-order==1.2.0 # opt-in: --random-order --random-order-bucket=global (legacy suites are not shuffle-clean yet, see .claude/skills/gns3-api-test-writing)
pytest-cov==7.1.0
ruff==0.16.9
mypy==2.3.1
rust-just==1.58.0 # provides the `just` command runner, see justfile

View File

@ -279,7 +279,7 @@ Fix the WebSocket error handler:
```python ```python
@app.exception_handler(ControllerNotFoundError) @app.exception_handler(ControllerNotFoundError)
async def controller_not_found_error_handler(request: Request, exc: ControllerNotFoundError): async def controller_not_found_error_handler(request: Request, exc: ControllerNotFoundError):
method = getattr(request, 'method', 'WebSocket') method = getattr(request, "method", "WebSocket")
log.error(f"Controller not found error in {request.url.path} ({method}): {exc}") log.error(f"Controller not found error in {request.url.path} ({method}): {exc}")
return JSONResponse( return JSONResponse(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,

View File

@ -238,25 +238,32 @@ Both `device_config_send` and `device_show_run` support an optional `template` p
```python ```python
# Direct commands (single/batch) # Direct commands (single/batch)
device_config_send(project_id, device_configs=[ device_config_send(
{"device_name": "R1", "config_commands": ["int lo0", "ip add 1.1.1.1 255.255.255.255"]}, project_id,
]) device_configs=[
{"device_name": "R1", "config_commands": ["int lo0", "ip add 1.1.1.1 255.255.255.255"]},
],
)
# Jinja2 template (reduces token usage for batch) # Jinja2 template (reduces token usage for batch)
device_config_send(project_id, device_config_send(
project_id,
template="interface lo{{ n }}\nip address {{ ip }} 255.255.255.255", template="interface lo{{ n }}\nip address {{ ip }} 255.255.255.255",
device_configs=[ device_configs=[
{"device_name": "R1", "vars": {"n": 0, "ip": "1.1.1.1"}}, {"device_name": "R1", "vars": {"n": 0, "ip": "1.1.1.1"}},
{"device_name": "R2", "vars": {"n": 0, "ip": "2.2.2.2"}}, {"device_name": "R2", "vars": {"n": 0, "ip": "2.2.2.2"}},
]) ],
)
# Show commands with template # Show commands with template
device_show_run(project_id, device_show_run(
project_id,
template="show ip route {{ protocol }}", template="show ip route {{ protocol }}",
device_configs=[ device_configs=[
{"device_name": "R1", "vars": {"protocol": "ospf"}}, {"device_name": "R1", "vars": {"protocol": "ospf"}},
{"device_name": "R2", "vars": {"protocol": "bgp"}}, {"device_name": "R2", "vars": {"protocol": "bgp"}},
]) ],
)
``` ```
### Best Practices ### Best Practices
@ -273,9 +280,12 @@ device_show_run(project_id,
```python ```python
# Save config on device # Save config on device
device_show_run(project_id, device_configs=[ device_show_run(
{"device_name": "R1", "commands": ["write memory"]}, project_id,
]) device_configs=[
{"device_name": "R1", "commands": ["write memory"]},
],
)
# Backup # Backup
config = node_file_get(project_id, node_id, "startup-config.cfg") config = node_file_get(project_id, node_id, "startup-config.cfg")
# Restore if config breaks # Restore if config breaks

View File

@ -51,8 +51,8 @@ Each IOU VM creation spawned `ld-linux --verify` and `iou-image -h` subprocesses
```python ```python
# Class-level caches shared across all instances # Class-level caches shared across all instances
IOUVM._loader_cache = {} # image path → loader command IOUVM._loader_cache = {} # image path → loader command
IOUVM._default_values_cache = {} # image path → (ram, nvram) IOUVM._default_values_cache = {} # image path → (ram, nvram)
``` ```
Only the first node with a given image runs the subprocesses; subsequent nodes reuse cached values. Only the first node with a given image runs the subprocesses; subsequent nodes reuse cached values.

View File

@ -798,7 +798,7 @@ OpenAI-compatible message model.
content=observation, # Always JSON string format content=observation, # Always JSON string format
tool_call_id=tool_call["id"], tool_call_id=tool_call["id"],
name=tool_call["name"], name=tool_call["name"],
metadata={"created_at": datetime.utcnow().isoformat()} metadata={"created_at": datetime.utcnow().isoformat()},
) )
``` ```
@ -924,11 +924,7 @@ ToolMessage(content=JSON_string)
**Route Registration**: **Route Registration**:
```python ```python
router.include_router( router.include_router(chat.router, prefix="/{project_id}/chat", tags=["Chat"])
chat.router,
prefix="/{project_id}/chat",
tags=["Chat"]
)
``` ```
**Main Endpoint Implementation**: **Main Endpoint Implementation**:

View File

@ -79,9 +79,11 @@ PC1> ping 10.10.0.254
def check_config_mode(self) -> bool: def check_config_mode(self) -> bool:
return False # VPCS has no config mode return False # VPCS has no config mode
def config_mode(self) -> str: def config_mode(self) -> str:
return "" # No config mode to enter return "" # No config mode to enter
def exit_config_mode(self) -> str: def exit_config_mode(self) -> str:
return "" # No config mode to exit return "" # No config mode to exit
``` ```
@ -354,6 +356,7 @@ The custom driver must be registered with Netmiko's global mappings:
```python ```python
def register_custom_device_type() -> None: def register_custom_device_type() -> None:
import importlib import importlib
sd = importlib.import_module("netmiko.ssh_dispatcher") sd = importlib.import_module("netmiko.ssh_dispatcher")
# Register in CLASS_MAPPER (for ConnectHandler) # Register in CLASS_MAPPER (for ConnectHandler)
@ -430,12 +433,12 @@ gns3server/agent/gns3_copilot/utils/custom_netmiko/ruijie_telnet.py
**1. Preprocessing - Known Interactive Commands** **1. Preprocessing - Known Interactive Commands**
```python ```python
INTERACTIVE_PATTERNS = [ INTERACTIVE_PATTERNS = [
re.compile(r'^router-id\s+', re.IGNORECASE), # OSPF/EIGRP/BGP router-id re.compile(r"^router-id\s+", re.IGNORECASE), # OSPF/EIGRP/BGP router-id
re.compile(r'^erase\s+', re.IGNORECASE), # erase startup-config re.compile(r"^erase\s+", re.IGNORECASE), # erase startup-config
re.compile(r'^delete\s+', re.IGNORECASE), # delete files re.compile(r"^delete\s+", re.IGNORECASE), # delete files
re.compile(r'^format\s+', re.IGNORECASE), # format filesystem re.compile(r"^format\s+", re.IGNORECASE), # format filesystem
re.compile(r'^reload\b', re.IGNORECASE), # reload/reboot re.compile(r"^reload\b", re.IGNORECASE), # reload/reboot
re.compile(r'^boot\s+system\s+', re.IGNORECASE), # change boot image re.compile(r"^boot\s+system\s+", re.IGNORECASE), # change boot image
] ]
``` ```

View File

@ -520,9 +520,14 @@ The tools call the shared REST handler layer (`gns3_copilot.gns3_client.api_hand
```python ```python
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import ( from gns3server.agent.gns3_copilot.gns3_client.api_handlers import (
build_gns3_ctx, create_node_handler, create_link_handler, build_gns3_ctx,
get_nodes_handler, start_node_handler, stop_node_handler, create_node_handler,
suspend_node_handler, update_node_handler, create_link_handler,
get_nodes_handler,
start_node_handler,
stop_node_handler,
suspend_node_handler,
update_node_handler,
) )
gns3_ctx = build_gns3_ctx() # JWT + server URL from the request context gns3_ctx = build_gns3_ctx() # JWT + server URL from the request context
@ -650,10 +655,7 @@ Note: These special node types are filtered out by GNS3TemplateTool and won't ap
from gns3server.agent.gns3_copilot.tools_v2 import GNS3StartNodeTool from gns3server.agent.gns3_copilot.tools_v2 import GNS3StartNodeTool
tool = GNS3StartNodeTool() tool = GNS3StartNodeTool()
result = tool._run(json.dumps({ result = tool._run(json.dumps({"project_id": "abc-123-def", "node_ids": ["node-1", "node-2", "node-3"]}))
"project_id": "abc-123-def",
"node_ids": ["node-1", "node-2", "node-3"]
}))
# Output includes progress bar and final status # Output includes progress bar and final status
``` ```
@ -664,10 +666,7 @@ result = tool._run(json.dumps({
from gns3server.agent.gns3_copilot.tools_v2 import GNS3StopNodeTool from gns3server.agent.gns3_copilot.tools_v2 import GNS3StopNodeTool
tool = GNS3StopNodeTool() tool = GNS3StopNodeTool()
result = tool._run(json.dumps({ result = tool._run(json.dumps({"project_id": "abc-123-def", "node_ids": ["node-1", "node-2"]}))
"project_id": "abc-123-def",
"node_ids": ["node-1", "node-2"]
}))
# Immediate return with stop status # Immediate return with stop status
``` ```
@ -677,19 +676,13 @@ result = tool._run(json.dumps({
```python ```python
# Lab deployment # Lab deployment
start_tool = GNS3StartNodeTool() start_tool = GNS3StartNodeTool()
start_result = start_tool._run(json.dumps({ start_result = start_tool._run(json.dumps({"project_id": project_id, "node_ids": all_node_ids}))
"project_id": project_id,
"node_ids": all_node_ids
}))
# ... Run tests ... # ... Run tests ...
# Lab shutdown # Lab shutdown
stop_tool = GNS3StopNodeTool() stop_tool = GNS3StopNodeTool()
stop_result = stop_tool._run(json.dumps({ stop_result = stop_tool._run(json.dumps({"project_id": project_id, "node_ids": all_node_ids}))
"project_id": project_id,
"node_ids": all_node_ids
}))
``` ```
### Example 4: Lab Pause and Resume ### Example 4: Lab Pause and Resume
@ -699,56 +692,45 @@ from gns3server.agent.gns3_copilot.tools_v2 import GNS3SuspendNodeTool
# Start lab # Start lab
start_tool = GNS3StartNodeTool() start_tool = GNS3StartNodeTool()
start_result = start_tool._run(json.dumps({ start_result = start_tool._run(json.dumps({"project_id": project_id, "node_ids": all_node_ids}))
"project_id": project_id,
"node_ids": all_node_ids
}))
# ... Configure devices ... # ... Configure devices ...
# ... Run some tests ... # ... Run some tests ...
# Suspend lab (preserves all state) # Suspend lab (preserves all state)
suspend_tool = GNS3SuspendNodeTool() suspend_tool = GNS3SuspendNodeTool()
suspend_result = suspend_tool._run(json.dumps({ suspend_result = suspend_tool._run(json.dumps({"project_id": project_id, "node_ids": all_node_ids}))
"project_id": project_id,
"node_ids": all_node_ids
}))
# Nodes suspended - state preserved in memory # Nodes suspended - state preserved in memory
# ... Take a break, work on something else ... # ... Take a break, work on something else ...
# Resume lab (quick recovery) # Resume lab (quick recovery)
start_result = start_tool._run(json.dumps({ start_result = start_tool._run(json.dumps({"project_id": project_id, "node_ids": all_node_ids}))
"project_id": project_id,
"node_ids": all_node_ids
}))
# Back to previous state in seconds! # Back to previous state in seconds!
``` ```
### Example 5: Suspend While Renaming Nodes ### Example 5: Suspend While Renaming Nodes
```python ```python
from gns3server.agent.gns3_copilot.tools_v2 import ( from gns3server.agent.gns3_copilot.tools_v2 import GNS3SuspendNodeTool, GNS3UpdateNodeNameTool
GNS3SuspendNodeTool,
GNS3UpdateNodeNameTool
)
# Suspend nodes first (allows renaming) # Suspend nodes first (allows renaming)
suspend_tool = GNS3SuspendNodeTool() suspend_tool = GNS3SuspendNodeTool()
suspend_result = suspend_tool._run(json.dumps({ suspend_result = suspend_tool._run(json.dumps({"project_id": project_id, "node_ids": node_ids}))
"project_id": project_id,
"node_ids": node_ids
}))
# Now rename nodes (possible while suspended!) # Now rename nodes (possible while suspended!)
rename_tool = GNS3UpdateNodeNameTool() rename_tool = GNS3UpdateNodeNameTool()
rename_result = rename_tool._run(json.dumps({ rename_result = rename_tool._run(
"project_id": project_id, json.dumps(
"nodes": [ {
{"node_id": "node-1", "new_name": "Router-Primary"}, "project_id": project_id,
{"node_id": "node-2", "new_name": "Router-Backup"} "nodes": [
] {"node_id": "node-1", "new_name": "Router-Primary"},
})) {"node_id": "node-2", "new_name": "Router-Backup"},
],
}
)
)
# Resume when ready # Resume when ready
# Note: Cannot rename while started, but CAN rename while suspended! # Note: Cannot rename while started, but CAN rename while suspended!
@ -777,21 +759,17 @@ result = tool._run("")
from gns3server.agent.gns3_copilot.tools_v2 import GNS3CreateNodeTool from gns3server.agent.gns3_copilot.tools_v2 import GNS3CreateNodeTool
tool = GNS3CreateNodeTool() tool = GNS3CreateNodeTool()
result = tool._run(json.dumps({ result = tool._run(
"project_id": "abc-123-def", json.dumps(
"nodes": [
{ {
"template_id": "uuid-of-router-template", "project_id": "abc-123-def",
"x": 100, "nodes": [
"y": -200 {"template_id": "uuid-of-router-template", "x": 100, "y": -200},
}, {"template_id": "uuid-of-switch-template", "x": -200, "y": 300},
{ ],
"template_id": "uuid-of-switch-template",
"x": -200,
"y": 300
} }
] )
})) )
# Creates two nodes with specified templates and positions # Creates two nodes with specified templates and positions
``` ```
@ -802,23 +780,27 @@ result = tool._run(json.dumps({
from gns3server.agent.gns3_copilot.tools_v2 import GNS3LinkTool from gns3server.agent.gns3_copilot.tools_v2 import GNS3LinkTool
tool = GNS3LinkTool() tool = GNS3LinkTool()
result = tool._run(json.dumps({ result = tool._run(
"project_id": "abc-123-def", json.dumps(
"links": [
{ {
"node_id1": "uuid-of-node1", "project_id": "abc-123-def",
"port1": "Ethernet0/0", "links": [
"node_id2": "uuid-of-node2", {
"port2": "Ethernet0/0" "node_id1": "uuid-of-node1",
}, "port1": "Ethernet0/0",
{ "node_id2": "uuid-of-node2",
"node_id1": "uuid-of-node1", "port2": "Ethernet0/0",
"port1": "Ethernet0/1", },
"node_id2": "uuid-of-node3", {
"port2": "Ethernet0/0" "node_id1": "uuid-of-node1",
"port1": "Ethernet0/1",
"node_id2": "uuid-of-node3",
"port2": "Ethernet0/0",
},
],
} }
] )
})) )
# Creates two links connecting the nodes # Creates two links connecting the nodes
``` ```
@ -829,15 +811,19 @@ result = tool._run(json.dumps({
from gns3server.agent.gns3_copilot.tools_v2 import GNS3UpdateNodeNameTool from gns3server.agent.gns3_copilot.tools_v2 import GNS3UpdateNodeNameTool
tool = GNS3UpdateNodeNameTool() tool = GNS3UpdateNodeNameTool()
result = tool._run(json.dumps({ result = tool._run(
"project_id": "abc-123-def", json.dumps(
"nodes": [ {
{"node_id": "node-1", "new_name": "R1-Core"}, "project_id": "abc-123-def",
{"node_id": "node-2", "new_name": "R2-Core"}, "nodes": [
{"node_id": "node-3", "new_name": "S1-Access"}, {"node_id": "node-1", "new_name": "R1-Core"},
{"node_id": "node-4", "new_name": "S2-Access"} {"node_id": "node-2", "new_name": "R2-Core"},
] {"node_id": "node-3", "new_name": "S1-Access"},
})) {"node_id": "node-4", "new_name": "S2-Access"},
],
}
)
)
# Applies consistent naming to all nodes # Applies consistent naming to all nodes
``` ```
@ -850,7 +836,7 @@ from gns3server.agent.gns3_copilot.tools_v2 import (
GNS3CreateNodeTool, GNS3CreateNodeTool,
GNS3LinkTool, GNS3LinkTool,
GNS3UpdateNodeNameTool, GNS3UpdateNodeNameTool,
GNS3StartNodeTool GNS3StartNodeTool,
) )
# Step 1: Get available templates # Step 1: Get available templates
@ -860,44 +846,63 @@ templates = template_tool._run("")
# Step 2: Create nodes # Step 2: Create nodes
create_tool = GNS3CreateNodeTool() create_tool = GNS3CreateNodeTool()
nodes = create_tool._run(json.dumps({ nodes = create_tool._run(
"project_id": project_id, json.dumps(
"nodes": [ {
{"template_id": router_template_id, "x": 0, "y": -200}, "project_id": project_id,
{"template_id": router_template_id, "x": 200, "y": -200}, "nodes": [
{"template_id": switch_template_id, "x": 100, "y": 0} {"template_id": router_template_id, "x": 0, "y": -200},
] {"template_id": router_template_id, "x": 200, "y": -200},
})) {"template_id": switch_template_id, "x": 100, "y": 0},
],
}
)
)
# Step 3: Connect nodes # Step 3: Connect nodes
link_tool = GNS3LinkTool() link_tool = GNS3LinkTool()
links = link_tool._run(json.dumps({ links = link_tool._run(
"project_id": project_id, json.dumps(
"links": [ {
{"node_id1": nodes["created_nodes"][0]["node_id"], "port1": "Ethernet0/0", "project_id": project_id,
"node_id2": nodes["created_nodes"][2]["node_id"], "port2": "Ethernet0/0"}, "links": [
{"node_id1": nodes["created_nodes"][1]["node_id"], "port1": "Ethernet0/0", {
"node_id2": nodes["created_nodes"][2]["node_id"], "port2": "Ethernet0/1"} "node_id1": nodes["created_nodes"][0]["node_id"],
] "port1": "Ethernet0/0",
})) "node_id2": nodes["created_nodes"][2]["node_id"],
"port2": "Ethernet0/0",
},
{
"node_id1": nodes["created_nodes"][1]["node_id"],
"port1": "Ethernet0/0",
"node_id2": nodes["created_nodes"][2]["node_id"],
"port2": "Ethernet0/1",
},
],
}
)
)
# Step 4: Apply naming # Step 4: Apply naming
name_tool = GNS3UpdateNodeNameTool() name_tool = GNS3UpdateNodeNameTool()
names = name_tool._run(json.dumps({ names = name_tool._run(
"project_id": project_id, json.dumps(
"nodes": [ {
{"node_id": nodes["created_nodes"][0]["node_id"], "new_name": "R1"}, "project_id": project_id,
{"node_id": nodes["created_nodes"][1]["node_id"], "new_name": "R2"}, "nodes": [
{"node_id": nodes["created_nodes"][2]["node_id"], "new_name": "SW1"} {"node_id": nodes["created_nodes"][0]["node_id"], "new_name": "R1"},
] {"node_id": nodes["created_nodes"][1]["node_id"], "new_name": "R2"},
})) {"node_id": nodes["created_nodes"][2]["node_id"], "new_name": "SW1"},
],
}
)
)
# Step 5: Start nodes # Step 5: Start nodes
start_tool = GNS3StartNodeTool() start_tool = GNS3StartNodeTool()
start_result = start_tool._run(json.dumps({ start_result = start_tool._run(
"project_id": project_id, json.dumps({"project_id": project_id, "node_ids": [n["node_id"] for n in nodes["created_nodes"]]})
"node_ids": [n["node_id"] for n in nodes["created_nodes"]] )
}))
``` ```
## Error Handling ## Error Handling

View File

@ -150,9 +150,9 @@ Git operations use per-command environment variables to prevent hanging:
```python ```python
_GIT_TIMEOUT_ENV = { _GIT_TIMEOUT_ENV = {
'GIT_HTTP_TIMEOUT': '10', # Connection timeout (default: 120s) "GIT_HTTP_TIMEOUT": "10", # Connection timeout (default: 120s)
'GIT_HTTP_LOW_SPEED_TIME': '5', # Slow speed threshold window "GIT_HTTP_LOW_SPEED_TIME": "5", # Slow speed threshold window
'GIT_HTTP_LOW_SPEED_LIMIT': '1000', # < 1 KB/s = slow → abort "GIT_HTTP_LOW_SPEED_LIMIT": "1000", # < 1 KB/s = slow → abort
} }
``` ```

View File

@ -389,18 +389,13 @@ router ospf {{ process_id }}
@interrupt @interrupt
def template_review_checkpoint(state): def template_review_checkpoint(state):
"""Pause and wait for user confirmation.""" """Pause and wait for user confirmation."""
return { return {"type": "template_review", "data": state["generated_template"]}
"type": "template_review",
"data": state["generated_template"]
}
@interrupt @interrupt
def params_review_checkpoint(state): def params_review_checkpoint(state):
"""Pause and wait for user confirmation.""" """Pause and wait for user confirmation."""
return { return {"type": "params_review", "data": state["generated_params"]}
"type": "params_review",
"data": state["generated_params"]
}
``` ```
**State Management:** **State Management:**
@ -939,24 +934,9 @@ For 1000+ devices, some failures are inevitable. The system provides:
```python ```python
{ {
"total_devices": 1000, "total_devices": 1000,
"summary": { "summary": {"success": 987, "failed": 13, "skipped": 0},
"success": 987, "failed_devices": [{"device_name": "R456", "error": "Connection timeout", "retry_available": true}, ...],
"failed": 13, "retry_suggestions": {"auto_retry": True, "retry_batch_size": 10, "exponential_backoff": True},
"skipped": 0
},
"failed_devices": [
{
"device_name": "R456",
"error": "Connection timeout",
"retry_available": true
},
...
],
"retry_suggestions": {
"auto_retry": True,
"retry_batch_size": 10,
"exponential_backoff": True
}
} }
``` ```
@ -1277,54 +1257,38 @@ All servers connect to access switches in pairs.
"node_type": "cisco_iosv", "node_type": "cisco_iosv",
"count": 4, "count": 4,
"name_pattern": "Spine-R{{ id }}", "name_pattern": "Spine-R{{ id }}",
"properties": { "properties": {"ram": 4096, "cpus": 2, "adapters": 8},
"ram": 4096, "position": {"strategy": "hierarchical", "y": 100, "x_spacing": 600},
"cpus": 2,
"adapters": 8
},
"position": {
"strategy": "hierarchical",
"y": 100,
"x_spacing": 600
}
}, },
{ {
"name": "leaf", "name": "leaf",
"node_type": "cisco_iosv_l2", "node_type": "cisco_iosv_l2",
"count": 20, "count": 20,
"name_pattern": "Leaf-SW{{ id }}", "name_pattern": "Leaf-SW{{ id }}",
"properties": { "properties": {"ram": 2048, "cpus": 1, "adapters": 16},
"ram": 2048,
"cpus": 1,
"adapters": 16
},
"position": { "position": {
"strategy": "grid", "strategy": "grid",
"grid_rows": 4, "grid_rows": 4,
"grid_cols": 5, "grid_cols": 5,
"y": 400, "y": 400,
"x_spacing": 300, "x_spacing": 300,
"y_spacing": 200 "y_spacing": 200,
} },
}, },
{ {
"name": "access", "name": "access",
"node_type": "cisco_iosv_l2", "node_type": "cisco_iosv_l2",
"count": 200, "count": 200,
"name_pattern": "Acc-SW{{ id }}", "name_pattern": "Acc-SW{{ id }}",
"properties": { "properties": {"ram": 1024, "cpus": 1, "adapters": 4},
"ram": 1024,
"cpus": 1,
"adapters": 4
},
"position": { "position": {
"strategy": "grid", "strategy": "grid",
"grid_rows": 10, "grid_rows": 10,
"grid_cols": 20, "grid_cols": 20,
"y": 800, "y": 800,
"x_spacing": 120, "x_spacing": 120,
"y_spacing": 100 "y_spacing": 100,
} },
}, },
{ {
"name": "server", "name": "server",
@ -1338,36 +1302,19 @@ All servers connect to access switches in pairs.
"grid_cols": 25, "grid_cols": 25,
"y": 1200, "y": 1200,
"x_spacing": 60, "x_spacing": 60,
"y_spacing": 60 "y_spacing": 60,
} },
} },
], ],
"auto_link": { "auto_link": {
"links": [ "links": [
{ {"from_group": "spine", "to_group": "leaf", "strategy": "mesh"},
"from_group": "spine", {"from_group": "leaf", "to_group": "access", "strategy": "paired", "count": 10},
"to_group": "leaf", {"from_group": "access", "to_group": "server", "strategy": "paired", "count": 2},
"strategy": "mesh"
},
{
"from_group": "leaf",
"to_group": "access",
"strategy": "paired",
"count": 10
},
{
"from_group": "access",
"to_group": "server",
"strategy": "paired",
"count": 2
}
] ]
}, },
"layout": "auto_spine_leaf", "layout": "auto_spine_leaf",
"resource_limits": { "resource_limits": {"max_ram_mb": 750000, "max_vcpus": 724},
"max_ram_mb": 750000,
"max_vcpus": 724
}
} }
# Execution Result # Execution Result
@ -1381,8 +1328,8 @@ All servers connect to access switches in pairs.
{"name": "spine", "created": 4, "failed": 0}, {"name": "spine", "created": 4, "failed": 0},
{"name": "leaf", "created": 20, "failed": 0}, {"name": "leaf", "created": 20, "failed": 0},
{"name": "access", "created": 200, "failed": 0}, {"name": "access", "created": 200, "failed": 0},
{"name": "server", "created": 500, "failed": 0} {"name": "server", "created": 500, "failed": 0},
] ],
} }
``` ```
@ -1522,14 +1469,14 @@ class LinkPattern(BaseModel):
"""Pattern for creating links between node groups.""" """Pattern for creating links between node groups."""
from_nodes: NodeSelector # Source nodes from_nodes: NodeSelector # Source nodes
to_nodes: NodeSelector # Destination nodes to_nodes: NodeSelector # Destination nodes
strategy: Literal[ strategy: Literal[
"one_to_one", # 1:1 pairing "one_to_one", # 1:1 pairing
"one_to_many", # Star topology "one_to_many", # Star topology
"many_to_many", # Full mesh "many_to_many", # Full mesh
"sequential", # Linear chain "sequential", # Linear chain
"ring" # Ring topology "ring", # Ring topology
] ]
port_allocation: PortAllocationStrategy port_allocation: PortAllocationStrategy
@ -1548,16 +1495,16 @@ class PortAllocationStrategy(BaseModel):
"""How to allocate ports for links.""" """How to allocate ports for links."""
strategy: Literal[ strategy: Literal[
"round_robin", # Distribute evenly "round_robin", # Distribute evenly
"sequential", # Use in order "sequential", # Use in order
"optimized", # Smart allocation "optimized", # Smart allocation
"auto" # Automatic selection "auto", # Automatic selection
] ]
on_conflict: Literal[ on_conflict: Literal[
"skip", # Skip if port unavailable "skip", # Skip if port unavailable
"use_next", # Use next available port "use_next", # Use next available port
"fail" # Fail on conflict "fail", # Fail on conflict
] = "use_next" ] = "use_next"
``` ```

View File

@ -35,7 +35,7 @@ Add `max_nodes` field to both `User` and `UserGroup` models:
# In User model # In User model
max_nodes = Column(Integer, nullable=True) # NULL = no limit max_nodes = Column(Integer, nullable=True) # NULL = no limit
# In UserGroup model # In UserGroup model
max_nodes = Column(Integer, nullable=True) # NULL = no limit max_nodes = Column(Integer, nullable=True) # NULL = no limit
``` ```
@ -49,9 +49,7 @@ Add node limit configuration to `ControllerSettings`:
class NodeLimitSettings(BaseModel): class NodeLimitSettings(BaseModel):
enabled: bool = False # Feature toggle (default disabled) enabled: bool = False # Feature toggle (default disabled)
default_max_nodes: int = 5 # Default limit when enabled default_max_nodes: int = 5 # Default limit when enabled
excluded_node_types: List[str] = Field(default_factory=lambda: [ excluded_node_types: List[str] = Field(default_factory=lambda: ["ethernet_switch", "ethernet_hub", "cloud", "nat"])
"ethernet_switch", "ethernet_hub", "cloud", "nat"
])
``` ```
#### 3. Core Service Implementation #### 3. Core Service Implementation
@ -83,15 +81,13 @@ Add limit checking to node startup endpoint:
async def start_node( async def start_node(
node: Node = Depends(dep_node), node: Node = Depends(dep_node),
current_user: User = Depends(get_current_active_user), current_user: User = Depends(get_current_active_user),
node_limit_service: NodeLimitService = Depends(get_node_limit_service) node_limit_service: NodeLimitService = Depends(get_node_limit_service),
): ):
# Node limit check # Node limit check
can_start, error_msg = await node_limit_service.check_user_node_limit( can_start, error_msg = await node_limit_service.check_user_node_limit(current_user, node.project)
current_user, node.project
)
if not can_start: if not can_start:
raise HTTPException(status_code=403, detail=error_msg) raise HTTPException(status_code=403, detail=error_msg)
# Original startup logic # Original startup logic
await node.start() await node.start()
``` ```

View File

@ -36,6 +36,7 @@ AI_COPILOT_AVAILABLE = False
try: try:
from .gns3_copilot.project_agent_manager import get_project_agent_manager from .gns3_copilot.project_agent_manager import get_project_agent_manager
from .gns3_copilot.project_agent_manager import ProjectAgentManager from .gns3_copilot.project_agent_manager import ProjectAgentManager
AI_COPILOT_AVAILABLE = True AI_COPILOT_AVAILABLE = True
# Start skills repository initialization in background. # Start skills repository initialization in background.
@ -64,8 +65,7 @@ except ImportError as e:
RuntimeError: If AI Copilot dependencies are not installed RuntimeError: If AI Copilot dependencies are not installed
""" """
raise RuntimeError( raise RuntimeError(
"AI Copilot is not available. " "AI Copilot is not available. Install AI dependencies with: pip install gns3-server[ai-features]"
"Install AI dependencies with: pip install gns3-server[ai-features]"
) )
class ProjectAgentManager: class ProjectAgentManager:
@ -75,8 +75,7 @@ except ImportError as e:
def __init__(self): def __init__(self):
raise RuntimeError( raise RuntimeError(
"AI Copilot is not available. " "AI Copilot is not available. Install AI dependencies with: pip install gns3-server[ai-features]"
"Install AI dependencies with: pip install gns3-server[ai-features]"
) )
@ -88,6 +87,7 @@ try:
# Use importlib so the top-level SDK name "mcp" is not bound in this # Use importlib so the top-level SDK name "mcp" is not bound in this
# namespace — it would shadow the gns3server.agent.mcp subpackage. # namespace — it would shadow the gns3server.agent.mcp subpackage.
import importlib import importlib
importlib.import_module("mcp.server.fastmcp") importlib.import_module("mcp.server.fastmcp")
MCP_AVAILABLE = True MCP_AVAILABLE = True
except ImportError: except ImportError:

View File

@ -67,9 +67,12 @@ logger = logging.getLogger(__name__)
# Initialize tiktoken encoding (required dependency) # Initialize tiktoken encoding (required dependency)
import time import time
logger.debug("Initializing tiktoken encoding (cl100k_base)...") logger.debug("Initializing tiktoken encoding (cl100k_base)...")
logger.debug(f"Cache directory: {_cache_dir}") logger.debug(f"Cache directory: {_cache_dir}")
logger.debug("This may take a moment on first run (downloading ~1.6MB encoding file from openaipublic.blob.core.windows.net)") logger.debug(
"This may take a moment on first run (downloading ~1.6MB encoding file from openaipublic.blob.core.windows.net)"
)
start_time = time.time() start_time = time.time()
_tiktoken_encoding = tiktoken.get_encoding("cl100k_base") _tiktoken_encoding = tiktoken.get_encoding("cl100k_base")
elapsed = time.time() - start_time elapsed = time.time() - start_time
@ -149,21 +152,16 @@ def estimate_tool_tokens(tools: list[Any]) -> int:
if hasattr(tool, "args_schema") and tool.args_schema: if hasattr(tool, "args_schema") and tool.args_schema:
try: try:
# Try Pydantic v2 method (model_json_schema) # Try Pydantic v2 method (model_json_schema)
tool_schema["function"]["parameters"] = ( tool_schema["function"]["parameters"] = tool.args_schema.model_json_schema()
tool.args_schema.model_json_schema()
)
except AttributeError: except AttributeError:
# Fallback to Pydantic v1 method (schema) # Fallback to Pydantic v1 method (schema)
try: try:
tool_schema["function"]["parameters"] = ( tool_schema["function"]["parameters"] = tool.args_schema.schema()
tool.args_schema.schema()
)
except Exception: except Exception:
# Both methods failed, use empty schema # Both methods failed, use empty schema
tool_name = getattr(tool, "name", "unknown") tool_name = getattr(tool, "name", "unknown")
logger.debug( logger.debug(
"Failed to get schema for tool %s, using empty " "Failed to get schema for tool %s, using empty parameters",
"parameters",
tool_name, tool_name,
) )
tool_schema["function"]["parameters"] = {} tool_schema["function"]["parameters"] = {}
@ -171,15 +169,12 @@ def estimate_tool_tokens(tools: list[Any]) -> int:
# model_json_schema() raised an exception # model_json_schema() raised an exception
tool_name = getattr(tool, "name", "unknown") tool_name = getattr(tool, "name", "unknown")
logger.debug( logger.debug(
"model_json_schema() failed for tool %s: %s, " "model_json_schema() failed for tool %s: %s, trying v1 fallback",
"trying v1 fallback",
tool_name, tool_name,
e, e,
) )
try: try:
tool_schema["function"]["parameters"] = ( tool_schema["function"]["parameters"] = tool.args_schema.schema()
tool.args_schema.schema()
)
except Exception: except Exception:
tool_schema["function"]["parameters"] = {} tool_schema["function"]["parameters"] = {}
@ -331,14 +326,11 @@ def create_pre_model_hook(
if not llm_config: if not llm_config:
logger.error("LLM config not found. context_limit is required.") logger.error("LLM config not found. context_limit is required.")
raise ValueError( raise ValueError("LLM config not found. context_limit is required.")
"LLM config not found. context_limit is required."
)
if "context_limit" not in llm_config: if "context_limit" not in llm_config:
logger.error( logger.error(
"context_limit not found in LLM config. " "context_limit not found in LLM config. This is a required field. Please configure context_limit."
"This is a required field. Please configure context_limit."
) )
raise ValueError("context_limit is required in LLM config") raise ValueError("context_limit is required in LLM config")
@ -385,9 +377,7 @@ def create_pre_model_hook(
system_tokens = _count_tokens_for_message(system_message) system_tokens = _count_tokens_for_message(system_message)
# Calculate tokens for messages_with_system (including system) # Calculate tokens for messages_with_system (including system)
messages_with_system_tokens = sum( messages_with_system_tokens = sum(_count_tokens_for_message(m) for m in messages_with_system)
_count_tokens_for_message(m) for m in messages_with_system
)
# Calculate available budget # Calculate available budget
model_limit_tokens = context_limit_k * TOKENS_PER_K model_limit_tokens = context_limit_k * TOKENS_PER_K
@ -449,14 +439,11 @@ def create_pre_model_hook(
# Calculate final token counts # Calculate final token counts
final_total = sum(_count_tokens_for_message(m) for m in trimmed) final_total = sum(_count_tokens_for_message(m) for m in trimmed)
usage_percent = ( usage_percent = (final_total + tool_tokens) / model_limit_tokens * 100
(final_total + tool_tokens) / model_limit_tokens * 100
)
if len(trimmed) < len(messages_with_system): if len(trimmed) < len(messages_with_system):
logger.info( logger.info(
"Messages trimmed: %d → %d msgs. Total: ~%d tokens + %d " "Messages trimmed: %d → %d msgs. Total: ~%d tokens + %d tools = %d / %dK (%.1f%%), strategy=%s",
"tools = %d / %dK (%.1f%%), strategy=%s",
len(messages_with_system), len(messages_with_system),
len(trimmed), len(trimmed),
final_total, final_total,
@ -468,8 +455,7 @@ def create_pre_model_hook(
) )
else: else:
logger.info( logger.info(
"Context ready: %d msgs, ~%d tokens + %d tools = %d / %dK " "Context ready: %d msgs, ~%d tokens + %d tools = %d / %dK (%.1f%%), strategy=%s",
"(%.1f%%), strategy=%s",
len(trimmed), len(trimmed),
final_total, final_total,
tool_tokens, tool_tokens,
@ -520,33 +506,23 @@ def _inject_topology_into_system(
if topology_data: if topology_data:
topology_str = str(topology_data) topology_str = str(topology_data)
formatted_prompt = system_prompt.replace( formatted_prompt = system_prompt.replace("{{topology_info}}", f"\n\n## Current Topology\n{topology_str}")
"{{topology_info}}", f"\n\n## Current Topology\n{topology_str}"
)
logger.info( logger.info(
"✓ Topology injected: %d chars, nodes: %s", "✓ Topology injected: %d chars, nodes: %s",
len(topology_str), len(topology_str),
list(topology_data.get("nodes", {}).keys())[:5], list(topology_data.get("nodes", {}).keys())[:5],
) # Show first 5 node names ) # Show first 5 node names
logger.debug( logger.debug("Full topology data: %s", topology_str[:500]) # First 500 chars
"Full topology data: %s", topology_str[:500]
) # First 500 chars
else: else:
formatted_prompt = system_prompt.replace( formatted_prompt = system_prompt.replace("{{topology_info}}", "(No topology information available)")
"{{topology_info}}", "(No topology information available)"
)
logger.warning("✗ Topology data is None, injecting placeholder") logger.warning("✗ Topology data is None, injecting placeholder")
# Filter out existing SystemMessage instances # Filter out existing SystemMessage instances
non_system_messages = [ non_system_messages = [m for m in messages if not isinstance(m, SystemMessage)]
m for m in messages if not isinstance(m, SystemMessage)
]
filtered_count = len(messages) - len(non_system_messages) filtered_count = len(messages) - len(non_system_messages)
if filtered_count > 0: if filtered_count > 0:
logger.debug( logger.debug("Filtered out %d existing SystemMessage(s)", filtered_count)
"Filtered out %d existing SystemMessage(s)", filtered_count
)
return [SystemMessage(content=formatted_prompt)] + non_system_messages return [SystemMessage(content=formatted_prompt)] + non_system_messages
@ -568,8 +544,7 @@ def prepare_context_messages(
**DEPRECATED**: Use create_pre_model_hook() instead. **DEPRECATED**: Use create_pre_model_hook() instead.
""" """
warnings.warn( warnings.warn(
"prepare_context_messages() is deprecated. Use " "prepare_context_messages() is deprecated. Use create_pre_model_hook() instead.",
"create_pre_model_hook() instead.",
DeprecationWarning, DeprecationWarning,
stacklevel=2, stacklevel=2,
) )
@ -577,13 +552,9 @@ def prepare_context_messages(
if "{{topology_info}}" not in system_prompt: if "{{topology_info}}" not in system_prompt:
formatted_prompt = system_prompt formatted_prompt = system_prompt
elif topology_context: elif topology_context:
formatted_prompt = system_prompt.replace( formatted_prompt = system_prompt.replace("{{topology_info}}", f"\n\n## Current Topology\n{topology_context}")
"{{topology_info}}", f"\n\n## Current Topology\n{topology_context}"
)
else: else:
formatted_prompt = system_prompt.replace( formatted_prompt = system_prompt.replace("{{topology_info}}", "(No topology information available)")
"{{topology_info}}", "(No topology information available)"
)
return [SystemMessage(content=formatted_prompt)] + state_messages return [SystemMessage(content=formatted_prompt)] + state_messages
@ -629,9 +600,7 @@ if __name__ == "__main__":
# Test invocation # Test invocation
print("\nTest 3: Invoke pre_model_hook") print("\nTest 3: Invoke pre_model_hook")
test_state = { test_state = {
"messages": [ "messages": [HumanMessage(f"Message {i}: {'x' * 50}") for i in range(5)],
HumanMessage(f"Message {i}: {'x' * 50}") for i in range(5)
],
"topology_info": {"project_id": "test123", "nodes": 3}, "topology_info": {"project_id": "test123", "nodes": 3},
} }

View File

@ -311,8 +311,7 @@ def llm_call(state: dict, config: RunnableConfig | None = None):
if topology and "error" not in topology: if topology and "error" not in topology:
topology_info = topology topology_info = topology
logger.info( logger.info(
"Successfully retrieved topology for project_id: %s, " "Successfully retrieved topology for project_id: %s, name: %s",
"name: %s",
project_id, project_id,
topology.get("name"), topology.get("name"),
) )
@ -320,9 +319,7 @@ def llm_call(state: dict, config: RunnableConfig | None = None):
logger.warning( logger.warning(
"Failed to retrieve topology for project_id %s: %s", "Failed to retrieve topology for project_id %s: %s",
project_id, project_id,
topology.get("error", "Unknown error") topology.get("error", "Unknown error") if topology else "No result",
if topology
else "No result",
) )
except Exception as e: except Exception as e:
logger.warning( logger.warning(
@ -338,20 +335,13 @@ def llm_call(state: dict, config: RunnableConfig | None = None):
copilot_mode = llm_config.get("copilot_mode", "teaching_assistant").lower() copilot_mode = llm_config.get("copilot_mode", "teaching_assistant").lower()
if copilot_mode == "lab_automation_assistant": if copilot_mode == "lab_automation_assistant":
mode_tools = LAB_AUTOMATION_ASSISTANT_MODE_TOOLS mode_tools = LAB_AUTOMATION_ASSISTANT_MODE_TOOLS
logger.info( logger.info("Using LAB_AUTOMATION_ASSISTANT mode tools (includes configuration tools)")
"Using LAB_AUTOMATION_ASSISTANT mode tools (includes "
"configuration tools)"
)
elif copilot_mode == "troubleshooting_injection": elif copilot_mode == "troubleshooting_injection":
mode_tools = TROUBLESHOOTING_INJECTION_MODE_TOOLS mode_tools = TROUBLESHOOTING_INJECTION_MODE_TOOLS
logger.info( logger.info("Using TROUBLESHOOTING_INJECTION mode tools (fault injection)")
"Using TROUBLESHOOTING_INJECTION mode tools (fault injection)"
)
else: # teaching_assistant mode (default) else: # teaching_assistant mode (default)
mode_tools = TEACHING_ASSISTANT_MODE_TOOLS mode_tools = TEACHING_ASSISTANT_MODE_TOOLS
logger.info( logger.info("Using TEACHING_ASSISTANT mode tools (diagnostic tools only)")
"Using TEACHING_ASSISTANT mode tools (diagnostic tools only)"
)
# Create pre_model_hook for automatic topology injection and trimming # Create pre_model_hook for automatic topology injection and trimming
# Load system prompt based on copilot_mode configuration # Load system prompt based on copilot_mode configuration
@ -372,22 +362,16 @@ def llm_call(state: dict, config: RunnableConfig | None = None):
copilot_mode, copilot_mode,
len(mode_tools), len(mode_tools),
) )
model_with_tools = create_base_model_with_tools( model_with_tools = create_base_model_with_tools(mode_tools, llm_config=llm_config)
mode_tools, llm_config=llm_config
)
# Call pre_hook directly to prepare messages (topology injection + # Call pre_hook directly to prepare messages (topology injection +
# trimming) # trimming)
# Note: LangGraph's pre_model_hook only works with prebuilt agents, not # Note: LangGraph's pre_model_hook only works with prebuilt agents, not
# custom StateGraph # custom StateGraph
logger.debug("Calling pre_hook to prepare %d messages", len(messages)) logger.debug("Calling pre_hook to prepare %d messages", len(messages))
prepared_state = pre_hook( prepared_state = pre_hook({"messages": messages, "topology_info": topology_info})
{"messages": messages, "topology_info": topology_info}
)
prepared_messages = prepared_state["messages"] prepared_messages = prepared_state["messages"]
logger.info( logger.info("Messages prepared: %d → %d", len(messages), len(prepared_messages))
"Messages prepared: %d → %d", len(messages), len(prepared_messages)
)
# Invoke model with prepared messages # Invoke model with prepared messages
response = model_with_tools.invoke(prepared_messages) response = model_with_tools.invoke(prepared_messages)
@ -421,9 +405,7 @@ def llm_call(state: dict, config: RunnableConfig | None = None):
# Define generate title node # Define generate title node
def generate_title( def generate_title(state: MessagesState, config: RunnableConfig | None = None) -> dict:
state: MessagesState, config: RunnableConfig | None = None
) -> dict:
""" """
Generate a conversation title using a lightweight assistant LLM Generate a conversation title using a lightweight assistant LLM
(title_model). This node is only executed when no title has been set yet (title_model). This node is only executed when no title has been set yet
@ -469,27 +451,21 @@ def generate_title(
# Validate the generated title # Validate the generated title
if not new_title or len(new_title) < 3: if not new_title or len(new_title) < 3:
raise ValueError( raise ValueError(f"Generated title too short or empty: '{new_title}'")
f"Generated title too short or empty: '{new_title}'"
)
if new_title in [ if new_title in [
"New Conversation", "New Conversation",
"Untitled Session", "Untitled Session",
"GNS3 Session", "GNS3 Session",
]: ]:
raise ValueError( raise ValueError(f"Generated title is a default value: '{new_title}'")
f"Generated title is a default value: '{new_title}'"
)
# Safety: truncate long titles and avoid line breaks # Safety: truncate long titles and avoid line breaks
if len(new_title) > TITLE_MAX_LENGTH: if len(new_title) > TITLE_MAX_LENGTH:
new_title = new_title[: TITLE_MAX_LENGTH - 2] + "..." new_title = new_title[: TITLE_MAX_LENGTH - 2] + "..."
# Remove unwanted characters # Remove unwanted characters
new_title = ( new_title = new_title.replace("\n", " ").replace('"', "").replace("'", "")
new_title.replace("\n", " ").replace('"', "").replace("'", "")
)
logger.debug("Generated new title: %s", new_title) logger.debug("Generated new title: %s", new_title)
return {"conversation_title": new_title, "session_id": state.get("session_id")} return {"conversation_title": new_title, "session_id": state.get("session_id")}
@ -540,9 +516,7 @@ def tool_node(state: dict, config: RunnableConfig | None = None):
result = [] result = []
for tool_call in tool_calls: for tool_call in tool_calls:
tool_name = tool_call["name"] tool_name = tool_call["name"]
logger.debug( logger.debug("Executing tool: %s with args: %s", tool_name, tool_call["args"])
"Executing tool: %s with args: %s", tool_name, tool_call["args"]
)
tool = tools_by_name[tool_name] tool = tools_by_name[tool_name]
try: try:
observation = tool.invoke(tool_call["args"]) observation = tool.invoke(tool_call["args"])
@ -590,11 +564,13 @@ def abort_handler_node(state: dict) -> dict:
result = [] result = []
for tool_call in last_message.tool_calls: for tool_call in last_message.tool_calls:
tool_msg = ToolMessage( tool_msg = ToolMessage(
content=json.dumps({ content=json.dumps(
"status": "aborted", {
"message": "Tool execution was aborted by user", "status": "aborted",
"tool_call_id": tool_call["id"], "message": "Tool execution was aborted by user",
}), "tool_call_id": tool_call["id"],
}
),
tool_call_id=tool_call["id"], tool_call_id=tool_call["id"],
name=tool_call["name"], name=tool_call["name"],
metadata={"created_at": datetime.utcnow().isoformat(), "aborted": True}, metadata={"created_at": datetime.utcnow().isoformat(), "aborted": True},

View File

@ -94,8 +94,7 @@ def create_base_model(
# Log the loaded configuration (mask sensitive data) # Log the loaded configuration (mask sensitive data)
logger.info( logger.info(
"Creating base model: name=%s, provider=%s, base_url=%s, " "Creating base model: name=%s, provider=%s, base_url=%s, temperature=%s",
"temperature=%s",
config_vars["model_name"], config_vars["model_name"],
config_vars["model_provider"], config_vars["model_provider"],
config_vars["base_url"] if config_vars["base_url"] else "default", config_vars["base_url"] if config_vars["base_url"] else "default",
@ -163,8 +162,7 @@ def create_title_model(
config_vars = _load_llm_config(llm_config) config_vars = _load_llm_config(llm_config)
logger.info( logger.info(
"Creating title model: name=%s, provider=%s, base_url=%s, " "Creating title model: name=%s, provider=%s, base_url=%s, temperature=1.0",
"temperature=1.0",
config_vars["model_name"], config_vars["model_name"],
config_vars["model_provider"], config_vars["model_provider"],
config_vars["base_url"] if config_vars["base_url"] else "default", config_vars["base_url"] if config_vars["base_url"] else "default",

View File

@ -123,9 +123,7 @@ class AgentService:
return self._checkpointer return self._checkpointer
checkpoint_dir = self._get_checkpoint_dir() checkpoint_dir = self._get_checkpoint_dir()
checkpointer_path = os.path.join( checkpointer_path = os.path.join(checkpoint_dir, "copilot_checkpoints.db")
checkpoint_dir, "copilot_checkpoints.db"
)
log.debug("Creating checkpointer at: %s", checkpointer_path) log.debug("Creating checkpointer at: %s", checkpointer_path)
@ -135,17 +133,13 @@ class AgentService:
await self._checkpointer_conn.close() await self._checkpointer_conn.close()
log.debug("Closed previous checkpointer connection") log.debug("Closed previous checkpointer connection")
except Exception as e: except Exception as e:
log.warning( log.warning("Error closing old checkpointer connection: %s", e)
"Error closing old checkpointer connection: %s", e
)
# Create new connection # Create new connection
conn = await aiosqlite.connect(checkpointer_path) conn = await aiosqlite.connect(checkpointer_path)
# Enable WAL mode for better concurrent performance # Enable WAL mode for better concurrent performance
await conn.execute("PRAGMA journal_mode=WAL;") await conn.execute("PRAGMA journal_mode=WAL;")
self._checkpointer_conn = ( self._checkpointer_conn = conn # Save connection reference to prevent GC
conn # Save connection reference to prevent GC
)
self._checkpointer = AsyncSqliteSaver(conn) self._checkpointer = AsyncSqliteSaver(conn)
# CRITICAL: Initialize database schema # CRITICAL: Initialize database schema
@ -197,14 +191,8 @@ class AgentService:
""") """)
# Create indexes # Create indexes
await conn.execute( await conn.execute("CREATE INDEX IF NOT EXISTS idx_thread_id ON chat_sessions(thread_id)")
"CREATE INDEX IF NOT EXISTS idx_thread_id ON " await conn.execute("CREATE INDEX IF NOT EXISTS idx_user_project ON chat_sessions(user_id, project_id)")
"chat_sessions(thread_id)"
)
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_user_project ON "
"chat_sessions(user_id, project_id)"
)
# Check if pinned column exists, add it if not (migration for existing # Check if pinned column exists, add it if not (migration for existing
# databases) # databases)
@ -214,16 +202,12 @@ class AgentService:
if "pinned" not in column_names: if "pinned" not in column_names:
log.debug("Adding pinned column to existing chat_sessions table") log.debug("Adding pinned column to existing chat_sessions table")
await conn.execute( await conn.execute("ALTER TABLE chat_sessions ADD COLUMN pinned BOOLEAN DEFAULT FALSE")
"ALTER TABLE chat_sessions ADD COLUMN pinned BOOLEAN DEFAULT "
"FALSE"
)
await conn.commit() await conn.commit()
# Create pinned index (after column is guaranteed to exist) # Create pinned index (after column is guaranteed to exist)
await conn.execute( await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_pinned_updated ON " "CREATE INDEX IF NOT EXISTS idx_pinned_updated ON chat_sessions(pinned DESC, updated_at DESC)"
"chat_sessions(pinned DESC, updated_at DESC)"
) )
await conn.commit() await conn.commit()
@ -234,9 +218,7 @@ class AgentService:
if self._graph is None: if self._graph is None:
checkpointer = await self._get_checkpointer() checkpointer = await self._get_checkpointer()
self._graph = agent_builder.compile(checkpointer=checkpointer) self._graph = agent_builder.compile(checkpointer=checkpointer)
log.info( log.info("LangGraph agent compiled for project: %s", self.project_path)
"LangGraph agent compiled for project: %s", self.project_path
)
return self._graph return self._graph
async def stream_chat( async def stream_chat(
@ -266,8 +248,7 @@ class AgentService:
Dict containing SSE-compatible response chunks Dict containing SSE-compatible response chunks
""" """
log.info( log.info(
"Stream chat started: project_id=%s, user_id=%s, session_id=%s, " "Stream chat started: project_id=%s, user_id=%s, session_id=%s, mode=%s",
"mode=%s",
project_id, project_id,
user_id, user_id,
session_id, session_id,
@ -293,11 +274,7 @@ class AgentService:
title="New Conversation", title="New Conversation",
copilot_mode=copilot_mode, copilot_mode=copilot_mode,
) )
log.debug( log.debug("Created new chat session: thread_id=%s, copilot_mode=%s", session_id, copilot_mode)
"Created new chat session: thread_id=%s, copilot_mode=%s",
session_id,
copilot_mode
)
# Set request-scoped context variables (memory-only, not persisted) # Set request-scoped context variables (memory-only, not persisted)
if jwt_token: if jwt_token:
@ -387,39 +364,25 @@ class AgentService:
# Stream events # Stream events
try: try:
async for event in graph.astream_events( async for event in graph.astream_events(inputs, config=config, version="v2"):
inputs, config=config, version="v2"
):
event_type = event.get("event", "") event_type = event.get("event", "")
data = event.get("data", {}) data = event.get("data", {})
# Track LLM calls and tokens # Track LLM calls and tokens
if event_type == "on_chat_model_start": if event_type == "on_chat_model_start":
# Filter out title_generator_node from statistics # Filter out title_generator_node from statistics
langgraph_node = event.get("metadata", {}).get( langgraph_node = event.get("metadata", {}).get("langgraph_node", "")
"langgraph_node", ""
)
if langgraph_node != "title_generator_node": if langgraph_node != "title_generator_node":
llm_calls_count += 1 llm_calls_count += 1
log.debug( log.debug("LLM call started, count=%d", llm_calls_count)
"LLM call started, count=%d", llm_calls_count
)
else: else:
log.debug( log.debug("Skipping LLM call count for internal node: title_generator_node")
"Skipping LLM call count for internal node: "
"title_generator_node"
)
elif event_type == "on_chat_model_end": elif event_type == "on_chat_model_end":
# Filter out title_generator_node from token counting # Filter out title_generator_node from token counting
langgraph_node = event.get("metadata", {}).get( langgraph_node = event.get("metadata", {}).get("langgraph_node", "")
"langgraph_node", ""
)
if langgraph_node == "title_generator_node": if langgraph_node == "title_generator_node":
log.debug( log.debug("Skipping token counting for internal node: title_generator_node")
"Skipping token counting for internal node: "
"title_generator_node"
)
else: else:
# Extract token usage from response metadata # Extract token usage from response metadata
# Try multiple possible locations where token usage # Try multiple possible locations where token usage
@ -441,12 +404,8 @@ class AgentService:
if hasattr(output_msg, "usage_metadata"): if hasattr(output_msg, "usage_metadata"):
usage = output_msg.usage_metadata usage = output_msg.usage_metadata
if usage: if usage:
input_tokens += usage.get( input_tokens += usage.get("input_tokens", 0)
"input_tokens", 0 output_tokens += usage.get("output_tokens", 0)
)
output_tokens += usage.get(
"output_tokens", 0
)
token_info_found = True token_info_found = True
# Method 3: Check data directly for token usage fields # Method 3: Check data directly for token usage fields
@ -455,10 +414,7 @@ class AgentService:
input_tokens += data.get("input_tokens", 0) input_tokens += data.get("input_tokens", 0)
if "output_tokens" in data: if "output_tokens" in data:
output_tokens += data.get("output_tokens", 0) output_tokens += data.get("output_tokens", 0)
if ( if "input_tokens" in data or "output_tokens" in data:
"input_tokens" in data
or "output_tokens" in data
):
token_info_found = True token_info_found = True
# Count AI response as one message (only once per turn) # Count AI response as one message (only once per turn)
@ -470,9 +426,7 @@ class AgentService:
elif event_type == "on_tool_end": elif event_type == "on_tool_end":
message_count += 1 # Tool result message message_count += 1 # Tool result message
tool_messages_counted += 1 tool_messages_counted += 1
log.debug( log.debug("Tool message counted, message_count=%d", message_count)
"Tool message counted, message_count=%d", message_count
)
# Convert event to chunk for SSE streaming # Convert event to chunk for SSE streaming
# Use accumulator for on_chat_model_stream events to handle # Use accumulator for on_chat_model_stream events to handle
@ -480,16 +434,11 @@ class AgentService:
# Filter out internal nodes (title_generator_node) from # Filter out internal nodes (title_generator_node) from
# streaming to frontend # streaming to frontend
langgraph_node = event.get("metadata", {}).get( langgraph_node = event.get("metadata", {}).get("langgraph_node", "")
"langgraph_node", ""
)
if langgraph_node == "title_generator_node": if langgraph_node == "title_generator_node":
# Skip all events from the title_generator_node (internal # Skip all events from the title_generator_node (internal
# use only) # use only)
log.debug( log.debug("Skipping event from internal node: title_generator_node")
"Skipping event from internal node: "
"title_generator_node"
)
continue continue
if event_type == "on_chat_model_stream": if event_type == "on_chat_model_stream":
@ -540,8 +489,7 @@ class AgentService:
last_message_at=last_message_at, last_message_at=last_message_at,
) )
log.info( log.info(
"Session statistics updated: thread_id=%s, messages=%d, " "Session statistics updated: thread_id=%s, messages=%d, llm_calls=%d, tokens=%d+%d=%d",
"llm_calls=%d, tokens=%d+%d=%d",
session_id, session_id,
message_count, message_count,
llm_calls_count, llm_calls_count,
@ -555,13 +503,8 @@ class AgentService:
if final_state and "conversation_title" in final_state.values: if final_state and "conversation_title" in final_state.values:
generated_title = final_state.values["conversation_title"] generated_title = final_state.values["conversation_title"]
current_session = await repo.get_session_by_thread(session_id) current_session = await repo.get_session_by_thread(session_id)
if ( if current_session and current_session.title != generated_title:
current_session await repo.update_session(thread_id=session_id, title=generated_title)
and current_session.title != generated_title
):
await repo.update_session(
thread_id=session_id, title=generated_title
)
log.info( log.info(
"Auto-generated title synced: thread_id=%s, title=%s", "Auto-generated title synced: thread_id=%s, title=%s",
session_id, session_id,
@ -576,9 +519,7 @@ class AgentService:
"session_id": session_id, "session_id": session_id,
} }
def _convert_event_to_chunk( def _convert_event_to_chunk(self, event: Dict[str, Any], session_id: str) -> Optional[Dict[str, Any]]:
self, event: Dict[str, Any], session_id: str
) -> Optional[Dict[str, Any]]:
""" """
Convert LangGraph event to API response chunk. Convert LangGraph event to API response chunk.
@ -627,9 +568,7 @@ class AgentService:
return None return None
async def get_history( async def get_history(self, session_id: str, limit: int = 100) -> Dict[str, Any]:
self, session_id: str, limit: int = 100
) -> Dict[str, Any]:
""" """
Get conversation history for a session. Get conversation history for a session.
@ -651,9 +590,7 @@ class AgentService:
for msg in state.values["messages"][-limit:]: for msg in state.values["messages"][-limit:]:
messages.append(self._convert_message_to_dict(msg)) messages.append(self._convert_message_to_dict(msg))
title = state.values.get( title = state.values.get("conversation_title", "New Conversation")
"conversation_title", "New Conversation"
)
return { return {
"thread_id": session_id, "thread_id": session_id,
@ -710,9 +647,7 @@ class AgentService:
repo = ChatSessionsRepository(self._checkpointer_conn) repo = ChatSessionsRepository(self._checkpointer_conn)
return await repo.delete_session(session_id) return await repo.delete_session(session_id)
async def rename_session( async def rename_session(self, session_id: str, new_title: str) -> Optional[Dict[str, Any]]:
self, session_id: str, new_title: str
) -> Optional[Dict[str, Any]]:
""" """
Rename a chat session. Rename a chat session.
@ -727,14 +662,10 @@ class AgentService:
await self._get_checkpointer() await self._get_checkpointer()
repo = ChatSessionsRepository(self._checkpointer_conn) repo = ChatSessionsRepository(self._checkpointer_conn)
session = await repo.update_session( session = await repo.update_session(thread_id=session_id, title=new_title)
thread_id=session_id, title=new_title
)
return session.to_dict() if session else None return session.to_dict() if session else None
async def pin_session( async def pin_session(self, session_id: str, pinned: bool = True) -> Optional[Dict[str, Any]]:
self, session_id: str, pinned: bool = True
) -> Optional[Dict[str, Any]]:
""" """
Pin or unpin a chat session. Pin or unpin a chat session.
@ -760,10 +691,7 @@ class AgentService:
if self._checkpointer_conn: if self._checkpointer_conn:
try: try:
# Add timeout to prevent blocking on database close # Add timeout to prevent blocking on database close
await asyncio.wait_for( await asyncio.wait_for(self._checkpointer_conn.close(), timeout=5.0)
self._checkpointer_conn.close(),
timeout=5.0
)
log.debug( log.debug(
"Checkpointer connection closed for: %s", "Checkpointer connection closed for: %s",
self.project_path, self.project_path,

View File

@ -156,16 +156,11 @@ class ChatSessionsRepository:
await self.conn.commit() await self.conn.commit()
session_id = cursor.lastrowid session_id = cursor.lastrowid
log.info( log.info("Created chat session: id=%s, thread_id=%s, copilot_mode=%s", session_id, thread_id, copilot_mode)
"Created chat session: id=%s, thread_id=%s, copilot_mode=%s",
session_id, thread_id, copilot_mode
)
return await self.get_session_by_id(session_id) return await self.get_session_by_id(session_id)
async def get_session_by_id( async def get_session_by_id(self, session_id: int) -> Optional[ChatSession]:
self, session_id: int
) -> Optional[ChatSession]:
""" """
Get a session by its database ID. Get a session by its database ID.
@ -175,18 +170,14 @@ class ChatSessionsRepository:
Returns: Returns:
ChatSession or None ChatSession or None
""" """
cursor = await self.conn.execute( cursor = await self.conn.execute("SELECT * FROM chat_sessions WHERE id = ?", (session_id,))
"SELECT * FROM chat_sessions WHERE id = ?", (session_id,)
)
row = await cursor.fetchone() row = await cursor.fetchone()
if row: if row:
return self._row_to_session(row) return self._row_to_session(row)
return None return None
async def get_session_by_thread( async def get_session_by_thread(self, thread_id: str) -> Optional[ChatSession]:
self, thread_id: str
) -> Optional[ChatSession]:
""" """
Get a session by thread_id. Get a session by thread_id.
@ -196,9 +187,7 @@ class ChatSessionsRepository:
Returns: Returns:
ChatSession or None ChatSession or None
""" """
cursor = await self.conn.execute( cursor = await self.conn.execute("SELECT * FROM chat_sessions WHERE thread_id = ?", (thread_id,))
"SELECT * FROM chat_sessions WHERE thread_id = ?", (thread_id,)
)
row = await cursor.fetchone() row = await cursor.fetchone()
if row: if row:
@ -318,10 +307,7 @@ class ChatSessionsRepository:
params.append(now) params.append(now)
params.append(thread_id) params.append(thread_id)
query = ( query = f"UPDATE chat_sessions SET {', '.join(updates)} WHERE thread_id = ?"
f"UPDATE chat_sessions SET {', '.join(updates)} WHERE thread_id "
f"= ?"
)
await self.conn.execute(query, params) await self.conn.execute(query, params)
await self.conn.commit() await self.conn.commit()
@ -340,21 +326,15 @@ class ChatSessionsRepository:
True if deleted, False if not found True if deleted, False if not found
""" """
# First, delete the checkpoint data # First, delete the checkpoint data
await self.conn.execute( await self.conn.execute("DELETE FROM checkpoints WHERE thread_id = ?", (thread_id,))
"DELETE FROM checkpoints WHERE thread_id = ?", (thread_id,)
)
# Then delete the session # Then delete the session
cursor = await self.conn.execute( cursor = await self.conn.execute("DELETE FROM chat_sessions WHERE thread_id = ?", (thread_id,))
"DELETE FROM chat_sessions WHERE thread_id = ?", (thread_id,)
)
await self.conn.commit() await self.conn.commit()
deleted = cursor.rowcount > 0 deleted = cursor.rowcount > 0
if deleted: if deleted:
log.info( log.info("Deleted chat session and checkpoints: thread_id=%s", thread_id)
"Deleted chat session and checkpoints: thread_id=%s", thread_id
)
return deleted return deleted
@ -378,13 +358,9 @@ class ChatSessionsRepository:
# Delete checkpoints and sessions # Delete checkpoints and sessions
for thread_id in thread_ids: for thread_id in thread_ids:
await self.conn.execute( await self.conn.execute("DELETE FROM checkpoints WHERE thread_id = ?", (thread_id,))
"DELETE FROM checkpoints WHERE thread_id = ?", (thread_id,)
)
cursor = await self.conn.execute( cursor = await self.conn.execute("DELETE FROM chat_sessions WHERE project_id = ?", (project_id,))
"DELETE FROM chat_sessions WHERE project_id = ?", (project_id,)
)
await self.conn.commit() await self.conn.commit()
deleted_count = cursor.rowcount deleted_count = cursor.rowcount
@ -397,9 +373,7 @@ class ChatSessionsRepository:
return deleted_count return deleted_count
async def pin_session( async def pin_session(self, thread_id: str, pinned: bool = True) -> Optional[ChatSession]:
self, thread_id: str, pinned: bool = True
) -> Optional[ChatSession]:
""" """
Pin or unpin a session. Pin or unpin a session.
@ -412,8 +386,7 @@ class ChatSessionsRepository:
""" """
now = datetime.utcnow().isoformat() now = datetime.utcnow().isoformat()
await self.conn.execute( await self.conn.execute(
"UPDATE chat_sessions SET pinned = ?, updated_at = ? WHERE " "UPDATE chat_sessions SET pinned = ?, updated_at = ? WHERE thread_id = ?",
"thread_id = ?",
(1 if pinned else 0, now, thread_id), (1 if pinned else 0, now, thread_id),
) )
await self.conn.commit() await self.conn.commit()

View File

@ -35,13 +35,10 @@ from gns3server.config import Config
SKILLS_CONFIG = { SKILLS_CONFIG = {
# Git repository URL for skills # Git repository URL for skills
"repo_url": "https://github.com/gns3/gns3-skills.git", "repo_url": "https://github.com/gns3/gns3-skills.git",
# Git branch to use # Git branch to use
"branch": "main", "branch": "main",
# Automatically pull updates on reload # Automatically pull updates on reload
"auto_update": True, "auto_update": True,
# Enable external skills loading # Enable external skills loading
# If False, use the built-in hardcoded skills # If False, use the built-in hardcoded skills
"enabled": True, "enabled": True,

View File

@ -70,22 +70,53 @@ MAX_NODE_FILE_BYTES = 50 * 1024 # 50 KiB
VALID_NODE_FIELDS = { VALID_NODE_FIELDS = {
# NodeBase # NodeBase
"compute_id", "name", "node_type", "node_id", "compute_id",
"console", "console_type", "console_auto_start", "name",
"aux", "aux_type", "properties", "label", "symbol", "node_type",
"x", "y", "z", "locked", "node_id",
"port_name_format", "port_segment_size", "first_port_name", "console",
"custom_adapters", "tags", "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 # Node
"template_id", "project_id", "node_directory", "status", "template_id",
"command_line", "width", "height", "ports", "console_host", "project_id",
"node_directory",
"status",
"command_line",
"width",
"height",
"ports",
"console_host",
} }
VALID_LINK_FIELDS = { VALID_LINK_FIELDS = {
"link_id", "project_id", "link_type", "nodes", "suspend", "link_id",
"link_style", "filters", "show_filters_icon", "project_id",
"capturing", "capture_file_name", "capture_file_path", "link_type",
"capture_compute_id", "wireshark", "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"] LINK_DEFAULT_FIELDS = ["link_id", "link_type", "nodes"]
@ -93,6 +124,7 @@ LINK_DEFAULT_FIELDS = ["link_id", "link_type", "nodes"]
# ── Helpers ──────────────────────────────────────────────────────────────── # ── Helpers ────────────────────────────────────────────────────────────────
def _get_connector(gns3_ctx: dict[str, Any]): def _get_connector(gns3_ctx: dict[str, Any]):
return Gns3Connector( return Gns3Connector(
url=gns3_ctx["server_url"], url=gns3_ctx["server_url"],
@ -102,9 +134,7 @@ def _get_connector(gns3_ctx: dict[str, Any]):
) )
def build_gns3_ctx( def build_gns3_ctx(jwt_token: str | None = None, url: str | None = None) -> dict[str, Any] | None:
jwt_token: str | None = None, url: str | None = None
) -> dict[str, Any] | None:
""" """
Build a handler ``gns3_ctx`` for in-process copilot callers. 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( raise ValueError(
f"Unrecognized link nodes format. " f"Unrecognized link nodes format. "
f"Use standard [{{\"node_id\":\"..\",\"adapter_number\":0,\"port_number\":0}},...] " f'Use standard [{{"node_id":"..","adapter_number":0,"port_number":0}},...] '
f"or compact [\"id\",0,0,\"id\",0,0], got: {nodes}" f'or compact ["id",0,0,"id",0,0], got: {nodes}'
) )
# ── Node handlers ────────────────────────────────────────────────────────── # ── Node handlers ──────────────────────────────────────────────────────────
def get_nodes_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: def get_nodes_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
project_id = params.get("project_id") project_id = params.get("project_id")
if not 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") fields = params.get("fields")
if fields: if fields:
if not isinstance(fields, list): 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] invalid = [f for f in fields if f not in VALID_NODE_FIELDS]
if invalid: if invalid:
return { return {
@ -221,7 +252,7 @@ def get_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[s
fields = params.get("fields") fields = params.get("fields")
if fields: if fields:
if not isinstance(fields, list): 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] invalid = [f for f in fields if f not in VALID_NODE_FIELDS]
if invalid: if invalid:
return { 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): def _batch_lifecycle(project_id, node_ids, action, conn, action_label):
"""Helper to run a lifecycle action on multiple nodes in parallel.""" """Helper to run a lifecycle action on multiple nodes in parallel."""
def _act(nid): def _act(nid):
try: try:
conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/{nid}/{action}") 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}"} return {"node_id": nid, "status": "success", "message": f"Node {nid} {action_label}"}
except Exception as e: except Exception as e:
return {"node_id": nid, "status": "error", "error": str(e)} return {"node_id": nid, "status": "error", "error": str(e)}
with ThreadPoolExecutor(max_workers=min(len(node_ids), BATCH_MAX_WORKERS)) as pool: with ThreadPoolExecutor(max_workers=min(len(node_ids), BATCH_MAX_WORKERS)) as pool:
return list(pool.map(_act, node_ids)) 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") fields = params.get("fields")
if fields is not None and not isinstance(fields, list): 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") nodes = params.get("nodes")
# Batch mode: nodes=[{template_id?, x, y, name?, compute_id?}] # 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"} return {"error": "nodes must be a non-empty array"}
default_tid = params.get("template_id") default_tid = params.get("template_id")
conn = _get_connector(gns3_ctx) conn = _get_connector(gns3_ctx)
def _create_one(node_data): def _create_one(node_data):
tid = node_data.get("template_id", default_tid) tid = node_data.get("template_id", default_tid)
if not 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)} return {"template_id": tid, "status": "success", "node": _filter_node_response(resp, fields)}
except Exception as e: except Exception as e:
return {"template_id": tid, "status": "error", "error": str(e)} return {"template_id": tid, "status": "error", "error": str(e)}
if any(not node.get("name") for node in nodes): if any(not node.get("name") for node in nodes):
# The controller assigns default names (R-1, R-2, ...) and console # The controller assigns default names (R-1, R-2, ...) and console
# ports in request arrival order, and a parallel fan-out makes the # 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): if not isinstance(node_ids, list):
return {"error": "node_ids must be a list"} return {"error": "node_ids must be a list"}
conn = _get_connector(gns3_ctx) conn = _get_connector(gns3_ctx)
def _del(nid): def _del(nid):
try: try:
conn.http_call("delete", f"{conn.base_url}/projects/{project_id}/nodes/{nid}") conn.http_call("delete", f"{conn.base_url}/projects/{project_id}/nodes/{nid}")
return {"node_id": nid, "status": "success", "message": f"Node {nid} deleted"} return {"node_id": nid, "status": "success", "message": f"Node {nid} deleted"}
except Exception as e: except Exception as e:
return {"node_id": nid, "status": "error", "error": str(e)} return {"node_id": nid, "status": "error", "error": str(e)}
with ThreadPoolExecutor(max_workers=min(len(node_ids), BATCH_MAX_WORKERS)) as pool: with ThreadPoolExecutor(max_workers=min(len(node_ids), BATCH_MAX_WORKERS)) as pool:
return list(pool.map(_del, node_ids)) return list(pool.map(_del, node_ids))
node_id = params.get("node_id") 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 → # the ~200-char JWT previously embedded here (dropped header segment →
# "Missing 'alg' value in header" on the server). # "Missing 'alg' value in header" on the server).
username = gns3_ctx.get("jwt_username") username = gns3_ctx.get("jwt_username")
ticket = access_ticket_service.mint( ticket = (
username, access_ticket_service.mint(
token_version=gns3_ctx.get("jwt_token_version", 0), username,
project_id=project_id, token_version=gns3_ctx.get("jwt_token_version", 0),
node_id=node_id, project_id=project_id,
) if username else None node_id=node_id,
)
if username
else None
)
raw_url = f"{gns3_ctx['server_url']}/v3/projects/{project_id}/nodes/{node_id}/console/ws" raw_url = f"{gns3_ctx['server_url']}/v3/projects/{project_id}/nodes/{node_id}/console/ws"
if ticket: if ticket:
raw_url += f"?token={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) total_lines = len(lines)
# Apply offset/limit # 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 has_more = (offset + limit) < total_lines or truncated
content = "".join(selected) 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"} return {"error": "project_id and node_id are required"}
conn = _get_connector(gns3_ctx) 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} 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} 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 ────────────────────────────────────────────────────────── # ── Link handlers ──────────────────────────────────────────────────────────
def get_links_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: def get_links_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
project_id = params.get("project_id") project_id = params.get("project_id")
if not 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") fields = params.get("fields")
if fields: if fields:
if not isinstance(fields, list): 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] invalid = [f for f in fields if f not in VALID_LINK_FIELDS]
if invalid: if invalid:
return { return {
@ -669,7 +713,7 @@ def create_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic
fields = params.get("fields") fields = params.get("fields")
if fields is not None and not isinstance(fields, list): 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") links = params.get("links")
# Batch mode: links=[{nodes, link_type?, filters?, suspend?}] # 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: if not isinstance(links, list) or not links:
return {"error": "links must be a non-empty array"} return {"error": "links must be a non-empty array"}
conn = _get_connector(gns3_ctx) conn = _get_connector(gns3_ctx)
def _create_one(link_data): def _create_one(link_data):
raw_nodes = link_data.get("nodes") raw_nodes = link_data.get("nodes")
if not raw_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)} return {"status": "success", "link": _filter_link_response(resp, fields)}
except Exception as e: except Exception as e:
return {"status": "error", "error": str(e)} return {"status": "error", "error": str(e)}
with ThreadPoolExecutor(max_workers=min(len(links), BATCH_MAX_WORKERS)) as pool: with ThreadPoolExecutor(max_workers=min(len(links), BATCH_MAX_WORKERS)) as pool:
# pool.map keeps the submission order, so callers can correlate # pool.map keeps the submission order, so callers can correlate
# results with the links they sent regardless of completion order # 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): if not isinstance(link_ids, list):
return {"error": "link_ids must be a list"} return {"error": "link_ids must be a list"}
conn = _get_connector(gns3_ctx) conn = _get_connector(gns3_ctx)
def _del(lid): def _del(lid):
try: try:
conn.http_call("delete", f"{conn.base_url}/projects/{project_id}/links/{lid}") conn.http_call("delete", f"{conn.base_url}/projects/{project_id}/links/{lid}")
return {"link_id": lid, "status": "success", "message": f"Link {lid} deleted"} return {"link_id": lid, "status": "success", "message": f"Link {lid} deleted"}
except Exception as e: except Exception as e:
return {"link_id": lid, "status": "error", "error": str(e)} return {"link_id": lid, "status": "error", "error": str(e)}
with ThreadPoolExecutor(max_workers=min(len(link_ids), BATCH_MAX_WORKERS)) as pool: with ThreadPoolExecutor(max_workers=min(len(link_ids), BATCH_MAX_WORKERS)) as pool:
return list(pool.map(_del, link_ids)) return list(pool.map(_del, link_ids))
link_id = params.get("link_id") 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): if not isinstance(link_ids, list):
return {"error": "link_ids must be a list"} return {"error": "link_ids must be a list"}
conn = _get_connector(gns3_ctx) conn = _get_connector(gns3_ctx)
def _rst(lid): def _rst(lid):
try: try:
url = f"{conn.base_url}/projects/{project_id}/links/{lid}/reset" 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} return {"link_id": lid, "status": "reset", "link": r}
except Exception as e: except Exception as e:
return {"link_id": lid, "status": "error", "error": str(e)} return {"link_id": lid, "status": "error", "error": str(e)}
with ThreadPoolExecutor(max_workers=min(len(link_ids), BATCH_MAX_WORKERS)) as pool: with ThreadPoolExecutor(max_workers=min(len(link_ids), BATCH_MAX_WORKERS)) as pool:
return list(pool.map(_rst, link_ids)) return list(pool.map(_rst, link_ids))
link_id = params.get("link_id") 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): def _batch_capture(project_id, link_ids, action, data_builder, conn):
"""Helper for batch capture start/stop.""" """Helper for batch capture start/stop."""
def _act(lid): def _act(lid):
try: try:
url = f"{conn.base_url}/projects/{project_id}/links/{lid}/capture/{action}" 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"} return {"link_id": lid, "status": "success"}
except Exception as e: except Exception as e:
return {"link_id": lid, "status": "error", "error": str(e)} return {"link_id": lid, "status": "error", "error": str(e)}
with ThreadPoolExecutor(max_workers=min(len(link_ids), BATCH_MAX_WORKERS)) as pool: with ThreadPoolExecutor(max_workers=min(len(link_ids), BATCH_MAX_WORKERS)) as pool:
return list(pool.map(_act, link_ids)) 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") dlt = params.get("data_link_type", "DLT_EN10MB")
ws = params.get("wireshark", False) ws = params.get("wireshark", False)
fname = params.get("capture_file_name") fname = params.get("capture_file_name")
def _build(lid): def _build(lid):
data = {"data_link_type": dlt, "wireshark": ws} data = {"data_link_type": dlt, "wireshark": ws}
if fname: if fname:
data["capture_file_name"] = fname data["capture_file_name"] = fname
return {"json_data": data} return {"json_data": data}
return _batch_capture(project_id, link_ids, "start", _build, conn) return _batch_capture(project_id, link_ids, "start", _build, conn)
link_id = params.get("link_id") link_id = params.get("link_id")
if not 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): if not isinstance(link_ids, list):
return {"error": "link_ids must be a list"} return {"error": "link_ids must be a list"}
results = [_download(lid) for lid in link_ids] 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") link_id = params.get("link_id")
if not 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"): elif direction in ("tx", "rx"):
body["direction"] = direction body["direction"] = direction
if not body: 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() return conn.http_call("put", url, json_data=body).json()
# action == "delete" # action == "delete"
conn.http_call("delete", url) 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]: 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: if params.get(opt) is not None:
body[opt] = params[opt] body[opt] = params[opt]
if not body: 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() return conn.http_call("put", url, json_data=body).json()
# action == "delete" # action == "delete"

View File

@ -139,17 +139,11 @@ class Gns3Connector:
self.session.headers["Accept"] = "application/json" # pragma: no cover self.session.headers["Accept"] = "application/json" # pragma: no cover
# Set authentication based on API version # Set authentication based on API version
if ( if self.auth_type == "basic" and self.user is not None and self.cred is not None:
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 self.session.auth = (self.user, self.cred) # pragma: no cover
elif self.auth_type == "jwt" and self.access_token: elif self.auth_type == "jwt" and self.access_token:
self.session.headers["Authorization"] = ( self.session.headers["Authorization"] = f"Bearer {self.access_token}"
f"Bearer {self.access_token}"
)
def _authenticate_v3(self) -> None: def _authenticate_v3(self) -> None:
""" """
@ -161,15 +155,10 @@ class Gns3Connector:
return return
if not self.user or not self.cred: if not self.user or not self.cred:
raise ValueError( raise ValueError("Username and password are required for v3 authentication when no JWT token is provided")
"Username and password are required for v3 authentication "
"when no JWT token is provided"
)
# Construct authentication URL (v3 API uses different base URL) # Construct authentication URL (v3 API uses different base URL)
auth_url = ( auth_url = f"{self.base_url.replace('/v3', '')}/v3/access/users/authenticate"
f"{self.base_url.replace('/v3', '')}/v3/access/users/authenticate"
)
auth_data = {"username": self.user, "password": self.cred} auth_data = {"username": self.user, "password": self.cred}
# Use temporary session for authentication # Use temporary session for authentication
@ -177,21 +166,14 @@ class Gns3Connector:
temp_session.headers["Content-Type"] = "application/json" temp_session.headers["Content-Type"] = "application/json"
try: try:
response = temp_session.post( response = temp_session.post(auth_url, json=auth_data, verify=self.verify, timeout=10.0)
auth_url, json=auth_data, verify=self.verify, timeout=10.0
)
if response.status_code == 200: if response.status_code == 200:
auth_result = response.json() auth_result = response.json()
self.access_token = auth_result["access_token"] self.access_token = auth_result["access_token"]
# Update session with new token # Update session with new token
self.session.headers["Authorization"] = ( self.session.headers["Authorization"] = f"Bearer {self.access_token}"
f"Bearer {self.access_token}"
)
else: else:
raise HTTPError( raise HTTPError(f"v3 API authentication failed: {response.status_code} - {response.text}")
f"v3 API authentication failed: {response.status_code} - "
f"{response.text}"
)
except Exception as e: except Exception as e:
raise HTTPError(f"v3 API authentication error: {str(e)}") from e raise HTTPError(f"v3 API authentication error: {str(e)}") from e
@ -205,9 +187,7 @@ class Gns3Connector:
try: try:
# Decode token without verification to check expiry # Decode token without verification to check expiry
decoded: dict[str, Any] = jwt.decode( decoded: dict[str, Any] = jwt.decode(token, options={"verify_signature": False})
token, options={"verify_signature": False}
)
exp = decoded.get("exp") exp = decoded.get("exp")
if exp is not None: if exp is not None:
return time.time() > float(exp) return time.time() > float(exp)
@ -237,12 +217,7 @@ class Gns3Connector:
""" """
# Handle JWT authentication # Handle JWT authentication
if ( if self.auth_type == "jwt" and not self.access_token and self.user and self.cred:
self.auth_type == "jwt"
and not self.access_token
and self.user
and self.cred
):
self._authenticate_v3() self._authenticate_v3()
# Get request function (e.g., session.get, session.post) # Get request function (e.g., session.get, session.post)
@ -285,15 +260,10 @@ class Gns3Connector:
try: try:
# Only attempt parsing when Content-Type is JSON # Only attempt parsing when Content-Type is JSON
if ( if "application/json" in response.headers.get("Content-Type", "").lower():
"application/json"
in response.headers.get("Content-Type", "").lower()
):
error_json = response.json() error_json = response.json()
status = error_json.get("status", "Unknown Status") status = error_json.get("status", "Unknown Status")
message = error_json.get( message = error_json.get("message", "No message provided in JSON.")
"message", "No message provided in JSON."
)
# Construct a more descriptive new error # Construct a more descriptive new error
new_err = HTTPError( new_err = HTTPError(
f"{status}: {message} (Original {response.status_code} Error)", f"{status}: {message} (Original {response.status_code} Error)",

View File

@ -76,10 +76,7 @@ def _get_url_from_controller() -> Optional[str]:
controller = Controller.instance() controller = Controller.instance()
local_compute = controller.get_compute("local") local_compute = controller.get_compute("local")
url = ( url = f"{local_compute.protocol}://{local_compute.host}:{local_compute.port}"
f"{local_compute.protocol}://{local_compute.host}:"
f"{local_compute.port}"
)
logger.debug( logger.debug(
"Got GNS3 URL from Controller: %s (protocol=%s, host=%s, port=%s)", "Got GNS3 URL from Controller: %s (protocol=%s, host=%s, port=%s)",
url, url,
@ -98,9 +95,7 @@ def _get_url_from_controller() -> Optional[str]:
logger.debug("Local compute not found in Controller: %s", str(e)) logger.debug("Local compute not found in Controller: %s", str(e))
return None return None
except Exception as e: except Exception as e:
logger.warning( logger.warning("Unexpected error getting URL from Controller: %s", str(e))
"Unexpected error getting URL from Controller: %s", str(e)
)
return None return None
@ -114,10 +109,7 @@ def _get_url_from_config() -> Optional[str]:
from gns3server.config import Config from gns3server.config import Config
server_config = Config.instance().settings.Server server_config = Config.instance().settings.Server
url = ( url = f"{server_config.protocol.value}://{server_config.host}:{server_config.port}"
f"{server_config.protocol.value}://{server_config.host}:"
f"{server_config.port}"
)
logger.debug( logger.debug(
"Got GNS3 URL from Config: %s (protocol=%s, host=%s, port=%s)", "Got GNS3 URL from Config: %s (protocol=%s, host=%s, port=%s)",
url, url,
@ -137,9 +129,7 @@ def _get_url_from_config() -> Optional[str]:
return None return None
def get_gns3_connector( def get_gns3_connector(jwt_token: Optional[str] = None, url: Optional[str] = None) -> Optional[Gns3Connector]:
jwt_token: Optional[str] = None, url: Optional[str] = None
) -> Optional[Gns3Connector]:
"""Create and return a Gns3Connector instance with JWT authentication. """Create and return a Gns3Connector instance with JWT authentication.
URL Resolution Strategy (in order): URL Resolution Strategy (in order):
@ -223,9 +213,7 @@ def get_gns3_connector(
return connector return connector
except Exception as e: except Exception as e:
logger.error( logger.error("Failed to create Gns3Connector: %s", str(e), exc_info=True)
"Failed to create Gns3Connector: %s", str(e), exc_info=True
)
return None return None
@ -284,9 +272,7 @@ async def get_gns3_connector_with_llm_config(
url = _detect_url_for_api() url = _detect_url_for_api()
# Step 3: Get LLM config # Step 3: Get LLM config
llm_config = get_llm_config( llm_config = get_llm_config(user_id=user_id, jwt_token=jwt_token, app=app)
user_id=user_id, jwt_token=jwt_token, app=app
)
if not llm_config: if not llm_config:
logger.warning(f"No LLM config found for user {user_id}") 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} return {"connector": connector, "llm_config": llm_config}
except Exception as e: except Exception as e:
logger.error( logger.error(f"Failed to get GNS3 connector with LLM config: {e}", exc_info=True)
f"Failed to get GNS3 connector with LLM config: {e}", exc_info=True
)
return None return None
@ -375,9 +359,7 @@ def get_gns3_server_host() -> str:
logger.debug("Extracted GNS3 server host: %s from URL: %s", host, url) logger.debug("Extracted GNS3 server host: %s from URL: %s", host, url)
return host return host
except Exception as e: except Exception as e:
logger.warning( logger.warning("Failed to extract host from URL %s: %s, using fallback", url, e)
"Failed to extract host from URL %s: %s, using fallback", url, e
)
return DEFAULT_GNS3_URL.split("://")[1].split(":")[0] 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 # We're in an async context with a running loop
# This shouldn't happen since this is a sync function # This shouldn't happen since this is a sync function
with concurrent.futures.ThreadPoolExecutor() as executor: with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit( future = executor.submit(asyncio.run, get_user_llm_config_with_app(user_id, app))
asyncio.run, get_user_llm_config_with_app(user_id, app)
)
return future.result(timeout=10) return future.result(timeout=10)
except RuntimeError: except RuntimeError:
# No running event loop - we're in a sync context # 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) return future.result(timeout=10)
else: else:
# Loop exists but not running - use it # Loop exists but not running - use it
return loop.run_until_complete( return loop.run_until_complete(get_user_llm_config_with_app(user_id, app))
get_user_llm_config_with_app(user_id, app)
)
except RuntimeError: except RuntimeError:
# No event loop exists - create a new one # No event loop exists - create a new one
loop = asyncio.new_event_loop() loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop) asyncio.set_event_loop(loop)
try: try:
return loop.run_until_complete( return loop.run_until_complete(get_user_llm_config_with_app(user_id, app))
get_user_llm_config_with_app(user_id, app)
)
finally: finally:
loop.close() loop.close()
# Fallback: No app provided, try API call (will mask group config API # Fallback: No app provided, try API call (will mask group config API
# keys) # keys)
logger.warning( logger.warning("No app provided for get_llm_config, group config API keys may be masked")
"No app provided for get_llm_config, group config API keys may be "
"masked"
)
return None return None
except Exception as e: except Exception as e:

View File

@ -62,12 +62,8 @@ logger = logging.getLogger(__name__)
# Context variables for request-scoped data # Context variables for request-scoped data
# Automatically cleaned up when request context ends # Automatically cleaned up when request context ends
_jwt_token_context: ContextVar[Optional[str]] = ContextVar( _jwt_token_context: ContextVar[Optional[str]] = ContextVar("_jwt_token_context", default=None)
"_jwt_token_context", default=None _llm_config_context: ContextVar[Optional[dict]] = ContextVar("_llm_config_context", default=None)
)
_llm_config_context: ContextVar[Optional[dict]] = ContextVar(
"_llm_config_context", default=None
)
def set_current_jwt_token(token: str) -> None: def set_current_jwt_token(token: str) -> None:

View File

@ -90,20 +90,13 @@ class GNS3ProjectInfoTool(BaseTool):
""" """
# Log received input # Log received input
logger.info( logger.info("Received tool_input: %s, project_id: %s", tool_input, project_id)
"Received tool_input: %s, project_id: %s", tool_input, project_id
)
try: try:
# Validate project_id parameter # Validate project_id parameter
if not project_id: if not project_id:
logger.error("project_id parameter is required.") logger.error("project_id parameter is required.")
return { return {"error": ("project_id parameter is required. Please provide a valid project UUID.")}
"error": (
"project_id parameter is required. Please provide a valid "
"project UUID."
)
}
# Build handler context (JWT + server URL from request context) # Build handler context (JWT + server URL from request context)
logger.debug("Connecting to GNS3 server...") logger.debug("Connecting to GNS3 server...")
@ -111,17 +104,10 @@ class GNS3ProjectInfoTool(BaseTool):
if gns3_ctx is None: if gns3_ctx is None:
logger.error("Failed to create GNS3 connector") logger.error("Failed to create GNS3 connector")
return { return {"error": ("Failed to connect to GNS3 server. Please check your configuration.")}
"error": (
"Failed to connect to GNS3 server. Please check your "
"configuration."
)
}
# Use the provided project_id directly # Use the provided project_id directly
logger.info( logger.info(f"Retrieving project info for project_id: {project_id}")
f"Retrieving project info for project_id: {project_id}"
)
inventory = fetch_project_inventory(gns3_ctx, project_id) inventory = fetch_project_inventory(gns3_ctx, project_id)
# Get node and link counts # Get node and link counts

View File

@ -100,18 +100,13 @@ class GNS3TopologyTool(BaseTool):
""" """
# Log received input # Log received input
logger.info( logger.info("Received tool_input: %s, project_id: %s", tool_input, project_id)
"Received tool_input: %s, project_id: %s", tool_input, project_id
)
try: try:
# Validate project_id parameter # Validate project_id parameter
if not project_id: if not project_id:
logger.error("project_id parameter is required.") logger.error("project_id parameter is required.")
return { return {"error": "project_id parameter is required. Please provide a valid project UUID."}
"error": "project_id parameter is required. "
"Please provide a valid project UUID."
}
# Build handler context (JWT + server URL) # Build handler context (JWT + server URL)
# jwt_token/url can be passed explicitly (e.g. from MCP handlers) # jwt_token/url can be passed explicitly (e.g. from MCP handlers)
@ -121,10 +116,7 @@ class GNS3TopologyTool(BaseTool):
if gns3_ctx is None: if gns3_ctx is None:
logger.error("Failed to create GNS3 connector") logger.error("Failed to create GNS3 connector")
return { return {"error": "Failed to connect to GNS3 server. Please check your configuration."}
"error": "Failed to connect to GNS3 server. Please check "
"your configuration."
}
# Use the provided project_id directly # Use the provided project_id directly
logger.info(f"Retrieving topology for project_id: {project_id}") logger.info(f"Retrieving topology for project_id: {project_id}")
@ -135,16 +127,13 @@ class GNS3TopologyTool(BaseTool):
"project_id": inventory["project_id"], "project_id": inventory["project_id"],
"name": inventory["name"], "name": inventory["name"],
"status": inventory["status"], "status": inventory["status"],
"nodes": self._clean_nodes_ports( "nodes": self._clean_nodes_ports(copy.deepcopy(inventory["nodes_inventory"])),
copy.deepcopy(inventory["nodes_inventory"])
),
"links": inventory["links_summary"], "links": inventory["links_summary"],
} }
# Log topology result # Log topology result
logger.info( logger.info(
"Topology retrieved: project_id=%s, name=%s, nodes=%d, " "Topology retrieved: project_id=%s, name=%s, nodes=%d, links=%d",
"links=%d",
topology.get("project_id"), topology.get("project_id"),
topology.get("name"), topology.get("name"),
len(topology.get("nodes", {})), 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 for node in data.values(): # Iterate through R-1, R-2, R-3, R-4
if "ports" in node and isinstance(node["ports"], list): if "ports" in node and isinstance(node["ports"], list):
node["ports"] = [ node["ports"] = [{"name": port["name"], "short_name": port["short_name"]} for port in node["ports"]]
{"name": port["name"], "short_name": port["short_name"]}
for port in node["ports"]
]
return data return data

View File

@ -38,9 +38,7 @@ from urllib.parse import urlparse
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import _get_connector from gns3server.agent.gns3_copilot.gns3_client.api_handlers import _get_connector
def build_nodes_inventory( def build_nodes_inventory(nodes: list[dict[str, Any]], server_host: str | None) -> dict[str, Any]:
nodes: list[dict[str, Any]], server_host: str | None
) -> dict[str, Any]:
""" """
Build an inventory-style dict keyed by node name. Build an inventory-style dict keyed by node name.
@ -70,9 +68,7 @@ def build_nodes_inventory(
return inventory return inventory
def build_links_summary( def build_links_summary(nodes: list[dict[str, Any]], links: list[dict[str, Any]]) -> list[dict[str, str]]:
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. 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_a = link["nodes"][0]
side_b = link["nodes"][1] side_b = link["nodes"][1]
try: try:
node_a = next( node_a = next(x for x in nodes if x.get("node_id") == side_a["node_id"])
x for x in nodes if x.get("node_id") == side_a["node_id"]
)
port_a = str( port_a = str(
next( next(
p["name"] p["name"]
for p in (node_a.get("ports") or []) for p in (node_a.get("ports") or [])
if p["port_number"] == side_a["port_number"] if p["port_number"] == side_a["port_number"] and p["adapter_number"] == side_a["adapter_number"]
and p["adapter_number"] == side_a["adapter_number"]
) )
) )
node_b = next( node_b = next(x for x in nodes if x.get("node_id") == side_b["node_id"])
x for x in nodes if x.get("node_id") == side_b["node_id"]
)
port_b = str( port_b = str(
next( next(
p["name"] p["name"]
for p in (node_b.get("ports") or []) for p in (node_b.get("ports") or [])
if p["port_number"] == side_b["port_number"] if p["port_number"] == side_b["port_number"] and p["adapter_number"] == side_b["adapter_number"]
and p["adapter_number"] == side_b["adapter_number"]
) )
) )
name_a = str(node_a["name"]) if node_a.get("name") else "Unknown" 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" name_b = str(node_b["name"]) if node_b.get("name") else "Unknown"
summary.append({ summary.append(
"link_id": link.get("link_id"), {
"node_a": name_a, "link_id": link.get("link_id"),
"port_a": port_a, "node_a": name_a,
"node_b": name_b, "port_a": port_a,
"port_b": port_b, "node_b": name_b,
}) "port_b": port_b,
}
)
except (StopIteration, KeyError, AttributeError): except (StopIteration, KeyError, AttributeError):
# Prevent errors when lookups can't match data # Prevent errors when lookups can't match data
continue continue
return summary return summary
def fetch_project_inventory( def fetch_project_inventory(gns3_ctx: dict[str, Any], project_id: str) -> dict[str, Any]:
gns3_ctx: dict[str, Any], project_id: str
) -> dict[str, Any]:
""" """
Fetch a project's metadata, nodes and links and return the aggregated Fetch a project's metadata, nodes and links and return the aggregated
inventory — the equivalent of the old ``Project.get()`` + inventory — the equivalent of the old ``Project.get()`` +

View File

@ -61,9 +61,7 @@ class ProjectAgentManager:
cls._instance._lock = asyncio.Lock() cls._instance._lock = asyncio.Lock()
return cls._instance return cls._instance
async def get_agent( async def get_agent(self, project_id: str, project_path: str) -> AgentService:
self, project_id: str, project_path: str
) -> AgentService:
""" """
Get or create an AgentService for a project. Get or create an AgentService for a project.

View File

@ -70,10 +70,7 @@ def load_system_prompt(llm_config: dict | None = None) -> str:
str: The system prompt string, or empty string if not found. str: The system prompt string, or empty string if not found.
""" """
if not llm_config: if not llm_config:
logger.debug( logger.debug("No LLM config provided, using default TEACHING_ASSISTANT prompt mode")
"No LLM config provided, using default TEACHING_ASSISTANT "
"prompt mode"
)
return _load_prompt("teaching_assistant") return _load_prompt("teaching_assistant")
# llm_config is a flattened dict with copilot_mode at the top level # llm_config is a flattened dict with copilot_mode at the top level
@ -81,15 +78,10 @@ def load_system_prompt(llm_config: dict | None = None) -> str:
mode = llm_config.get("copilot_mode", "teaching_assistant").lower() mode = llm_config.get("copilot_mode", "teaching_assistant").lower()
if mode == "lab_automation_assistant": if mode == "lab_automation_assistant":
logger.debug( logger.debug("Using LAB_AUTOMATION_ASSISTANT prompt mode (diagnostics + configuration)")
"Using LAB_AUTOMATION_ASSISTANT prompt mode (diagnostics + "
"configuration)"
)
return _load_prompt("lab_automation_assistant") return _load_prompt("lab_automation_assistant")
elif mode == "troubleshooting_injection": elif mode == "troubleshooting_injection":
logger.debug( logger.debug("Using TROUBLESHOOTING_INJECTION prompt mode (fault injection)")
"Using TROUBLESHOOTING_INJECTION prompt mode (fault injection)"
)
return _load_prompt("troubleshooting_injection") return _load_prompt("troubleshooting_injection")
else: else:
logger.debug("Using TEACHING_ASSISTANT prompt mode (diagnostics only)") logger.debug("Using TEACHING_ASSISTANT prompt mode (diagnostics only)")

View File

@ -201,7 +201,9 @@ class SkillsLoader:
topic_key = yaml_file.stem topic_key = yaml_file.stem
logger.warning(f"No 'topic' field in {yaml_file}, using filename '{topic_key}'") logger.warning(f"No 'topic' field in {yaml_file}, using filename '{topic_key}'")
if topic_key in topics: if topic_key in topics:
logger.warning(f"Duplicate topic '{topic_key}' in {device_path.name} (from {yaml_file}), overwriting") logger.warning(
f"Duplicate topic '{topic_key}' in {device_path.name} (from {yaml_file}), overwriting"
)
# category/topics belong to the base skill only # category/topics belong to the base skill only
topic_data.pop("category", None) topic_data.pop("category", None)

View File

@ -49,9 +49,9 @@ logger = logging.getLogger(__name__)
# Applied per-command via the `env` parameter to avoid polluting # Applied per-command via the `env` parameter to avoid polluting
# the global process environment. # the global process environment.
_GIT_TIMEOUT_ENV = { _GIT_TIMEOUT_ENV = {
'GIT_HTTP_TIMEOUT': '10', # Connection timeout (default: 120s) "GIT_HTTP_TIMEOUT": "10", # Connection timeout (default: 120s)
'GIT_HTTP_LOW_SPEED_TIME': '5', # Slow speed threshold window "GIT_HTTP_LOW_SPEED_TIME": "5", # Slow speed threshold window
'GIT_HTTP_LOW_SPEED_LIMIT': '10240', # < 10 KB/s = slow → abort "GIT_HTTP_LOW_SPEED_LIMIT": "10240", # < 10 KB/s = slow → abort
} }
@ -66,12 +66,7 @@ class SkillsManager:
- Version tracking - Version tracking
""" """
def __init__( def __init__(self, repo_url: str = None, branch: str = "main", auto_update: bool = False):
self,
repo_url: str = None,
branch: str = "main",
auto_update: bool = False
):
""" """
Initialize the skills manager. Initialize the skills manager.
@ -147,7 +142,7 @@ class SkillsManager:
logger.warning( logger.warning(
"Skills repository has uncommitted changes, skipping pull. " "Skills repository has uncommitted changes, skipping pull. "
"Commit or stash changes in %s to enable automatic updates.", "Commit or stash changes in %s to enable automatic updates.",
self.local_path self.local_path,
) )
return return
@ -161,14 +156,9 @@ class SkillsManager:
# Check if behind and pull # Check if behind and pull
try: try:
behind_commits = list(self._repo.iter_commits( behind_commits = list(self._repo.iter_commits(f"{self.branch}..origin/{self.branch}"))
f'{self.branch}..origin/{self.branch}'
))
if behind_commits: if behind_commits:
logger.info( logger.info("Skills repository is behind by %d commit(s), pulling...", len(behind_commits))
"Skills repository is behind by %d commit(s), pulling...",
len(behind_commits)
)
origin.pull(self.branch, env=_GIT_TIMEOUT_ENV) origin.pull(self.branch, env=_GIT_TIMEOUT_ENV)
logger.info(f"Updated to commit {self.get_current_version()}") logger.info(f"Updated to commit {self.get_current_version()}")
else: else:
@ -188,12 +178,7 @@ class SkillsManager:
return False return False
try: try:
self._repo = git.Repo.clone_from( self._repo = git.Repo.clone_from(self.repo_url, self.local_path, branch=self.branch, env=_GIT_TIMEOUT_ENV)
self.repo_url,
self.local_path,
branch=self.branch,
env=_GIT_TIMEOUT_ENV
)
logger.info(f"Successfully cloned skills repository to {self.local_path}") logger.info(f"Successfully cloned skills repository to {self.local_path}")
return True return True
except git.GitCommandError as e: except git.GitCommandError as e:
@ -256,7 +241,9 @@ class SkillsManager:
del SKILLS_REGISTRY[k] del SKILLS_REGISTRY[k]
SKILLS_REGISTRY.update(all_skills) SKILLS_REGISTRY.update(all_skills)
logger.info(f"Loaded {len(new_injection_skills)} injection skills, {len(new_device_skills)} device skills, and {len(new_feature_skills)} feature skills") logger.info(
f"Loaded {len(new_injection_skills)} injection skills, {len(new_device_skills)} device skills, and {len(new_feature_skills)} feature skills"
)
return True return True
except Exception as e: except Exception as e:
logger.error(f"Failed to reload skills: {e}") logger.error(f"Failed to reload skills: {e}")
@ -302,12 +289,7 @@ class SkillsManager:
""" """
try: try:
# Available prompt names # Available prompt names
prompt_names = [ prompt_names = ["lab_automation_assistant", "teaching_assistant", "troubleshooting_injection", "title"]
"lab_automation_assistant",
"teaching_assistant",
"troubleshooting_injection",
"title"
]
# Load all prompts # Load all prompts
loaded_count = 0 loaded_count = 0
@ -385,6 +367,7 @@ class SkillsManager:
""" """
try: try:
from .registry import INJECTION_SKILLS_REGISTRY from .registry import INJECTION_SKILLS_REGISTRY
return len(INJECTION_SKILLS_REGISTRY) return len(INJECTION_SKILLS_REGISTRY)
except Exception: except Exception:
return 0 return 0
@ -413,7 +396,7 @@ class SkillsManager:
"skill_count": self.get_skill_count(), "skill_count": self.get_skill_count(),
"prompt_count": self.get_prompt_count(), "prompt_count": self.get_prompt_count(),
"auto_update": self.auto_update, "auto_update": self.auto_update,
"is_initialized": (self.local_path / ".git").exists() "is_initialized": (self.local_path / ".git").exists(),
} }
def rollback(self, commit_hash: str) -> bool: def rollback(self, commit_hash: str) -> bool:
@ -462,12 +445,14 @@ class SkillsManager:
commits = [] commits = []
for commit in self._repo.iter_commits(max_count=limit): for commit in self._repo.iter_commits(max_count=limit):
commits.append({ commits.append(
"hash": commit.hexsha, {
"message": commit.message.strip(), "hash": commit.hexsha,
"author": str(commit.author), "message": commit.message.strip(),
"date": commit.committed_datetime.isoformat() "author": str(commit.author),
}) "date": commit.committed_datetime.isoformat(),
}
)
return commits return commits
except Exception as e: except Exception as e:
logger.error(f"Failed to get commit history: {e}") logger.error(f"Failed to get commit history: {e}")

View File

@ -115,7 +115,7 @@ def _ensure_skills_manager():
manager = SkillsManager( manager = SkillsManager(
repo_url=config.get("repo_url"), repo_url=config.get("repo_url"),
branch=config.get("branch", "main"), branch=config.get("branch", "main"),
auto_update=config.get("auto_update", False) auto_update=config.get("auto_update", False),
) )
if not manager.initialize(): if not manager.initialize():
@ -224,7 +224,7 @@ def reload_injection_skills() -> dict[str, Any]:
"success": False, "success": False,
"message": "Skills manager not initialized", "message": "Skills manager not initialized",
"skill_count": len(INJECTION_SKILLS_REGISTRY), "skill_count": len(INJECTION_SKILLS_REGISTRY),
"version": "" "version": "",
} }
try: try:
@ -233,7 +233,7 @@ def reload_injection_skills() -> dict[str, Any]:
"success": success, "success": success,
"message": "Skills reloaded successfully" if success else "Failed to reload skills", "message": "Skills reloaded successfully" if success else "Failed to reload skills",
"skill_count": manager.get_skill_count(), "skill_count": manager.get_skill_count(),
"version": manager.get_current_version() "version": manager.get_current_version(),
} }
except Exception as e: except Exception as e:
logger.error(f"Error during skills reload: {e}") logger.error(f"Error during skills reload: {e}")
@ -241,7 +241,7 @@ def reload_injection_skills() -> dict[str, Any]:
"success": False, "success": False,
"message": f"Error: {str(e)}", "message": f"Error: {str(e)}",
"skill_count": len(INJECTION_SKILLS_REGISTRY), "skill_count": len(INJECTION_SKILLS_REGISTRY),
"version": "" "version": "",
} }
@ -263,12 +263,7 @@ def reload_prompts() -> dict[str, Any]:
""" """
manager = get_skills_manager() manager = get_skills_manager()
if manager is None: if manager is None:
return { return {"success": False, "message": "Skills manager not initialized", "prompt_count": 0, "version": ""}
"success": False,
"message": "Skills manager not initialized",
"prompt_count": 0,
"version": ""
}
try: try:
success = manager.reload_prompts() success = manager.reload_prompts()
@ -276,7 +271,7 @@ def reload_prompts() -> dict[str, Any]:
"success": success, "success": success,
"message": "Prompts reloaded successfully" if success else "Failed to reload prompts", "message": "Prompts reloaded successfully" if success else "Failed to reload prompts",
"prompt_count": manager.get_prompt_count(), "prompt_count": manager.get_prompt_count(),
"version": manager.get_current_version() "version": manager.get_current_version(),
} }
except Exception as e: except Exception as e:
logger.error(f"Error during prompts reload: {e}") logger.error(f"Error during prompts reload: {e}")
@ -285,7 +280,7 @@ def reload_prompts() -> dict[str, Any]:
"success": False, "success": False,
"message": f"Error: {str(e)}", "message": f"Error: {str(e)}",
"prompt_count": manager.get_prompt_count() if manager else 0, "prompt_count": manager.get_prompt_count() if manager else 0,
"version": "" "version": "",
} }
@ -315,16 +310,11 @@ def reload_forbidden_commands() -> dict[str, Any]:
"success": True, "success": True,
"message": "Forbidden commands reloaded", "message": "Forbidden commands reloaded",
"command_count": len(commands), "command_count": len(commands),
"version": manager.get_current_version() if manager else "" "version": manager.get_current_version() if manager else "",
} }
except Exception as e: except Exception as e:
logger.error(f"Error during forbidden commands reload: {e}") logger.error(f"Error during forbidden commands reload: {e}")
return { return {"success": False, "message": f"Error: {str(e)}", "command_count": 0, "version": ""}
"success": False,
"message": f"Error: {str(e)}",
"command_count": 0,
"version": ""
}
def get_prompt(prompt_name: str) -> str: def get_prompt(prompt_name: str) -> str:
@ -360,10 +350,7 @@ def get_skills_repository_info() -> dict[str, Any]:
""" """
manager = get_skills_manager() manager = get_skills_manager()
if manager is None: if manager is None:
return { return {"initialized": False, "message": "Skills manager not initialized"}
"initialized": False,
"message": "Skills manager not initialized"
}
return manager.get_repository_info() return manager.get_repository_info()
@ -411,8 +398,7 @@ def get_skill(
"error": f"device_type '{device_type}' is not in category '{category}'", "error": f"device_type '{device_type}' is not in category '{category}'",
"device_category": skill.get("category"), "device_category": skill.get("category"),
"available_in_category": [ "available_in_category": [
did for did, s in SKILLS_REGISTRY.items() did for did, s in SKILLS_REGISTRY.items() if s.get("category", "").lower() == category.lower()
if s.get("category", "").lower() == category.lower()
], ],
} }
@ -484,8 +470,7 @@ def get_skill(
} }
if topics: if topics:
result["topics"] = { result["topics"] = {
k: {"name": v.get("name", k), "description": v.get("description", "")} k: {"name": v.get("name", k), "description": v.get("description", "")} for k, v in topics.items()
for k, v in topics.items()
} }
return result return result
@ -503,19 +488,23 @@ def list_available_skills(category: str | None = None) -> list[dict[str, Any]]:
for did, skill in SKILLS_REGISTRY.items(): for did, skill in SKILLS_REGISTRY.items():
if category: if category:
if skill.get("category", "").lower() == category.lower(): if skill.get("category", "").lower() == category.lower():
skills.append({ skills.append(
{
"device_type": did,
"name": skill.get("name", did),
"category": skill.get("category"),
"topic_count": len(skill.get("topics", {})),
}
)
else:
skills.append(
{
"device_type": did, "device_type": did,
"name": skill.get("name", did), "name": skill.get("name", did),
"category": skill.get("category"), "category": skill.get("category"),
"topic_count": len(skill.get("topics", {})), "topic_count": len(skill.get("topics", {})),
}) }
else: )
skills.append({
"device_type": did,
"name": skill.get("name", did),
"category": skill.get("category"),
"topic_count": len(skill.get("topics", {})),
})
return skills return skills
@ -541,7 +530,7 @@ def get_injection_skill(
return { return {
"error": f"Unknown injection fault type: {device_type}", "error": f"Unknown injection fault type: {device_type}",
"available_fault_types": list(INJECTION_SKILLS_REGISTRY.keys()), "available_fault_types": list(INJECTION_SKILLS_REGISTRY.keys()),
"hint": "Use {'action': 'list'} to see all available fault types" "hint": "Use {'action': 'list'} to see all available fault types",
} }
issues = skill.get("issues", {}) issues = skill.get("issues", {})
@ -611,11 +600,13 @@ def list_available_injection_skills(context: list[str] | None = None) -> list[di
category_lower = category.lower() category_lower = category.lower()
if not any(kw.lower() in category_lower or kw.lower() in did.lower() for kw in context): if not any(kw.lower() in category_lower or kw.lower() in did.lower() for kw in context):
continue continue
skills.append({ skills.append(
"device_type": did, {
"name": skill.get("name", did), "device_type": did,
"category": category, "name": skill.get("name", did),
}) "category": category,
}
)
return skills return skills
@ -658,11 +649,13 @@ def list_available_packet_analysis_protocols() -> list[dict[str, str]]:
""" """
protocols = [] protocols = []
for key, data in PACKET_ANALYSIS_REGISTRY.items(): for key, data in PACKET_ANALYSIS_REGISTRY.items():
protocols.append({ protocols.append(
"protocol": key, {
"name": data.get("name", key), "protocol": key,
"description": data.get("description", ""), "name": data.get("name", key),
}) "description": data.get("description", ""),
}
)
return protocols return protocols
@ -711,10 +704,14 @@ class DeviceSkillsTool(BaseTool):
try: try:
params = json.loads(tool_input) params = json.loads(tool_input)
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
return json.dumps({ return json.dumps(
"error": f"Invalid JSON input: {e}", {
"hint": 'Expected format: {"device_type": "xxx"} or {"action": "list"}' "error": f"Invalid JSON input: {e}",
}, ensure_ascii=False, indent=2) "hint": 'Expected format: {"device_type": "xxx"} or {"action": "list"}',
},
ensure_ascii=False,
indent=2,
)
else: else:
params = tool_input params = tool_input
@ -722,18 +719,19 @@ class DeviceSkillsTool(BaseTool):
if action == "list": if action == "list":
skills = list_available_skills() skills = list_available_skills()
return json.dumps({ return json.dumps({"count": len(skills), "skills": skills}, ensure_ascii=False, indent=2)
"count": len(skills),
"skills": skills
}, ensure_ascii=False, indent=2)
device_type = params.get("device_type") device_type = params.get("device_type")
if not device_type: if not device_type:
return json.dumps({ return json.dumps(
"error": "Missing required field: device_type", {
"available_device_types": list(SKILLS_REGISTRY.keys()), "error": "Missing required field: device_type",
"hint": 'Use {"action": "list"} to see all available device types' "available_device_types": list(SKILLS_REGISTRY.keys()),
}, ensure_ascii=False, indent=2) "hint": 'Use {"action": "list"} to see all available device types',
},
ensure_ascii=False,
indent=2,
)
category = params.get("category") category = params.get("category")
detail = params.get("detail", "full") detail = params.get("detail", "full")
@ -788,10 +786,14 @@ class InjectionSkillsTool(BaseTool):
try: try:
params = json.loads(tool_input) params = json.loads(tool_input)
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
return json.dumps({ return json.dumps(
"error": f"Invalid JSON input: {e}", {
"hint": 'Expected format: {"device_type": "xxx"} or {"action": "list"}' "error": f"Invalid JSON input: {e}",
}, ensure_ascii=False, indent=2) "hint": 'Expected format: {"device_type": "xxx"} or {"action": "list"}',
},
ensure_ascii=False,
indent=2,
)
else: else:
params = tool_input params = tool_input
@ -800,33 +802,44 @@ class InjectionSkillsTool(BaseTool):
if action == "list": if action == "list":
context = params.get("context") context = params.get("context")
if not context or not isinstance(context, list) or len(context) == 0: if not context or not isinstance(context, list) or len(context) == 0:
return json.dumps({ return json.dumps(
"error": "context parameter is required when action='list'", {
"hint": "Analyze the topology and device configurations first, " "error": "context parameter is required when action='list'",
"then pass the protocols/services you found as context. " "hint": "Analyze the topology and device configurations first, "
'Example: {"action": "list", "context": ["ospf", "bgp", "vlan"]}', "then pass the protocols/services you found as context. "
"available_categories": sorted(set( 'Example: {"action": "list", "context": ["ospf", "bgp", "vlan"]}',
skill.get("category", "") "available_categories": sorted(
for skill in INJECTION_SKILLS_REGISTRY.values() set(skill.get("category", "") for skill in INJECTION_SKILLS_REGISTRY.values())
)) ),
}, ensure_ascii=False, indent=2) },
ensure_ascii=False,
indent=2,
)
skills = list_available_injection_skills(context=context) skills = list_available_injection_skills(context=context)
logger.debug(f"Injection skills filtered by context={context}: {len(skills)} matching") logger.debug(f"Injection skills filtered by context={context}: {len(skills)} matching")
return json.dumps({ return json.dumps(
"count": len(skills), {
"total_available": len(INJECTION_SKILLS_REGISTRY), "count": len(skills),
"context": context, "total_available": len(INJECTION_SKILLS_REGISTRY),
"fault_types": skills "context": context,
}, ensure_ascii=False, indent=2) "fault_types": skills,
},
ensure_ascii=False,
indent=2,
)
device_type = params.get("device_type") device_type = params.get("device_type")
if not device_type: if not device_type:
return json.dumps({ return json.dumps(
"error": "Missing required field: device_type", {
"available_fault_types": list(INJECTION_SKILLS_REGISTRY.keys()), "error": "Missing required field: device_type",
"hint": 'Use {"action": "list"} to see all available fault types' "available_fault_types": list(INJECTION_SKILLS_REGISTRY.keys()),
}, ensure_ascii=False, indent=2) "hint": 'Use {"action": "list"} to see all available fault types',
},
ensure_ascii=False,
indent=2,
)
detail = params.get("detail", "full") detail = params.get("detail", "full")
issue = params.get("issue") issue = params.get("issue")
@ -876,10 +889,14 @@ class PacketAnalysisSkillsTool(BaseTool):
try: try:
params = json.loads(tool_input) params = json.loads(tool_input)
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
return json.dumps({ return json.dumps(
"error": f"Invalid JSON input: {e}", {
"hint": 'Expected format: {"action": "get", "protocol": "ospf"}' "error": f"Invalid JSON input: {e}",
}, ensure_ascii=False, indent=2) "hint": 'Expected format: {"action": "get", "protocol": "ospf"}',
},
ensure_ascii=False,
indent=2,
)
else: else:
params = tool_input params = tool_input
@ -887,17 +904,18 @@ class PacketAnalysisSkillsTool(BaseTool):
if action == "list": if action == "list":
protocols = list_available_packet_analysis_protocols() protocols = list_available_packet_analysis_protocols()
return json.dumps({ return json.dumps({"count": len(protocols), "protocols": protocols}, ensure_ascii=False, indent=2)
"count": len(protocols),
"protocols": protocols
}, ensure_ascii=False, indent=2)
protocol = params.get("protocol") protocol = params.get("protocol")
if not protocol: if not protocol:
return json.dumps({ return json.dumps(
"error": "Missing required field: protocol", {
"available_protocols": list(PACKET_ANALYSIS_REGISTRY.keys()), "error": "Missing required field: protocol",
}, ensure_ascii=False, indent=2) "available_protocols": list(PACKET_ANALYSIS_REGISTRY.keys()),
},
ensure_ascii=False,
indent=2,
)
result = get_packet_analysis_protocol(protocol) result = get_packet_analysis_protocol(protocol)
return json.dumps(result, ensure_ascii=False, indent=2) return json.dumps(result, ensure_ascii=False, indent=2)

View File

@ -196,56 +196,35 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
# Validate input # Validate input
device_configs_list, project_id = self._validate_tool_input(tool_input) device_configs_list, project_id = self._validate_tool_input(tool_input)
if ( if isinstance(device_configs_list, list) and len(device_configs_list) > 0 and "error" in device_configs_list[0]:
isinstance(device_configs_list, list)
and len(device_configs_list) > 0
and "error" in device_configs_list[0]
):
return device_configs_list return device_configs_list
# Filter forbidden commands and store blocked commands info # Filter forbidden commands and store blocked commands info
device_configs_list, blocked_commands_map = ( device_configs_list, blocked_commands_map = self._filter_forbidden_commands_from_device_configs(
self._filter_forbidden_commands_from_device_configs( device_configs_list
device_configs_list
)
) )
# Expand multiline commands (e.g., banner commands with embedded newlines) # Expand multiline commands (e.g., banner commands with embedded newlines)
# This converts commands like "banner motd #\nline1\nline2\n#" into # This converts commands like "banner motd #\nline1\nline2\n#" into
# separate commands: ["banner motd #", "line1", "line2", "#"] # separate commands: ["banner motd #", "line1", "line2", "#"]
device_configs_list = self._expand_multiline_commands( device_configs_list = self._expand_multiline_commands(device_configs_list)
device_configs_list
)
# Create a mapping of device names to their configuration commands # Create a mapping of device names to their configuration commands
device_configs_map = self._configs_map(device_configs_list) device_configs_map = self._configs_map(device_configs_list)
# Prepare device hosts data # Prepare device hosts data
try: try:
hosts_data = self._prepare_device_hosts_data( hosts_data = self._prepare_device_hosts_data(device_configs_list, project_id, jwt_token=jwt_token, url=url)
device_configs_list, project_id, jwt_token=jwt_token, url=url
)
except ValueError as e: except ValueError as e:
logger.error("Failed to prepare device hosts data: %s", e) logger.error("Failed to prepare device hosts data: %s", e)
return [{"status": "failed", "error": str(e)}] return [{"status": "failed", "error": str(e)}]
# Check if any devices have errors (e.g., missing device_type tag) # Check if any devices have errors (e.g., missing device_type tag)
error_devices = { error_devices = {name: data for name, data in hosts_data.items() if "error" in data}
name: data
for name, data in hosts_data.items()
if "error" in data
}
if error_devices: if error_devices:
logger.error( logger.error("Devices with configuration errors: %s", list(error_devices.keys()))
"Devices with configuration errors: %s",
list(error_devices.keys())
)
return [ return [
{ {"device_name": name, "status": "failed", "error": data["error"]}
"device_name": name,
"status": "failed",
"error": data["error"]
}
for name, data in error_devices.items() for name, data in error_devices.items()
] ]
@ -275,9 +254,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
except Exception as e: except Exception as e:
# Overall execution failed # Overall execution failed
logger.error( logger.error("Error executing configurations on all devices: %s", e)
"Error executing configurations on all devices: %s", e
)
return [{"status": "failed", "error": f"Execution error: {str(e)}"}] return [{"status": "failed", "error": f"Execution error: {str(e)}"}]
logger.info( logger.info(
@ -287,22 +264,16 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
return results return results
def _run_all_device_configs_with_single_retry( def _run_all_device_configs_with_single_retry(self, task: Task, device_configs_map: dict[str, list[str]]) -> Result:
self, task: Task, device_configs_map: dict[str, list[str]]
) -> Result:
"""Execute configuration commands with single retry mechanism.""" """Execute configuration commands with single retry mechanism."""
device_name = task.host.name device_name = task.host.name
config_commands = device_configs_map.get(device_name, []) config_commands = device_configs_map.get(device_name, [])
if not config_commands: if not config_commands:
return Result( return Result(host=task.host, result="No configuration commands to execute")
host=task.host, result="No configuration commands to execute"
)
try: try:
_result = task.run( _result = task.run(task=netmiko_send_config, config_commands=config_commands)
task=netmiko_send_config, config_commands=config_commands
)
return Result(host=task.host, result=_result.result) return Result(host=task.host, result=_result.result)
except ReadTimeout as e: except ReadTimeout as e:
@ -322,9 +293,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
# Handle Cisco IOSv L2 where '#' prompt char may be delayed, # Handle Cisco IOSv L2 where '#' prompt char may be delayed,
# causing Netmiko failures. Implements retry logic. # causing Netmiko failures. Implements retry logic.
if "netmiko_send_config (failed)" in str(e): if "netmiko_send_config (failed)" in str(e):
_result = task.run( _result = task.run(task=netmiko_send_config, config_commands=config_commands)
task=netmiko_send_config, config_commands=config_commands
)
return Result(host=task.host, result=_result.result) return Result(host=task.host, result=_result.result)
# Log any other exceptions with full details # Log any other exceptions with full details
@ -364,9 +333,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
parsed_input = json.loads(tool_input) parsed_input = json.loads(tool_input)
logger.debug("Successfully parsed tool input from JSON string.") logger.debug("Successfully parsed tool input from JSON string.")
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
logger.error( logger.error("Invalid JSON string received as tool input: %s", e)
"Invalid JSON string received as tool input: %s", e
)
return ( return (
[{"status": "failed", "error": f"Invalid JSON string input from model: {e}"}], [{"status": "failed", "error": f"Invalid JSON string input from model: {e}"}],
None, None,
@ -392,10 +359,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
return ([{"status": "failed", "error": error_msg}], None) return ([{"status": "failed", "error": error_msg}], None)
if not self._validate_project_id(project_id): if not self._validate_project_id(project_id):
error_msg = ( error_msg = f"Invalid project_id format: {project_id}. Expected UUID format."
f"Invalid project_id format: {project_id}. "
"Expected UUID format."
)
logger.error(error_msg) logger.error(error_msg)
return ([{"status": "failed", "error": error_msg}], None) return ([{"status": "failed", "error": error_msg}], None)
@ -413,10 +377,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
# Handle legacy format: [...] # Handle legacy format: [...]
elif isinstance(parsed_input, list): elif isinstance(parsed_input, list):
logger.warning( logger.warning("Using legacy format without project_id. Please use new format with project_id.")
"Using legacy format without project_id. "
"Please use new format with project_id."
)
return parsed_input, None return parsed_input, None
else: else:
@ -438,9 +399,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
Returns: Returns:
True if valid UUID format, False otherwise True if valid UUID format, False otherwise
""" """
uuid_pattern = ( uuid_pattern = r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
)
return bool(re.match(uuid_pattern, project_id, re.IGNORECASE)) return bool(re.match(uuid_pattern, project_id, re.IGNORECASE))
def _filter_forbidden_commands_from_device_configs( def _filter_forbidden_commands_from_device_configs(
@ -465,9 +424,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
commands = device_config["config_commands"] commands = device_config["config_commands"]
# Filter commands # Filter commands
allowed_commands, blocked_info = filter_forbidden_commands( allowed_commands, blocked_info = filter_forbidden_commands(commands)
commands
)
# Update device config with allowed commands only # Update device config with allowed commands only
filtered_config = device_config.copy() filtered_config = device_config.copy()
@ -486,9 +443,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
return filtered_list, blocked_commands_map return filtered_list, blocked_commands_map
def _expand_multiline_commands( def _expand_multiline_commands(self, device_configs_list: list[dict[str, Any]]) -> list[dict[str, Any]]:
self, device_configs_list: list[dict[str, Any]]
) -> list[dict[str, Any]]:
""" """
Expand commands that contain embedded newline characters. Expand commands that contain embedded newline characters.
@ -540,9 +495,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
return expanded_list return expanded_list
def _configs_map( def _configs_map(self, device_config_list: list[dict[str, Any]]) -> dict[str, list[str]]:
self, device_config_list: list[dict[str, Any]]
) -> dict[str, list[str]]:
"""Create a mapping of device names to their configuration commands.""" """Create a mapping of device names to their configuration commands."""
device_configs_map = {} device_configs_map = {}
for device_config in device_config_list: for device_config in device_config_list:
@ -561,15 +514,10 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
) -> dict[str, dict[str, Any]]: ) -> dict[str, dict[str, Any]]:
"""Prepare device hosts data from topology information.""" """Prepare device hosts data from topology information."""
# Extract device names list # Extract device names list
device_names = [ device_names = [device_config["device_name"] for device_config in device_config_list]
device_config["device_name"]
for device_config in device_config_list
]
# Get device port information with project_id # Get device port information with project_id
hosts_data = get_device_ports_from_topology( hosts_data = get_device_ports_from_topology(device_names, project_id, jwt_token=jwt_token, url=url)
device_names, project_id, jwt_token=jwt_token, url=url
)
if not hosts_data: if not hosts_data:
error_msg = ( error_msg = (
@ -589,9 +537,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
return hosts_data return hosts_data
def _initialize_nornir( def _initialize_nornir(self, hosts_data: dict[str, dict[str, Any]]) -> Nornir:
self, hosts_data: dict[str, dict[str, Any]]
) -> Nornir:
""" """
Initialize Nornir with the provided hosts data. Initialize Nornir with the provided hosts data.
@ -616,8 +562,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
# Log device types being configured # Log device types being configured
device_types = [ device_types = [
host["connection_options"]["netmiko"]["extras"]["device_type"] host["connection_options"]["netmiko"]["extras"]["device_type"] for host in hosts_data.values()
for host in hosts_data.values()
] ]
logger.info( logger.info(
"Initializing Nornir: host=%s, device_types=%s, hosts=%d", "Initializing Nornir: host=%s, device_types=%s, hosts=%d",
@ -665,16 +610,11 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
device_result = { device_result = {
"device_name": device_name, "device_name": device_name,
"status": "failed", "status": "failed",
"error": ( "error": (f"Device '{device_name}' not found in topology or missing console_port"),
f"Device '{device_name}' not found in topology or "
"missing console_port"
),
} }
# Add blocked commands info if any # Add blocked commands info if any
if blocked_commands_info: if blocked_commands_info:
device_result["blocked_commands"] = list( device_result["blocked_commands"] = list(blocked_commands_info.keys())
blocked_commands_info.keys()
)
device_result["blocked_info"] = blocked_commands_info device_result["blocked_info"] = blocked_commands_info
results.append(device_result) results.append(device_result)
continue continue
@ -684,15 +624,11 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
device_result = { device_result = {
"device_name": device_name, "device_name": device_name,
"status": "failed", "status": "failed",
"error": ( "error": (f"Device '{device_name}' not found in task results"),
f"Device '{device_name}' not found in task results"
),
} }
# Add blocked commands info if any # Add blocked commands info if any
if blocked_commands_info: if blocked_commands_info:
device_result["blocked_commands"] = list( device_result["blocked_commands"] = list(blocked_commands_info.keys())
blocked_commands_info.keys()
)
device_result["blocked_info"] = blocked_commands_info device_result["blocked_info"] = blocked_commands_info
results.append(device_result) results.append(device_result)
continue continue
@ -704,9 +640,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
if multi_result[0].failed: if multi_result[0].failed:
# Execution failed # Execution failed
device_result["status"] = "failed" device_result["status"] = "failed"
device_result["error"] = ( device_result["error"] = f"Configuration execution failed: {multi_result[0].result}"
f"Configuration execution failed: {multi_result[0].result}"
)
device_result["output"] = multi_result[0].result device_result["output"] = multi_result[0].result
else: else:
# Execution successful # Execution successful
@ -716,9 +650,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
# Add blocked commands info if any # Add blocked commands info if any
if blocked_commands_info: if blocked_commands_info:
device_result["blocked_commands"] = list( device_result["blocked_commands"] = list(blocked_commands_info.keys())
blocked_commands_info.keys()
)
device_result["blocked_info"] = blocked_commands_info device_result["blocked_info"] = blocked_commands_info
# Update status if some commands were blocked but succeeded # Update status if some commands were blocked but succeeded
if device_result["status"] == "success": if device_result["status"] == "success":

View File

@ -199,18 +199,12 @@ class ExecuteMultipleDeviceCommands(BaseTool):
# Validate input # Validate input
device_configs_list, project_id = self._validate_tool_input(tool_input) device_configs_list, project_id = self._validate_tool_input(tool_input)
if ( if isinstance(device_configs_list, list) and len(device_configs_list) > 0 and "error" in device_configs_list[0]:
isinstance(device_configs_list, list)
and len(device_configs_list) > 0
and "error" in device_configs_list[0]
):
return device_configs_list return device_configs_list
# Filter forbidden commands and store blocked commands info # Filter forbidden commands and store blocked commands info
device_configs_list, blocked_commands_map = ( device_configs_list, blocked_commands_map = self._filter_forbidden_commands_from_device_configs(
self._filter_forbidden_commands_from_device_configs( device_configs_list
device_configs_list
)
) )
# Create a mapping of device names to their display commands # Create a mapping of device names to their display commands
@ -218,30 +212,17 @@ class ExecuteMultipleDeviceCommands(BaseTool):
# Prepare device hosts data # Prepare device hosts data
try: try:
hosts_data = self._prepare_device_hosts_data( hosts_data = self._prepare_device_hosts_data(device_configs_list, project_id, jwt_token=jwt_token, url=url)
device_configs_list, project_id, jwt_token=jwt_token, url=url
)
except ValueError as e: except ValueError as e:
logger.error("Failed to prepare device hosts data: %s", e) logger.error("Failed to prepare device hosts data: %s", e)
return [{"status": "failed", "error": str(e)}] return [{"status": "failed", "error": str(e)}]
# Check if any devices have errors (e.g., missing device_type tag) # Check if any devices have errors (e.g., missing device_type tag)
error_devices = { error_devices = {name: data for name, data in hosts_data.items() if "error" in data}
name: data
for name, data in hosts_data.items()
if "error" in data
}
if error_devices: if error_devices:
logger.error( logger.error("Devices with configuration errors: %s", list(error_devices.keys()))
"Devices with configuration errors: %s",
list(error_devices.keys())
)
return [ return [
{ {"device_name": name, "status": "failed", "error": data["error"]}
"device_name": name,
"status": "failed",
"error": data["error"]
}
for name, data in error_devices.items() for name, data in error_devices.items()
] ]
@ -281,17 +262,13 @@ class ExecuteMultipleDeviceCommands(BaseTool):
return results return results
def _run_all_device_configs_with_single_retry( def _run_all_device_configs_with_single_retry(self, task: Task, device_configs_map: dict[str, list[str]]) -> Result:
self, task: Task, device_configs_map: dict[str, list[str]]
) -> Result:
"""Execute READ-ONLY diagnostic commands with single retry.""" """Execute READ-ONLY diagnostic commands with single retry."""
device_name = task.host.name device_name = task.host.name
diagnostic_commands = device_configs_map.get(device_name, []) diagnostic_commands = device_configs_map.get(device_name, [])
if not diagnostic_commands: if not diagnostic_commands:
return Result( return Result(host=task.host, result="No diagnostic commands to execute")
host=task.host, result="No diagnostic commands to execute"
)
try: try:
_result = task.run( _result = task.run(
@ -366,9 +343,7 @@ class ExecuteMultipleDeviceCommands(BaseTool):
parsed_input = json.loads(tool_input) parsed_input = json.loads(tool_input)
logger.info("Successfully parsed tool input from JSON string.") logger.info("Successfully parsed tool input from JSON string.")
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
logger.error( logger.error("Invalid JSON string received as tool input: %s", e)
"Invalid JSON string received as tool input: %s", e
)
return ( return (
[{"status": "failed", "error": f"Invalid JSON string input from model: {e}"}], [{"status": "failed", "error": f"Invalid JSON string input from model: {e}"}],
None, None,
@ -412,9 +387,7 @@ class ExecuteMultipleDeviceCommands(BaseTool):
# Handle legacy format: [...] # Handle legacy format: [...]
elif isinstance(parsed_input, list): elif isinstance(parsed_input, list):
logger.warning( logger.warning("Legacy input format without project_id. Use new format.")
"Legacy input format without project_id. Use new format."
)
return parsed_input, None return parsed_input, None
else: else:
@ -435,9 +408,7 @@ class ExecuteMultipleDeviceCommands(BaseTool):
Returns: Returns:
True if valid UUID format, False otherwise True if valid UUID format, False otherwise
""" """
uuid_pattern = ( uuid_pattern = r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
)
return bool(re.match(uuid_pattern, project_id, re.IGNORECASE)) return bool(re.match(uuid_pattern, project_id, re.IGNORECASE))
def _filter_forbidden_commands_from_device_configs( def _filter_forbidden_commands_from_device_configs(
@ -462,9 +433,7 @@ class ExecuteMultipleDeviceCommands(BaseTool):
commands = device_config["commands"] commands = device_config["commands"]
# Filter commands # Filter commands
allowed_commands, blocked_info = filter_forbidden_commands( allowed_commands, blocked_info = filter_forbidden_commands(commands)
commands
)
# Update device config with allowed commands only # Update device config with allowed commands only
filtered_config = device_config.copy() filtered_config = device_config.copy()
@ -483,9 +452,7 @@ class ExecuteMultipleDeviceCommands(BaseTool):
return filtered_list, blocked_commands_map return filtered_list, blocked_commands_map
def _configs_map( def _configs_map(self, device_config_list: list[dict[str, Any]]) -> dict[str, list[str]]:
self, device_config_list: list[dict[str, Any]]
) -> dict[str, list[str]]:
"""Create a mapping of device names to their diagnostic commands.""" """Create a mapping of device names to their diagnostic commands."""
device_diagnostic_map = {} device_diagnostic_map = {}
for device_config in device_config_list: for device_config in device_config_list:
@ -504,21 +471,13 @@ class ExecuteMultipleDeviceCommands(BaseTool):
) -> dict[str, dict[str, Any]]: ) -> dict[str, dict[str, Any]]:
"""Prepare device hosts data from topology information.""" """Prepare device hosts data from topology information."""
# Extract device names list # Extract device names list
device_names = [ device_names = [device_config["device_name"] for device_config in device_config_list]
device_config["device_name"]
for device_config in device_config_list
]
# Get device port information with project_id # Get device port information with project_id
hosts_data = get_device_ports_from_topology( hosts_data = get_device_ports_from_topology(device_names, project_id, jwt_token=jwt_token, url=url)
device_names, project_id, jwt_token=jwt_token, url=url
)
if not hosts_data: if not hosts_data:
error_msg = ( error_msg = f"Failed to get device info from topology. Project: {project_id}, Devices: {device_names}"
f"Failed to get device info from topology. "
f"Project: {project_id}, Devices: {device_names}"
)
raise ValueError(error_msg) raise ValueError(error_msg)
# Check for missing devices # Check for missing devices
@ -532,9 +491,7 @@ class ExecuteMultipleDeviceCommands(BaseTool):
return hosts_data return hosts_data
def _initialize_nornir( def _initialize_nornir(self, hosts_data: dict[str, dict[str, Any]]) -> Nornir:
self, hosts_data: dict[str, dict[str, Any]]
) -> Nornir:
""" """
Initialize Nornir with the provided hosts data. Initialize Nornir with the provided hosts data.
@ -559,8 +516,7 @@ class ExecuteMultipleDeviceCommands(BaseTool):
# Log device types being configured # Log device types being configured
device_types = [ device_types = [
host["connection_options"]["netmiko"]["extras"]["device_type"] host["connection_options"]["netmiko"]["extras"]["device_type"] for host in hosts_data.values()
for host in hosts_data.values()
] ]
logger.info( logger.info(
"Initializing Nornir: host=%s, device_types=%s, hosts=%d", "Initializing Nornir: host=%s, device_types=%s, hosts=%d",
@ -608,16 +564,11 @@ class ExecuteMultipleDeviceCommands(BaseTool):
device_result = { device_result = {
"device_name": device_name, "device_name": device_name,
"status": "failed", "status": "failed",
"error": ( "error": (f"Device '{device_name}' not found in topology or missing console_port"),
f"Device '{device_name}' not found in topology "
"or missing console_port"
),
} }
# Add blocked commands info if any # Add blocked commands info if any
if blocked_commands_info: if blocked_commands_info:
device_result["blocked_commands"] = list( device_result["blocked_commands"] = list(blocked_commands_info.keys())
blocked_commands_info.keys()
)
device_result["blocked_info"] = blocked_commands_info device_result["blocked_info"] = blocked_commands_info
results.append(device_result) results.append(device_result)
continue continue
@ -627,15 +578,11 @@ class ExecuteMultipleDeviceCommands(BaseTool):
device_result = { device_result = {
"device_name": device_name, "device_name": device_name,
"status": "failed", "status": "failed",
"error": ( "error": (f"Device '{device_name}' not found in task results"),
f"Device '{device_name}' not found in task results"
),
} }
# Add blocked commands info if any # Add blocked commands info if any
if blocked_commands_info: if blocked_commands_info:
device_result["blocked_commands"] = list( device_result["blocked_commands"] = list(blocked_commands_info.keys())
blocked_commands_info.keys()
)
device_result["blocked_info"] = blocked_commands_info device_result["blocked_info"] = blocked_commands_info
results.append(device_result) results.append(device_result)
continue continue
@ -647,9 +594,7 @@ class ExecuteMultipleDeviceCommands(BaseTool):
if multi_result[0].failed: if multi_result[0].failed:
# Execution failed # Execution failed
device_result["status"] = "failed" device_result["status"] = "failed"
device_result["error"] = ( device_result["error"] = f"Diagnostic command failed: {multi_result[0].result}"
f"Diagnostic command failed: {multi_result[0].result}"
)
device_result["output"] = multi_result[0].result device_result["output"] = multi_result[0].result
else: else:
# Execution successful # Execution successful
@ -659,9 +604,7 @@ class ExecuteMultipleDeviceCommands(BaseTool):
# Add blocked commands info if any # Add blocked commands info if any
if blocked_commands_info: if blocked_commands_info:
device_result["blocked_commands"] = list( device_result["blocked_commands"] = list(blocked_commands_info.keys())
blocked_commands_info.keys()
)
device_result["blocked_info"] = blocked_commands_info device_result["blocked_info"] = blocked_commands_info
# Update status if some commands were blocked but succeeded # Update status if some commands were blocked but succeeded
if device_result["status"] == "success": if device_result["status"] == "success":

View File

@ -132,9 +132,7 @@ class GNS3LinkTool(BaseTool):
if not isinstance(links_data, list) or len(links_data) == 0: if not isinstance(links_data, list) or len(links_data) == 0:
logger.error("Invalid links data: must be a non-empty array") logger.error("Invalid links data: must be a non-empty array")
return [ return [{"error": "Invalid links data: must be a non-empty array"}]
{"error": "Invalid links data: must be a non-empty array"}
]
# Build handler context (JWT + server URL from request context) # Build handler context (JWT + server URL from request context)
logger.info("Connecting to GNS3 server...") logger.info("Connecting to GNS3 server...")
@ -142,14 +140,7 @@ class GNS3LinkTool(BaseTool):
if gns3_ctx is None: if gns3_ctx is None:
logger.error("Failed to create GNS3 connector") logger.error("Failed to create GNS3 connector")
return [ return [{"error": ("Failed to connect to GNS3 server. Please check your configuration.")}]
{
"error": (
"Failed to connect to GNS3 server. "
"Please check your configuration."
)
}
]
# Fetch all nodes once for port resolution # Fetch all nodes once for port resolution
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx) listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
@ -172,9 +163,7 @@ class GNS3LinkTool(BaseTool):
# Validate link parameters # Validate link parameters
if not all([node_id1, port1, node_id2, port2]): if not all([node_id1, port1, node_id2, port2]):
error_msg = ( error_msg = f"Missing required fields in link definition {i}"
f"Missing required fields in link definition {i}"
)
logger.error(error_msg) logger.error(error_msg)
created_links.append({"error": error_msg}) created_links.append({"error": error_msg})
continue continue
@ -218,29 +207,15 @@ class GNS3LinkTool(BaseTool):
"nodes": [ "nodes": [
{ {
"node_id": node_id1, "node_id": node_id1,
"adapter_number": port1_info.get( "adapter_number": port1_info.get("adapter_number", 0),
"adapter_number", 0 "port_number": port1_info.get("port_number", 0),
), "label": {"text": port1_info.get("short_name") or port1},
"port_number": port1_info.get(
"port_number", 0
),
"label": {
"text": port1_info.get("short_name")
or port1
},
}, },
{ {
"node_id": node_id2, "node_id": node_id2,
"adapter_number": port2_info.get( "adapter_number": port2_info.get("adapter_number", 0),
"adapter_number", 0 "port_number": port2_info.get("port_number", 0),
), "label": {"text": port2_info.get("short_name") or port2},
"port_number": port2_info.get(
"port_number", 0
),
"label": {
"text": port2_info.get("short_name")
or port2
},
}, },
], ],
"fields": ["link_id"], "fields": ["link_id"],
@ -266,9 +241,7 @@ class GNS3LinkTool(BaseTool):
created_links.append({"error": error_msg}) created_links.append({"error": error_msg})
# Log final results # Log final results
success_count = len( success_count = len([link for link in created_links if "error" not in link])
[link for link in created_links if "error" not in link]
)
logger.info( logger.info(
"Link creation completed: %d successful, %d failed", "Link creation completed: %d successful, %d failed",
success_count, success_count,

View File

@ -169,9 +169,7 @@ class GNS3CreateNodeTool(BaseTool):
# Validate each node in the array # Validate each node in the array
for i, node_data in enumerate(nodes): for i, node_data in enumerate(nodes):
if not isinstance(node_data, dict): if not isinstance(node_data, dict):
logger.error( logger.error("Invalid input: Node %d must be a dictionary.", i + 1)
"Invalid input: Node %d must be a dictionary.", i + 1
)
return {"error": f"Node {i + 1} must be a dictionary."} return {"error": f"Node {i + 1} must be a dictionary."}
template_id = node_data.get("template_id") template_id = node_data.get("template_id")
@ -187,14 +185,10 @@ class GNS3CreateNodeTool(BaseTool):
] ]
): ):
logger.error( logger.error(
"Invalid input: Node %d missing or invalid " "Invalid input: Node %d missing or invalid template_id, x, or y.",
"template_id, x, or y.",
i + 1, i + 1,
) )
return { return {"error": f"Node {i + 1} missing or invalid template_id, x, or y."}
"error": f"Node {i + 1} missing or invalid "
f"template_id, x, or y."
}
# Build handler context (JWT + server URL from request context) # Build handler context (JWT + server URL from request context)
logger.info("Connecting to GNS3 server...") logger.info("Connecting to GNS3 server...")
@ -202,15 +196,10 @@ class GNS3CreateNodeTool(BaseTool):
if gns3_ctx is None: if gns3_ctx is None:
logger.error("Failed to create GNS3 connector") logger.error("Failed to create GNS3 connector")
return { return {"error": "Failed to connect to GNS3 server. Please check your configuration."}
"error": "Failed to connect to GNS3 server. "
"Please check your configuration."
}
# Create nodes # Create nodes
logger.info( logger.info("Creating %d nodes in project %s...", len(nodes), project_id)
"Creating %d nodes in project %s...", len(nodes), project_id
)
results: list[dict[str, Any]] = [] results: list[dict[str, Any]] = []
for i, node_data in enumerate(nodes): for i, node_data in enumerate(nodes):
@ -261,12 +250,8 @@ class GNS3CreateNodeTool(BaseTool):
# Continue with next node even if one fails # Continue with next node even if one fails
# Calculate summary statistics # Calculate summary statistics
successful_nodes = len( successful_nodes = len([r for r in results if r.get("status") == "success"])
[r for r in results if r.get("status") == "success"] failed_nodes = len([r for r in results if r.get("status") == "failed"])
)
failed_nodes = len(
[r for r in results if r.get("status") == "failed"]
)
# Prepare final result # Prepare final result
final_result = { final_result = {
@ -293,9 +278,7 @@ class GNS3CreateNodeTool(BaseTool):
return {"error": f"Invalid JSON input: {e}"} return {"error": f"Invalid JSON input: {e}"}
except Exception as e: except Exception as e:
logger.error("Failed to process node creation request: %s", e) logger.error("Failed to process node creation request: %s", e)
return { return {"error": f"Failed to process node creation request: {str(e)}"}
"error": f"Failed to process node creation request: {str(e)}"
}
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -46,12 +46,12 @@ logger = logging.getLogger(__name__)
# Built-in templates to filter out (utility templates, not actual network devices) # Built-in templates to filter out (utility templates, not actual network devices)
FILTERED_TEMPLATES = { FILTERED_TEMPLATES = {
"atm_switch", # ATM switch "atm_switch", # ATM switch
"cloud", # Cloud "cloud", # Cloud
"ethernet_hub", # Ethernet hub "ethernet_hub", # Ethernet hub
"ethernet_switch", # Ethernet switch (built-in) "ethernet_switch", # Ethernet switch (built-in)
"frame_relay_switch", # Frame Relay switch "frame_relay_switch", # Frame Relay switch
"nat", # NAT device "nat", # NAT device
} }
@ -131,17 +131,10 @@ class GNS3TemplateTool(BaseTool):
if gns3_server is None: if gns3_server is None:
logger.error("Failed to create GNS3 connector") logger.error("Failed to create GNS3 connector")
return { return {"error": ("Failed to connect to GNS3 server. Please check your configuration.")}
"error": (
"Failed to connect to GNS3 server. "
"Please check your configuration."
)
}
# Retrieve all available templates # Retrieve all available templates
templates = gns3_server.http_call( templates = gns3_server.http_call("get", f"{gns3_server.base_url}/templates").json()
"get", f"{gns3_server.base_url}/templates"
).json()
# Filter out utility templates and extract relevant info # Filter out utility templates and extract relevant info
template_info = [] template_info = []
@ -154,11 +147,13 @@ class GNS3TemplateTool(BaseTool):
continue continue
# Extract name, template_id, and template_type # Extract name, template_id, and template_type
template_info.append({ template_info.append(
"name": template.get("name", "N/A"), {
"template_id": template.get("template_id", "N/A"), "name": template.get("name", "N/A"),
"template_type": template.get("template_type", "N/A"), "template_id": template.get("template_id", "N/A"),
}) "template_type": template.get("template_type", "N/A"),
}
)
# Return JSON-formatted result with full logging # Return JSON-formatted result with full logging
result = {"templates": template_info} result = {"templates": template_info}
@ -172,9 +167,7 @@ class GNS3TemplateTool(BaseTool):
return result return result
except Exception as e: except Exception as e:
logger.error( logger.error("Failed to connect to GNS3 server or retrieve templates: %s", e)
"Failed to connect to GNS3 server or retrieve templates: %s", e
)
return {"error": f"Failed to retrieve templates: {str(e)}"} return {"error": f"Failed to retrieve templates: {str(e)}"}

View File

@ -198,18 +198,14 @@ class GNS3PacketFilterTool(BaseTool):
valid_actions = ["get_available", "set", "get", "clear"] valid_actions = ["get_available", "set", "get", "clear"]
if action not in valid_actions: if action not in valid_actions:
logger.error("Invalid action: %s. Must be one of %s", action, valid_actions) logger.error("Invalid action: %s. Must be one of %s", action, valid_actions)
return { return {"error": f"Invalid action: {action}. Must be one of {valid_actions}"}
"error": f"Invalid action: {action}. Must be one of {valid_actions}"
}
# Validate filters for "set" action # Validate filters for "set" action
if action == "set": if action == "set":
filters = input_data.get("filters") filters = input_data.get("filters")
if not filters or not isinstance(filters, dict): if not filters or not isinstance(filters, dict):
logger.error("Invalid input: 'set' action requires 'filters' dict.") logger.error("Invalid input: 'set' action requires 'filters' dict.")
return { return {"error": "'set' action requires 'filters' dict with filter configuration."}
"error": "'set' action requires 'filters' dict with filter configuration."
}
# Build handler context (JWT + server URL from request context) # Build handler context (JWT + server URL from request context)
logger.info("Connecting to GNS3 server...") logger.info("Connecting to GNS3 server...")
@ -217,28 +213,19 @@ class GNS3PacketFilterTool(BaseTool):
if gns3_ctx is None: if gns3_ctx is None:
logger.error("Failed to create GNS3 connector") logger.error("Failed to create GNS3 connector")
return { return {"error": "Failed to connect to GNS3 server. Please check your configuration."}
"error": "Failed to connect to GNS3 server. "
"Please check your configuration."
}
# Execute action # Execute action
logger.info( logger.info("Processing packet filter action '%s' for link %s...", action, link_id)
"Processing packet filter action '%s' for link %s...", action, link_id
)
if action == "get_available": if action == "get_available":
result = self._get_available_filters(gns3_ctx, project_id, link_id) result = self._get_available_filters(gns3_ctx, project_id, link_id)
elif action == "set": elif action == "set":
filters = input_data.get("filters", {}) filters = input_data.get("filters", {})
result = self._set_filters( result = self._set_filters(gns3_ctx, project_id, link_id, filters, show_filters_icon)
gns3_ctx, project_id, link_id, filters, show_filters_icon
)
elif action == "get": elif action == "get":
result = self._get_filters(gns3_ctx, project_id, link_id) result = self._get_filters(gns3_ctx, project_id, link_id)
elif action == "clear": elif action == "clear":
result = self._clear_filters( result = self._clear_filters(gns3_ctx, project_id, link_id, show_filters_icon)
gns3_ctx, project_id, link_id, show_filters_icon
)
else: else:
result = {"error": f"Unknown action: {action}"} result = {"error": f"Unknown action: {action}"}
@ -252,18 +239,12 @@ class GNS3PacketFilterTool(BaseTool):
return {"error": f"Invalid JSON input: {e}"} return {"error": f"Invalid JSON input: {e}"}
except Exception as e: except Exception as e:
logger.error("Failed to process packet filter request: %s", e) logger.error("Failed to process packet filter request: %s", e)
return { return {"error": f"Failed to process packet filter request: {str(e)}"}
"error": f"Failed to process packet filter request: {str(e)}"
}
def _get_available_filters( def _get_available_filters(self, gns3_ctx: dict, project_id: str, link_id: str) -> dict[str, Any]:
self, gns3_ctx: dict, project_id: str, link_id: str
) -> dict[str, Any]:
"""Get available filter types for the link.""" """Get available filter types for the link."""
try: try:
filters = available_filters_handler( filters = available_filters_handler({"project_id": project_id, "link_id": link_id}, gns3_ctx)
{"project_id": project_id, "link_id": link_id}, gns3_ctx
)
if "error" in filters: if "error" in filters:
raise RuntimeError(filters["error"]) raise RuntimeError(filters["error"])
logger.info("Retrieved %d available filter types.", len(filters)) logger.info("Retrieved %d available filter types.", len(filters))
@ -307,13 +288,9 @@ class GNS3PacketFilterTool(BaseTool):
if "Invalid" in result.stdout or "Invalid" in result.stderr: if "Invalid" in result.stdout or "Invalid" in result.stderr:
error_lines = [] error_lines = []
if "Invalid" in result.stderr: if "Invalid" in result.stderr:
error_lines.extend( error_lines.extend(line for line in result.stderr.split("\n") if "Invalid" in line)
line for line in result.stderr.split("\n") if "Invalid" in line
)
if "Invalid" in result.stdout: if "Invalid" in result.stdout:
error_lines.extend( error_lines.extend(line for line in result.stdout.split("\n") if "Invalid" in line)
line for line in result.stdout.split("\n") if "Invalid" in line
)
# Strip interface suffix (e.g., "for interface 'lo'") for cleaner error # Strip interface suffix (e.g., "for interface 'lo'") for cleaner error
error_msg_parts = [] error_msg_parts = []
@ -336,10 +313,7 @@ class GNS3PacketFilterTool(BaseTool):
except FileNotFoundError: except FileNotFoundError:
# tshark not installed - skip validation # tshark not installed - skip validation
logger.warning( logger.warning("tshark not found, skipping BPF syntax validation. Install tshark to enable BPF validation.")
"tshark not found, skipping BPF syntax validation. "
"Install tshark to enable BPF validation."
)
return {"valid": True, "error": None} return {"valid": True, "error": None}
except Exception as e: except Exception as e:
@ -414,14 +388,10 @@ class GNS3PacketFilterTool(BaseTool):
"status": "failed", "status": "failed",
} }
def _get_filters( def _get_filters(self, gns3_ctx: dict, project_id: str, link_id: str) -> dict[str, Any]:
self, gns3_ctx: dict, project_id: str, link_id: str
) -> dict[str, Any]:
"""Get current filters configured on the link.""" """Get current filters configured on the link."""
try: try:
link = get_link_handler( link = get_link_handler({"project_id": project_id, "link_id": link_id}, gns3_ctx)
{"project_id": project_id, "link_id": link_id}, gns3_ctx
)
if "error" in link: if "error" in link:
raise RuntimeError(link["error"]) raise RuntimeError(link["error"])

View File

@ -106,13 +106,8 @@ class GNS3StartNodeTool(BaseTool):
# Validate input # Validate input
if not project_id or not node_ids: if not project_id or not node_ids:
logger.error( logger.error("Missing required fields: project_id or node_ids.")
"Missing required fields: project_id or node_ids." return {"error": "Missing required fields: project_id and node_ids."}
)
return {
"error": "Missing required fields: "
"project_id and node_ids."
}
if not isinstance(node_ids, list): if not isinstance(node_ids, list):
logger.error("node_ids must be a list.") logger.error("node_ids must be a list.")
@ -124,10 +119,7 @@ class GNS3StartNodeTool(BaseTool):
if gns3_ctx is None: if gns3_ctx is None:
logger.error("Failed to create GNS3 connector") logger.error("Failed to create GNS3 connector")
return { return {"error": "Failed to connect to GNS3 server. Please check your configuration."}
"error": "Failed to connect to GNS3 server. "
"Please check your configuration."
}
# Verify nodes exist and capture pre-start info (one call) # Verify nodes exist and capture pre-start info (one call)
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx) listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
@ -143,14 +135,8 @@ class GNS3StartNodeTool(BaseTool):
) )
results = [] results = []
known_ids = [nid for nid in node_ids if nid in nodes_by_id] known_ids = [nid for nid in node_ids if nid in nodes_by_id]
start_results = start_node_handler( start_results = start_node_handler({"project_id": project_id, "node_ids": known_ids}, gns3_ctx)
{"project_id": project_id, "node_ids": known_ids}, gns3_ctx start_errors = {r["node_id"]: r.get("error") for r in start_results if r.get("status") == "error"}
)
start_errors = {
r["node_id"]: r.get("error")
for r in start_results
if r.get("status") == "error"
}
# Get immediate status (likely 'starting' or 'stopped') — one call # Get immediate status (likely 'starting' or 'stopped') — one call
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx) listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
@ -160,9 +146,7 @@ class GNS3StartNodeTool(BaseTool):
for node_id in node_ids: for node_id in node_ids:
if node_id not in nodes_by_id: if node_id not in nodes_by_id:
logger.error( logger.error("Node %s not found in project %s", node_id, project_id)
"Node %s not found in project %s", node_id, project_id
)
results.append( results.append(
{ {
"node_id": node_id, "node_id": node_id,
@ -204,9 +188,7 @@ class GNS3StartNodeTool(BaseTool):
) )
# Analyze results (count based on successful command sending) # Analyze results (count based on successful command sending)
successful_nodes = [ successful_nodes = [r for r in results if r.get("status") != "error"]
r for r in results if r.get("status") != "error"
]
failed_nodes = [r for r in results if r.get("status") == "error"] failed_nodes = [r for r in results if r.get("status") == "error"]
# Construct final response # Construct final response
@ -217,8 +199,7 @@ class GNS3StartNodeTool(BaseTool):
"failed": len(failed_nodes), "failed": len(failed_nodes),
"nodes": results, "nodes": results,
"note": ( "note": (
"Start commands sent. Nodes are booting in background. " "Start commands sent. Nodes are booting in background. Use wait_seconds, then check node status."
"Use wait_seconds, then check node status."
), ),
} }
@ -249,9 +230,7 @@ if __name__ == "__main__":
test_input_single = json.dumps( test_input_single = json.dumps(
{ {
"project_id": "<PROJECT_UUID>", # Replace with actual project UUID "project_id": "<PROJECT_UUID>", # Replace with actual project UUID
"node_ids": [ "node_ids": ["fbeda109-9a74-4d8c-a749-cc3847911a90"], # Replace with actual node UUID
"fbeda109-9a74-4d8c-a749-cc3847911a90"
], # Replace with actual node UUID
} }
) )
tool = GNS3StartNodeTool() tool = GNS3StartNodeTool()

View File

@ -94,13 +94,8 @@ class GNS3StopNodeTool(BaseTool):
# Validate input # Validate input
if not project_id or not node_ids: if not project_id or not node_ids:
logger.error( logger.error("Missing required fields: project_id or node_ids.")
"Missing required fields: project_id or node_ids." return {"error": "Missing required fields: project_id and node_ids."}
)
return {
"error": "Missing required fields: "
"project_id and node_ids."
}
if not isinstance(node_ids, list): if not isinstance(node_ids, list):
logger.error("node_ids must be a list.") logger.error("node_ids must be a list.")
@ -112,10 +107,7 @@ class GNS3StopNodeTool(BaseTool):
if gns3_ctx is None: if gns3_ctx is None:
logger.error("Failed to create GNS3 connector") logger.error("Failed to create GNS3 connector")
return { return {"error": "Failed to connect to GNS3 server. Please check your configuration."}
"error": "Failed to connect to GNS3 server. "
"Please check your configuration."
}
# Verify nodes exist and capture names (one call) # Verify nodes exist and capture names (one call)
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx) listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
@ -131,14 +123,8 @@ class GNS3StopNodeTool(BaseTool):
) )
results = [] results = []
known_ids = [nid for nid in node_ids if nid in nodes_by_id] known_ids = [nid for nid in node_ids if nid in nodes_by_id]
stop_results = stop_node_handler( stop_results = stop_node_handler({"project_id": project_id, "node_ids": known_ids}, gns3_ctx)
{"project_id": project_id, "node_ids": known_ids}, gns3_ctx stop_errors = {r["node_id"]: r.get("error") for r in stop_results if r.get("status") == "error"}
)
stop_errors = {
r["node_id"]: r.get("error")
for r in stop_results
if r.get("status") == "error"
}
# Get updated status — one call # Get updated status — one call
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx) listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
@ -148,9 +134,7 @@ class GNS3StopNodeTool(BaseTool):
for node_id in node_ids: for node_id in node_ids:
if node_id not in nodes_by_id: if node_id not in nodes_by_id:
logger.error( logger.error("Node %s not found in project %s", node_id, project_id)
"Node %s not found in project %s", node_id, project_id
)
results.append( results.append(
{ {
"node_id": node_id, "node_id": node_id,
@ -160,9 +144,7 @@ class GNS3StopNodeTool(BaseTool):
} }
) )
elif node_id in stop_errors: elif node_id in stop_errors:
logger.error( logger.error("Failed to stop node %s: %s", node_id, stop_errors[node_id])
"Failed to stop node %s: %s", node_id, stop_errors[node_id]
)
results.append( results.append(
{ {
"node_id": node_id, "node_id": node_id,
@ -187,9 +169,7 @@ class GNS3StopNodeTool(BaseTool):
) )
# Analyze results # Analyze results
successful_nodes = [ successful_nodes = [r for r in results if r.get("status") != "error"]
r for r in results if r.get("status") != "error"
]
failed_nodes = [r for r in results if r.get("status") == "error"] failed_nodes = [r for r in results if r.get("status") == "error"]
# Construct final response # Construct final response
@ -223,9 +203,7 @@ if __name__ == "__main__":
test_input_single = json.dumps( test_input_single = json.dumps(
{ {
"project_id": "<PROJECT_UUID>", # Replace with actual project UUID "project_id": "<PROJECT_UUID>", # Replace with actual project UUID
"node_ids": [ "node_ids": ["fbeda109-9a74-4d8c-a749-cc3847911a90"], # Replace with actual node UUID
"fbeda109-9a74-4d8c-a749-cc3847911a90"
], # Replace with actual node UUID
} }
) )
tool = GNS3StopNodeTool() tool = GNS3StopNodeTool()

View File

@ -100,13 +100,8 @@ class GNS3SuspendNodeTool(BaseTool):
# Validate input # Validate input
if not project_id or not node_ids: if not project_id or not node_ids:
logger.error( logger.error("Missing required fields: project_id or node_ids.")
"Missing required fields: project_id or node_ids." return {"error": "Missing required fields: project_id and node_ids."}
)
return {
"error": "Missing required fields: "
"project_id and node_ids."
}
if not isinstance(node_ids, list): if not isinstance(node_ids, list):
logger.error("node_ids must be a list.") logger.error("node_ids must be a list.")
@ -118,10 +113,7 @@ class GNS3SuspendNodeTool(BaseTool):
if gns3_ctx is None: if gns3_ctx is None:
logger.error("Failed to create GNS3 connector") logger.error("Failed to create GNS3 connector")
return { return {"error": "Failed to connect to GNS3 server. Please check your configuration."}
"error": "Failed to connect to GNS3 server. "
"Please check your configuration."
}
# Verify nodes exist and capture names (one call) # Verify nodes exist and capture names (one call)
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx) listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
@ -137,14 +129,8 @@ class GNS3SuspendNodeTool(BaseTool):
) )
results = [] results = []
known_ids = [nid for nid in node_ids if nid in nodes_by_id] known_ids = [nid for nid in node_ids if nid in nodes_by_id]
suspend_results = suspend_node_handler( suspend_results = suspend_node_handler({"project_id": project_id, "node_ids": known_ids}, gns3_ctx)
{"project_id": project_id, "node_ids": known_ids}, gns3_ctx suspend_errors = {r["node_id"]: r.get("error") for r in suspend_results if r.get("status") == "error"}
)
suspend_errors = {
r["node_id"]: r.get("error")
for r in suspend_results
if r.get("status") == "error"
}
# Get updated status — one call # Get updated status — one call
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx) listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
@ -154,9 +140,7 @@ class GNS3SuspendNodeTool(BaseTool):
for node_id in node_ids: for node_id in node_ids:
if node_id not in nodes_by_id: if node_id not in nodes_by_id:
logger.error( logger.error("Node %s not found in project %s", node_id, project_id)
"Node %s not found in project %s", node_id, project_id
)
results.append( results.append(
{ {
"node_id": node_id, "node_id": node_id,
@ -195,9 +179,7 @@ class GNS3SuspendNodeTool(BaseTool):
) )
# Analyze results # Analyze results
successful_nodes = [ successful_nodes = [r for r in results if r.get("status") != "error"]
r for r in results if r.get("status") != "error"
]
failed_nodes = [r for r in results if r.get("status") == "error"] failed_nodes = [r for r in results if r.get("status") == "error"]
# Construct final response # Construct final response
@ -207,10 +189,7 @@ class GNS3SuspendNodeTool(BaseTool):
"successful": len(successful_nodes), "successful": len(successful_nodes),
"failed": len(failed_nodes), "failed": len(failed_nodes),
"nodes": results, "nodes": results,
"note": ( "note": ("Suspended nodes preserve their state in memory. Use resume to continue where you left off."),
"Suspended nodes preserve their state in memory. "
"Use resume to continue where you left off."
),
} }
logger.info( logger.info(
@ -235,9 +214,7 @@ if __name__ == "__main__":
test_input_single = json.dumps( test_input_single = json.dumps(
{ {
"project_id": "<PROJECT_UUID>", # Replace with actual project UUID "project_id": "<PROJECT_UUID>", # Replace with actual project UUID
"node_ids": [ "node_ids": ["fbeda109-9a74-4d8c-a749-cc3847911a90"], # Replace with actual node UUID
"fbeda109-9a74-4d8c-a749-cc3847911a90"
], # Replace with actual node UUID
} }
) )
tool = GNS3SuspendNodeTool() tool = GNS3SuspendNodeTool()

View File

@ -125,9 +125,7 @@ class GNS3UpdateNodeNameTool(BaseTool):
return {"error": f"Node {i + 1} must be a dictionary."} return {"error": f"Node {i + 1} must be a dictionary."}
if "node_id" not in node_data or "new_name" not in node_data: if "node_id" not in node_data or "new_name" not in node_data:
logger.error("Node %d missing node_id or new_name.", i + 1) logger.error("Node %d missing node_id or new_name.", i + 1)
return { return {"error": f"Node {i + 1} missing node_id or new_name."}
"error": f"Node {i + 1} missing node_id or new_name."
}
# Build handler context (JWT + server URL from request context) # Build handler context (JWT + server URL from request context)
logger.info("Connecting to GNS3 server...") logger.info("Connecting to GNS3 server...")
@ -135,10 +133,7 @@ class GNS3UpdateNodeNameTool(BaseTool):
if gns3_ctx is None: if gns3_ctx is None:
logger.error("Failed to create GNS3 connector") logger.error("Failed to create GNS3 connector")
return { return {"error": "Failed to connect to GNS3 server. Please check your configuration."}
"error": "Failed to connect to GNS3 server. "
"Please check your configuration."
}
# Fetch current node names in one call (old names + existence) # Fetch current node names in one call (old names + existence)
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx) listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
@ -208,9 +203,7 @@ class GNS3UpdateNodeNameTool(BaseTool):
"error": "Name verification failed", "error": "Name verification failed",
} }
results.append(error_info) results.append(error_info)
logger.error( logger.error("Failed to update node name for %s", node_id)
"Failed to update node name for %s", node_id
)
except Exception as e: except Exception as e:
error_info = { error_info = {
@ -223,9 +216,7 @@ class GNS3UpdateNodeNameTool(BaseTool):
logger.error("Failed to update node %d: %s", i + 1, e) logger.error("Failed to update node %d: %s", i + 1, e)
# Analyze results # Analyze results
successful_nodes = [ successful_nodes = [r for r in results if r.get("status") == "success"]
r for r in results if r.get("status") == "success"
]
failed_nodes = [r for r in results if r.get("status") == "failed"] failed_nodes = [r for r in results if r.get("status") == "failed"]
# Construct final response # Construct final response

View File

@ -88,9 +88,7 @@ class GNS3WaitTool(BaseTool):
if not isinstance(seconds, int) or isinstance(seconds, bool): if not isinstance(seconds, int) or isinstance(seconds, bool):
return {"error": "seconds must be an integer (1-600)."} return {"error": "seconds must be an integer (1-600)."}
if not 1 <= seconds <= MAX_WAIT_SECONDS: if not 1 <= seconds <= MAX_WAIT_SECONDS:
return { return {"error": f"seconds must be between 1 and {MAX_WAIT_SECONDS}."}
"error": f"seconds must be between 1 and {MAX_WAIT_SECONDS}."
}
logger.info("Waiting %d seconds...", seconds) logger.info("Waiting %d seconds...", seconds)
waited = 0 waited = 0

View File

@ -123,19 +123,25 @@ class PacketAnalysisTool(BaseTool):
query = query.strip() query = query.strip()
if len(query.split()) > 1: if len(query.split()) > 1:
return json.dumps({ return json.dumps(
"error": "Only one keyword allowed", {
"hint": 'Use a single keyword like "ospf.lsa" or "bgp", not multiple words', "error": "Only one keyword allowed",
}) "hint": 'Use a single keyword like "ospf.lsa" or "bgp", not multiple words',
}
)
tshark = subprocess.Popen( tshark = subprocess.Popen(
["tshark", "-G", "fields"], ["tshark", "-G", "fields"],
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
) )
grep = subprocess.Popen( grep = subprocess.Popen(
["grep", "-iF", query, "-"], ["grep", "-iF", query, "-"],
stdin=tshark.stdout, stdout=subprocess.PIPE, stdin=tshark.stdout,
stderr=subprocess.DEVNULL, text=True, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
) )
tshark.stdout.close() tshark.stdout.close()
stdout, _ = grep.communicate(timeout=30) stdout, _ = grep.communicate(timeout=30)
@ -151,21 +157,27 @@ class PacketAnalysisTool(BaseTool):
name = parts[2] name = parts[2]
if name not in seen: if name not in seen:
seen.add(name) seen.add(name)
results.append({ results.append(
"field": name, {
"type": parts[3] if len(parts) > 3 else "", "field": name,
"protocol": parts[4] if len(parts) > 4 else "", "type": parts[3] if len(parts) > 3 else "",
"description": parts[7] if len(parts) > 7 else "", "protocol": parts[4] if len(parts) > 4 else "",
}) "description": parts[7] if len(parts) > 7 else "",
}
)
if len(results) >= 40: if len(results) >= 40:
break break
return json.dumps({ return json.dumps(
"query": query, {
"count": len(results), "query": query,
"fields": results, "count": len(results),
"hint": "Use the 'field' values in tshark_args with -e", "fields": results,
}, ensure_ascii=False, indent=2) "hint": "Use the 'field' values in tshark_args with -e",
},
ensure_ascii=False,
indent=2,
)
@classmethod @classmethod
def _load_valid_tshark_fields(cls) -> set: def _load_valid_tshark_fields(cls) -> set:
@ -235,11 +247,13 @@ class PacketAnalysisTool(BaseTool):
i += 1 i += 1
if invalid_fields: if invalid_fields:
return json.dumps({ return json.dumps(
"error": f"Invalid tshark field names: {', '.join(invalid_fields)}", {
"hint": 'Use {"action": "search_fields", "query": "<protocol>"} to look up valid field names', "error": f"Invalid tshark field names: {', '.join(invalid_fields)}",
"invalid_fields": invalid_fields, "hint": 'Use {"action": "search_fields", "query": "<protocol>"} to look up valid field names',
}) "invalid_fields": invalid_fields,
}
)
return None return None
@ -271,8 +285,7 @@ class PacketAnalysisTool(BaseTool):
return self._search_fields(query or "") return self._search_fields(query or "")
logger.info( logger.info(
f"PacketAnalysisTool invoked: project_id={project_id}, " f"PacketAnalysisTool invoked: project_id={project_id}, link_id={link_id}, tshark_args={tshark_args}"
f"link_id={link_id}, tshark_args={tshark_args}"
) )
# Validate inputs # Validate inputs
@ -429,6 +442,7 @@ class PacketAnalysisTool(BaseTool):
""" """
# Build command: tshark -r <file> <user_args> # Build command: tshark -r <file> <user_args>
import shlex import shlex
cmd = ["tshark", "-r", pcap_file] + shlex.split(tshark_args) cmd = ["tshark", "-r", pcap_file] + shlex.split(tshark_args)
logger.info(f"Running tshark: {' '.join(cmd)}") logger.info(f"Running tshark: {' '.join(cmd)}")
@ -451,10 +465,7 @@ class PacketAnalysisTool(BaseTool):
f"tshark reported: {stderr_clean}", f"tshark reported: {stderr_clean}",
] ]
if "is not a valid protocol" in stderr.lower() or "aren't valid" in stderr.lower(): if "is not a valid protocol" in stderr.lower() or "aren't valid" in stderr.lower():
hints.append( hints.append('Use {"action": "search_fields", "query": "<protocol>"} to find correct field names.')
'Use {"action": "search_fields", "query": "<protocol>"} '
"to find correct field names."
)
logger.warning(f"tshark stderr: {stderr}") logger.warning(f"tshark stderr: {stderr}")
return json.dumps({"error": "tshark argument error", "hints": hints}) return json.dumps({"error": "tshark argument error", "hints": hints})

View File

@ -179,11 +179,7 @@ class VPCSCommands(BaseTool):
# Validate input # Validate input
device_configs_list, project_id = self._validate_tool_input(tool_input) device_configs_list, project_id = self._validate_tool_input(tool_input)
if ( if isinstance(device_configs_list, list) and len(device_configs_list) > 0 and "error" in device_configs_list[0]:
isinstance(device_configs_list, list)
and len(device_configs_list) > 0
and "error" in device_configs_list[0]
):
return device_configs_list return device_configs_list
# Create a mapping of device names to their commands # Create a mapping of device names to their commands
@ -191,30 +187,17 @@ class VPCSCommands(BaseTool):
# Prepare device hosts data # Prepare device hosts data
try: try:
hosts_data = self._prepare_device_hosts_data( hosts_data = self._prepare_device_hosts_data(device_configs_list, project_id, jwt_token=jwt_token, url=url)
device_configs_list, project_id, jwt_token=jwt_token, url=url
)
except ValueError as e: except ValueError as e:
logger.error("Failed to prepare device hosts data: %s", e) logger.error("Failed to prepare device hosts data: %s", e)
return [{"status": "failed", "error": str(e)}] return [{"status": "failed", "error": str(e)}]
# Check if any devices have errors (e.g., missing device) # Check if any devices have errors (e.g., missing device)
error_devices = { error_devices = {name: data for name, data in hosts_data.items() if "error" in data}
name: data
for name, data in hosts_data.items()
if "error" in data
}
if error_devices: if error_devices:
logger.error( logger.error("Devices with configuration errors: %s", list(error_devices.keys()))
"Devices with configuration errors: %s",
list(error_devices.keys())
)
return [ return [
{ {"device_name": name, "status": "failed", "error": data["error"]}
"device_name": name,
"status": "failed",
"error": data["error"]
}
for name, data in error_devices.items() for name, data in error_devices.items()
] ]
@ -253,17 +236,13 @@ class VPCSCommands(BaseTool):
return results return results
def _run_vpcs_commands( def _run_vpcs_commands(self, task: Task, device_configs_map: dict[str, list[str]]) -> Result:
self, task: Task, device_configs_map: dict[str, list[str]]
) -> Result:
"""Execute VPCS commands with single retry.""" """Execute VPCS commands with single retry."""
device_name = task.host.name device_name = task.host.name
commands = device_configs_map.get(device_name, []) commands = device_configs_map.get(device_name, [])
if not commands: if not commands:
return Result( return Result(host=task.host, result="No commands to execute")
host=task.host, result="No commands to execute"
)
try: try:
# Use netmiko_multiline for VPCS commands # Use netmiko_multiline for VPCS commands
@ -334,9 +313,7 @@ class VPCSCommands(BaseTool):
parsed_input = json.loads(tool_input) parsed_input = json.loads(tool_input)
logger.info("Successfully parsed tool input from JSON string.") logger.info("Successfully parsed tool input from JSON string.")
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
logger.error( logger.error("Invalid JSON string received as tool input: %s", e)
"Invalid JSON string received as tool input: %s", e
)
return ( return (
[{"status": "failed", "error": f"Invalid JSON string input from model: {e}"}], [{"status": "failed", "error": f"Invalid JSON string input from model: {e}"}],
None, None,
@ -377,10 +354,7 @@ class VPCSCommands(BaseTool):
return device_configs, project_id return device_configs, project_id
else: else:
error_msg = ( error_msg = f"Tool input must be JSON with project_id and device_configs, got {type(parsed_input).__name__}"
"Tool input must be JSON with project_id and device_configs, "
f"got {type(parsed_input).__name__}"
)
logger.error(error_msg) logger.error(error_msg)
return ([{"status": "failed", "error": error_msg}], None) return ([{"status": "failed", "error": error_msg}], None)
@ -394,14 +368,10 @@ class VPCSCommands(BaseTool):
Returns: Returns:
True if valid UUID format, False otherwise True if valid UUID format, False otherwise
""" """
uuid_pattern = ( uuid_pattern = r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
)
return bool(re.match(uuid_pattern, project_id, re.IGNORECASE)) return bool(re.match(uuid_pattern, project_id, re.IGNORECASE))
def _configs_map( def _configs_map(self, device_config_list: list[dict[str, Any]]) -> dict[str, list[str]]:
self, device_config_list: list[dict[str, Any]]
) -> dict[str, list[str]]:
""" """
Create a mapping of device names to their command lists. Create a mapping of device names to their command lists.
@ -411,10 +381,7 @@ class VPCSCommands(BaseTool):
Returns: Returns:
Dictionary mapping device names to command lists Dictionary mapping device names to command lists
""" """
return { return {config["device_name"]: config["commands"] for config in device_config_list}
config["device_name"]: config["commands"]
for config in device_config_list
}
def _prepare_device_hosts_data( def _prepare_device_hosts_data(
self, self,
@ -445,18 +412,14 @@ class VPCSCommands(BaseTool):
device_names = [config["device_name"] for config in device_configs_list] device_names = [config["device_name"] for config in device_configs_list]
# Get device port mappings from topology # Get device port mappings from topology
device_ports = get_device_ports_from_topology( device_ports = get_device_ports_from_topology(device_names, project_id=project_id, jwt_token=jwt_token, url=url)
device_names, project_id=project_id, jwt_token=jwt_token, url=url
)
# Build Nornir inventory hosts data # Build Nornir inventory hosts data
hosts_data = {} hosts_data = {}
for device_name in device_names: for device_name in device_names:
if device_name not in device_ports: if device_name not in device_ports:
logger.error("Device '%s' not found in topology", device_name) logger.error("Device '%s' not found in topology", device_name)
hosts_data[device_name] = { hosts_data[device_name] = {"error": f"Device '{device_name}' not found in topology"}
"error": f"Device '{device_name}' not found in topology"
}
continue continue
port = device_ports[device_name]["port"] port = device_ports[device_name]["port"]
@ -498,9 +461,7 @@ class VPCSCommands(BaseTool):
return hosts_data return hosts_data
def _initialize_nornir( def _initialize_nornir(self, hosts_data: dict[str, dict[str, Any]]) -> "Nornir":
self, hosts_data: dict[str, dict[str, Any]]
) -> "Nornir":
""" """
Initialize Nornir with VPCS device inventory. Initialize Nornir with VPCS device inventory.
@ -576,12 +537,14 @@ class VPCSCommands(BaseTool):
# Check if device had an error during preparation # Check if device had an error during preparation
if device_name in hosts_data and "error" in hosts_data[device_name]: if device_name in hosts_data and "error" in hosts_data[device_name]:
results.append({ results.append(
"device_name": device_name, {
"status": "failed", "device_name": device_name,
"error": hosts_data[device_name]["error"], "status": "failed",
"commands": device_config["commands"], "error": hosts_data[device_name]["error"],
}) "commands": device_config["commands"],
}
)
continue continue
# Get result from Nornir task # Get result from Nornir task
@ -591,28 +554,34 @@ class VPCSCommands(BaseTool):
if host_result.failed: if host_result.failed:
# Task failed # Task failed
error_msg = str(host_result.result) if host_result.result else "Unknown error" error_msg = str(host_result.result) if host_result.result else "Unknown error"
results.append({ results.append(
"device_name": device_name, {
"status": "failed", "device_name": device_name,
"error": error_msg, "status": "failed",
"commands": device_config["commands"], "error": error_msg,
}) "commands": device_config["commands"],
}
)
else: else:
# Task succeeded # Task succeeded
results.append({ results.append(
"device_name": device_name, {
"status": "success", "device_name": device_name,
"output": host_result.result, "status": "success",
"commands": device_config["commands"], "output": host_result.result,
}) "commands": device_config["commands"],
}
)
else: else:
# Device not in task result (shouldn't happen) # Device not in task result (shouldn't happen)
results.append({ results.append(
"device_name": device_name, {
"status": "failed", "device_name": device_name,
"error": f"Device '{device_name}' not in task results", "status": "failed",
"commands": device_config["commands"], "error": f"Device '{device_name}' not in task results",
}) "commands": device_config["commands"],
}
)
return results return results

View File

@ -73,14 +73,14 @@ def _load_forbidden_commands() -> list[str]:
# Try to load from skills repository # Try to load from skills repository
manager = get_skills_manager() manager = get_skills_manager()
if manager is not None: if manager is not None:
commands = manager.load_forbidden_commands() commands = manager.load_forbidden_commands()
if commands: if commands:
logger.info( logger.info(
"Loaded %d forbidden command patterns from skills repository", "Loaded %d forbidden command patterns from skills repository",
len(commands), len(commands),
) )
_forbidden_commands_cache = commands _forbidden_commands_cache = commands
return _forbidden_commands_cache return _forbidden_commands_cache
# Fallback to defaults # Fallback to defaults
logger.warning("Using default forbidden commands list") logger.warning("Using default forbidden commands list")

View File

@ -69,11 +69,9 @@ The drivers are auto-registered when the tools are imported:
from gns3server.agent.gns3_copilot.tools_v2 import DisplayToolNornir from gns3server.agent.gns3_copilot.tools_v2 import DisplayToolNornir
tool = DisplayToolNornir() tool = DisplayToolNornir()
result = tool._run(json.dumps({ result = tool._run(
"device_names": ["huawei-sw1"], json.dumps({"device_names": ["huawei-sw1"], "commands": ["display version"], "project_id": "project-uuid"})
"commands": ["display version"], )
"project_id": "project-uuid"
}))
``` ```
## Running Tests ## Running Tests
@ -145,11 +143,14 @@ Create `tests/test_cisco.py`:
import unittest import unittest
from gns3server.agent.gns3_copilot.utils.custom_netmiko import cisco from gns3server.agent.gns3_copilot.utils.custom_netmiko import cisco
class TestCustomCiscoDriver(unittest.TestCase): class TestCustomCiscoDriver(unittest.TestCase):
def test_device_type_registered(self): def test_device_type_registered(self):
from netmiko.ssh_dispatcher import CLASS_MAPPER from netmiko.ssh_dispatcher import CLASS_MAPPER
self.assertIn("cisco_custom", CLASS_MAPPER) self.assertIn("cisco_custom", CLASS_MAPPER)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
``` ```

View File

@ -209,7 +209,7 @@ class GNS3HuaweiTelnetCE(HuaweiBase):
# Ensure we're in a clean state # Ensure we're in a clean state
try: try:
if hasattr(self, 'base_prompt') and self.base_prompt: if hasattr(self, "base_prompt") and self.base_prompt:
self._test_channel_read(pattern=self.base_prompt) self._test_channel_read(pattern=self.base_prompt)
else: else:
# If base_prompt is not set yet, just read to clear buffer # If base_prompt is not set yet, just read to clear buffer
@ -288,9 +288,7 @@ class GNS3HuaweiTelnetCE(HuaweiBase):
# This method keeps reading until there is no new data for # This method keeps reading until there is no new data for
# 'last_read' seconds. This is the proper Netmiko way to handle # 'last_read' seconds. This is the proper Netmiko way to handle
# command output # command output
output += self.read_channel_timing( output += self.read_channel_timing(read_timeout=read_timeout, last_read=2.0)
read_timeout=read_timeout, last_read=2.0
)
# Exit config mode if requested # Exit config mode if requested
if exit_config_mode: if exit_config_mode:
@ -318,9 +316,7 @@ class GNS3HuaweiTelnetCE(HuaweiBase):
return output return output
def exit_config_mode( def exit_config_mode(self, exit_config: str = "return", pattern: str = r"<\S+>|>\s*$") -> str:
self, exit_config: str = "return", pattern: str = r"<\S+>|>\s*$"
) -> str:
r""" r"""
Exit configuration mode for Huawei devices. Exit configuration mode for Huawei devices.
@ -377,6 +373,7 @@ class GNS3HuaweiTelnetCE(HuaweiBase):
# Register the custom device type with Netmiko # Register the custom device type with Netmiko
_registered = False # Flag to prevent duplicate registration _registered = False # Flag to prevent duplicate registration
def register_custom_device_type() -> None: def register_custom_device_type() -> None:
""" """
Register the custom GNS3HuaweiTelnetCE device type with Netmiko. Register the custom GNS3HuaweiTelnetCE device type with Netmiko.
@ -451,7 +448,4 @@ try:
except Exception as e: except Exception as e:
# Log but don't fail on import # Log but don't fail on import
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.warning( logger.warning(f"Failed to register custom device type: {e}", exc_info=True)
f"Failed to register custom device type: {e}",
exc_info=True
)

View File

@ -60,12 +60,12 @@ class RuijieTelnetEnhanced(RuijieOSBase):
# Interactive command patterns that trigger [yes/no] prompts # Interactive command patterns that trigger [yes/no] prompts
# These are commands that commonly require confirmation # These are commands that commonly require confirmation
INTERACTIVE_PATTERNS = [ INTERACTIVE_PATTERNS = [
re.compile(r'^router-id\s+', re.IGNORECASE), # OSPF router-id re.compile(r"^router-id\s+", re.IGNORECASE), # OSPF router-id
re.compile(r'^erase\s+', re.IGNORECASE), # erase startup-config re.compile(r"^erase\s+", re.IGNORECASE), # erase startup-config
re.compile(r'^delete\s+', re.IGNORECASE), # delete files re.compile(r"^delete\s+", re.IGNORECASE), # delete files
re.compile(r'^format\s+', re.IGNORECASE), # format filesystem re.compile(r"^format\s+", re.IGNORECASE), # format filesystem
re.compile(r'^reload\b', re.IGNORECASE), # reload/reboot re.compile(r"^reload\b", re.IGNORECASE), # reload/reboot
re.compile(r'^boot\s+system\s+', re.IGNORECASE), # change boot image re.compile(r"^boot\s+system\s+", re.IGNORECASE), # change boot image
] ]
def __init__( def __init__(
@ -80,9 +80,7 @@ class RuijieTelnetEnhanced(RuijieOSBase):
kwargs["default_enter"] = "\r\n" kwargs["default_enter"] = "\r\n"
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
def _preprocess_interactive_commands( def _preprocess_interactive_commands(self, config_commands: list[str]) -> list[str]:
self, config_commands: list[str]
) -> list[str]:
""" """
Preprocess commands to insert 'yes' after interactive commands. Preprocess commands to insert 'yes' after interactive commands.
@ -105,8 +103,7 @@ class RuijieTelnetEnhanced(RuijieOSBase):
for pattern in self.INTERACTIVE_PATTERNS: for pattern in self.INTERACTIVE_PATTERNS:
if pattern.match(cmd.strip()): if pattern.match(cmd.strip()):
logging.info( logging.info(
"Ruijie device: Detected interactive command '%s', " "Ruijie device: Detected interactive command '%s', inserting 'yes'",
"inserting 'yes'",
cmd.strip(), cmd.strip(),
) )
# Insert 'yes' after this command # Insert 'yes' after this command
@ -153,25 +150,18 @@ class RuijieTelnetEnhanced(RuijieOSBase):
output += self.config_mode() output += self.config_mode()
# Preprocess: Insert 'yes' after known interactive commands # Preprocess: Insert 'yes' after known interactive commands
processed_commands = self._preprocess_interactive_commands( processed_commands = self._preprocess_interactive_commands(config_commands)
config_commands
)
# Try batch send first (fast path) # Try batch send first (fast path)
try: try:
output += self._send_config_batch( output += self._send_config_batch(processed_commands, read_timeout, delay_factor)
processed_commands, read_timeout, delay_factor
)
except Exception as batch_error: except Exception as batch_error:
logging.warning( logging.warning(
"Ruijie device: Batch send failed, " "Ruijie device: Batch send failed, falling back to one-by-one: %s",
"falling back to one-by-one: %s",
batch_error, batch_error,
) )
# Fallback to one-by-one send with real-time detection # Fallback to one-by-one send with real-time detection
output += self._send_config_one_by_one( output += self._send_config_one_by_one(config_commands, read_timeout, delay_factor)
config_commands, read_timeout, delay_factor
)
# Exit config mode if requested # Exit config mode if requested
if exit_config_mode: if exit_config_mode:
@ -179,9 +169,7 @@ class RuijieTelnetEnhanced(RuijieOSBase):
return output return output
def _send_config_batch( def _send_config_batch(self, commands: list[str], read_timeout: int, delay_factor: float) -> str:
self, commands: list[str], read_timeout: int, delay_factor: float
) -> str:
""" """
Send configuration commands in batch (fast). Send configuration commands in batch (fast).
@ -197,15 +185,11 @@ class RuijieTelnetEnhanced(RuijieOSBase):
# Read all output at once # Read all output at once
# Use same default as Netmiko (2.0 seconds) to ensure complete output # Use same default as Netmiko (2.0 seconds) to ensure complete output
output += self.read_channel_timing( output += self.read_channel_timing(read_timeout=read_timeout, last_read=2.0)
read_timeout=read_timeout, last_read=2.0
)
return output return output
def _send_config_one_by_one( def _send_config_one_by_one(self, commands: list[str], read_timeout: int, delay_factor: float) -> str:
self, commands: list[str], read_timeout: int, delay_factor: float
) -> str:
""" """
Send commands one-by-one with real-time prompt detection. Send commands one-by-one with real-time prompt detection.
@ -225,26 +209,21 @@ class RuijieTelnetEnhanced(RuijieOSBase):
time.sleep(0.05) time.sleep(0.05)
# Read output after this command # Read output after this command
new_output = self.read_channel_timing( new_output = self.read_channel_timing(read_timeout=10, last_read=0.5)
read_timeout=10, last_read=0.5
)
output += new_output output += new_output
# Check if interactive prompt appeared after this command # Check if interactive prompt appeared after this command
for pattern in interactive_patterns: for pattern in interactive_patterns:
if re.search(pattern, new_output, re.IGNORECASE): if re.search(pattern, new_output, re.IGNORECASE):
logging.info( logging.info(
"Ruijie device: Detected interactive prompt " "Ruijie device: Detected interactive prompt after '%s', sending 'yes'",
"after '%s', sending 'yes'",
cmd, cmd,
) )
# Send 'yes' to confirm # Send 'yes' to confirm
self.write_channel(f"yes{self.RETURN}") self.write_channel(f"yes{self.RETURN}")
time.sleep(0.3) time.sleep(0.3)
# Read the confirmation response # Read the confirmation response
output += self.read_channel_timing( output += self.read_channel_timing(read_timeout=30, last_read=0.5)
read_timeout=30, last_read=0.5
)
break break
return output return output
@ -253,6 +232,7 @@ class RuijieTelnetEnhanced(RuijieOSBase):
# Register the custom device type with Netmiko # Register the custom device type with Netmiko
_registered = False # Flag to prevent duplicate registration _registered = False # Flag to prevent duplicate registration
def register_custom_device_type() -> None: def register_custom_device_type() -> None:
""" """
Register the custom RuijieTelnetEnhanced device type with Netmiko. Register the custom RuijieTelnetEnhanced device type with Netmiko.
@ -295,7 +275,4 @@ try:
register_custom_device_type() register_custom_device_type()
except Exception as e: except Exception as e:
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.warning( logger.warning(f"Failed to register Ruijie device type: {e}", exc_info=True)
f"Failed to register Ruijie device type: {e}",
exc_info=True
)

View File

@ -39,6 +39,7 @@ def get_netmiko_version() -> str:
"""Get Netmiko library version.""" """Get Netmiko library version."""
try: try:
import netmiko import netmiko
return netmiko.__version__ return netmiko.__version__
except Exception: except Exception:
return "Unknown" return "Unknown"
@ -68,47 +69,39 @@ def register_custom_drivers() -> set:
try: try:
# Import and register Huawei CE driver # Import and register Huawei CE driver
from gns3server.agent.gns3_copilot.utils.custom_netmiko \ from gns3server.agent.gns3_copilot.utils.custom_netmiko import huawei_ce
import huawei_ce
# Store custom device types before registration # Store custom device types before registration
custom_devices.add("gns3_huawei_telnet_ce") custom_devices.add("gns3_huawei_telnet_ce")
# The driver auto-registers on import, but we can call it # The driver auto-registers on import, but we can call it
# explicitly # explicitly
if hasattr(huawei_ce, 'register_custom_device_type'): if hasattr(huawei_ce, "register_custom_device_type"):
huawei_ce.register_custom_device_type() huawei_ce.register_custom_device_type()
except ImportError: except ImportError:
# Silently skip if custom drivers are not available # Silently skip if custom drivers are not available
pass pass
except Exception as e: except Exception as e:
# Log but don't fail # Log but don't fail
print( print(f"Warning: Failed to register Huawei CE driver: {e}", file=sys.stderr)
f"Warning: Failed to register Huawei CE driver: {e}",
file=sys.stderr
)
try: try:
# Import and register Ruijie Telnet driver # Import and register Ruijie Telnet driver
from gns3server.agent.gns3_copilot.utils.custom_netmiko \ from gns3server.agent.gns3_copilot.utils.custom_netmiko import ruijie_telnet
import ruijie_telnet
# Store custom device types before registration # Store custom device types before registration
custom_devices.add("gns3_ruijie_telnet") custom_devices.add("gns3_ruijie_telnet")
# The driver auto-registers on import, but we can call it # The driver auto-registers on import, but we can call it
# explicitly # explicitly
if hasattr(ruijie_telnet, 'register_custom_device_type'): if hasattr(ruijie_telnet, "register_custom_device_type"):
ruijie_telnet.register_custom_device_type() ruijie_telnet.register_custom_device_type()
except ImportError: except ImportError:
# Silently skip if custom drivers are not available # Silently skip if custom drivers are not available
pass pass
except Exception as e: except Exception as e:
# Log but don't fail # Log but don't fail
print( print(f"Warning: Failed to register Ruijie driver: {e}", file=sys.stderr)
f"Warning: Failed to register Ruijie driver: {e}",
file=sys.stderr
)
return custom_devices return custom_devices
@ -124,32 +117,32 @@ def extract_brand_name(device_type: str) -> str:
Brand name in a standardized format Brand name in a standardized format
""" """
# Remove common suffixes # Remove common suffixes
name = device_type.replace('_telnet', '').replace('_ssh', '') name = device_type.replace("_telnet", "").replace("_ssh", "")
name = name.replace('_serial', '') name = name.replace("_serial", "")
# Map to standard brand names # Map to standard brand names
brand_mapping = { brand_mapping = {
'cisco': 'Cisco', "cisco": "Cisco",
'huawei': 'Huawei', "huawei": "Huawei",
'juniper': 'Juniper', "juniper": "Juniper",
'arista': 'Arista', "arista": "Arista",
'hp': 'HP', "hp": "HP",
'aruba': 'Aruba', "aruba": "Aruba",
'dell': 'Dell', "dell": "Dell",
'brocade': 'Brocade', "brocade": "Brocade",
'extreme': 'Extreme', "extreme": "Extreme",
'ruckus': 'Ruckus', "ruckus": "Ruckus",
'ruijie': 'Ruijie (锐捷)', "ruijie": "Ruijie (锐捷)",
'zte': 'ZTE (中兴)', "zte": "ZTE (中兴)",
'maipu': 'Maipu (迈普)', "maipu": "Maipu (迈普)",
'h3c': 'H3C (华三)', "h3c": "H3C (华三)",
'nokia': 'Nokia', "nokia": "Nokia",
'paloalto': 'Palo Alto', "paloalto": "Palo Alto",
'f5': 'F5', "f5": "F5",
'checkpoint': 'Check Point', "checkpoint": "Check Point",
'generic': 'Generic', "generic": "Generic",
'huaweiyt': 'HuaweiYT', "huaweiyt": "HuaweiYT",
'vsz': 'Ruckus', "vsz": "Ruckus",
} }
# Check for exact matches first # Check for exact matches first
@ -163,7 +156,7 @@ def extract_brand_name(device_type: str) -> str:
return value return value
# Return capitalized version of the first part # Return capitalized version of the first part
return name.split('_')[0].capitalize() return name.split("_")[0].capitalize()
def group_devices_by_brand(device_types: List[str]) -> Dict[str, List[str]]: def group_devices_by_brand(device_types: List[str]) -> Dict[str, List[str]]:
@ -205,8 +198,8 @@ def get_devices_by_protocol() -> Tuple[List[str], List[str]]:
for device_type in CLASS_MAPPER.keys(): for device_type in CLASS_MAPPER.keys():
device_lower = device_type.lower() device_lower = device_type.lower()
has_telnet = 'telnet' in device_lower has_telnet = "telnet" in device_lower
has_ssh = 'ssh' in device_lower has_ssh = "ssh" in device_lower
if has_telnet and has_ssh: if has_telnet and has_ssh:
both_protocols.append(device_type) both_protocols.append(device_type)
@ -230,17 +223,14 @@ def get_total_device_count() -> int:
"""Get total number of device types in Netmiko.""" """Get total number of device types in Netmiko."""
try: try:
from netmiko.ssh_dispatcher import CLASS_MAPPER from netmiko.ssh_dispatcher import CLASS_MAPPER
return len(CLASS_MAPPER) return len(CLASS_MAPPER)
except ImportError: except ImportError:
return 0 return 0
def generate_markdown( def generate_markdown(
version: str, version: str, ssh_devices: List[str], telnet_devices: List[str], total_count: int, custom_devices: set
ssh_devices: List[str],
telnet_devices: List[str],
total_count: int,
custom_devices: set
) -> str: ) -> str:
""" """
Generate Markdown documentation with table format. Generate Markdown documentation with table format.
@ -261,7 +251,7 @@ def generate_markdown(
md_lines.append("# Netmiko Supported Devices") md_lines.append("# Netmiko Supported Devices")
md_lines.append("") md_lines.append("")
md_lines.append(f"**Netmiko Version:** {version}") md_lines.append(f"**Netmiko Version:** {version}")
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
md_lines.append(f"**Generated:** {timestamp}") md_lines.append(f"**Generated:** {timestamp}")
md_lines.append("") md_lines.append("")
md_lines.append("---") md_lines.append("---")
@ -313,8 +303,7 @@ def generate_markdown(
# Footer # Footer
md_lines.append("---") md_lines.append("---")
md_lines.append("") md_lines.append("")
md_lines.append("*This document was generated automatically by the " md_lines.append("*This document was generated automatically by the Netmiko device list script.*")
"Netmiko device list script.*")
return "\n".join(md_lines) return "\n".join(md_lines)
@ -332,10 +321,8 @@ def save_markdown(content: str, filename: str = "netmiko_devices.md") -> None:
# Go up from: .../gns3server/agent/gns3_copilot/utils/ # Go up from: .../gns3server/agent/gns3_copilot/utils/
# custom_netmiko/scripts to project root # custom_netmiko/scripts to project root
# That's 6 levels up # That's 6 levels up
project_root = os.path.dirname(os.path.dirname( project_root = os.path.dirname(
os.path.dirname(os.path.dirname( os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(script_dir)))))
os.path.dirname(os.path.dirname(script_dir))))
)
) )
# Target directory: docs/gns3-copilot # Target directory: docs/gns3-copilot
@ -348,17 +335,15 @@ def save_markdown(content: str, filename: str = "netmiko_devices.md") -> None:
output_path = os.path.join(target_dir, filename) output_path = os.path.join(target_dir, filename)
try: try:
with open(output_path, 'w', encoding='utf-8') as f: with open(output_path, "w", encoding="utf-8") as f:
f.write(content) f.write(content)
print(f"Markdown document saved to: {output_path}") print(f"Markdown document saved to: {output_path}")
except IOError as e: except IOError as e:
print(f"Error: Failed to save Markdown file: {e}", print(f"Error: Failed to save Markdown file: {e}", file=sys.stderr)
file=sys.stderr)
sys.exit(1) sys.exit(1)
def print_summary(version: str, ssh_count: int, telnet_count: int, def print_summary(version: str, ssh_count: int, telnet_count: int, total_count: int, output_file: str) -> None:
total_count: int, output_file: str) -> None:
"""Print summary to console.""" """Print summary to console."""
print("=" * 80) print("=" * 80)
print("Netmiko Device List Generator") print("Netmiko Device List Generator")
@ -402,7 +387,7 @@ def main() -> int:
ssh_devices=ssh_devices, ssh_devices=ssh_devices,
telnet_devices=telnet_devices, telnet_devices=telnet_devices,
total_count=total_count, total_count=total_count,
custom_devices=custom_devices custom_devices=custom_devices,
) )
# Save to file # Save to file
@ -415,7 +400,7 @@ def main() -> int:
ssh_count=len(ssh_devices), ssh_count=len(ssh_devices),
telnet_count=len(telnet_devices), telnet_count=len(telnet_devices),
total_count=total_count, total_count=total_count,
output_file=output_file output_file=output_file,
) )
return 0 return 0

View File

@ -25,9 +25,7 @@ import os
# Add project root to path using relative path # Add project root to path using relative path
test_dir = os.path.dirname(os.path.abspath(__file__)) test_dir = os.path.dirname(os.path.abspath(__file__))
project_root = os.path.dirname( project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(test_dir)))))
os.path.dirname(os.path.dirname(
os.path.dirname(os.path.dirname(test_dir)))))
sys.path.insert(0, project_root) sys.path.insert(0, project_root)
@ -103,13 +101,8 @@ class TestHuaweiTelnetCEDriver(unittest.TestCase):
def test_initialization_parameters(self): def test_initialization_parameters(self):
"""Test that initialization sets correct parameters.""" """Test that initialization sets correct parameters."""
# Create a mock instance (without actual connection) # Create a mock instance (without actual connection)
with patch.object( with patch.object(self.HuaweiTelnetCE, "__init__", lambda self, *args, **kwargs: None):
self.HuaweiTelnetCE, "__init__", instance = self.HuaweiTelnetCE.__new__(self.HuaweiTelnetCE)
lambda self, *args, **kwargs: None
):
instance = self.HuaweiTelnetCE.__new__(
self.HuaweiTelnetCE
)
# Mock the necessary attributes # Mock the necessary attributes
instance.protocol = "telnet" instance.protocol = "telnet"
@ -169,9 +162,7 @@ class TestHuaweiTelnetCEIntegration(unittest.TestCase):
) )
# Create a mock instance # Create a mock instance
instance = GNS3HuaweiTelnetCE.__new__( instance = GNS3HuaweiTelnetCE.__new__(GNS3HuaweiTelnetCE)
GNS3HuaweiTelnetCE
)
# Mock the necessary attributes and methods # Mock the necessary attributes and methods
instance.host = "127.0.0.1" instance.host = "127.0.0.1"
@ -203,12 +194,8 @@ def run_tests():
suite = unittest.TestSuite() suite = unittest.TestSuite()
# Add test cases # Add test cases
suite.addTests( suite.addTests(loader.loadTestsFromTestCase(TestHuaweiTelnetCEDriver))
loader.loadTestsFromTestCase(TestHuaweiTelnetCEDriver) suite.addTests(loader.loadTestsFromTestCase(TestHuaweiTelnetCEIntegration))
)
suite.addTests(
loader.loadTestsFromTestCase(TestHuaweiTelnetCEIntegration)
)
# Run tests # Run tests
runner = unittest.TextTestRunner(verbosity=2) runner = unittest.TextTestRunner(verbosity=2)
@ -218,9 +205,7 @@ def run_tests():
print("\n" + "=" * 100) print("\n" + "=" * 100)
print("Test Summary:") print("Test Summary:")
print(f" Run: {result.testsRun}") print(f" Run: {result.testsRun}")
success_count = ( success_count = result.testsRun - len(result.failures) - len(result.errors)
result.testsRun - len(result.failures) - len(result.errors)
)
print(f" Success: {success_count}") print(f" Success: {success_count}")
print(f" Failed: {len(result.failures)}") print(f" Failed: {len(result.failures)}")
print(f" Errors: {len(result.errors)}") print(f" Errors: {len(result.errors)}")

View File

@ -25,9 +25,7 @@ import os
# Add project root to path using relative path # Add project root to path using relative path
test_dir = os.path.dirname(os.path.abspath(__file__)) test_dir = os.path.dirname(os.path.abspath(__file__))
project_root = os.path.dirname( project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(test_dir)))))
os.path.dirname(os.path.dirname(
os.path.dirname(os.path.dirname(test_dir)))))
sys.path.insert(0, project_root) sys.path.insert(0, project_root)
@ -92,9 +90,7 @@ class TestRuijieTelnetEnhancedDriver(unittest.TestCase):
import re import re
# Should have INTERACTIVE_PATTERNS as a class attribute # Should have INTERACTIVE_PATTERNS as a class attribute
self.assertTrue( self.assertTrue(hasattr(self.RuijieTelnetEnhanced, "INTERACTIVE_PATTERNS"))
hasattr(self.RuijieTelnetEnhanced, "INTERACTIVE_PATTERNS")
)
# Check it's a list # Check it's a list
patterns = self.RuijieTelnetEnhanced.INTERACTIVE_PATTERNS patterns = self.RuijieTelnetEnhanced.INTERACTIVE_PATTERNS
@ -108,18 +104,11 @@ class TestRuijieTelnetEnhancedDriver(unittest.TestCase):
def test_preprocess_interactive_commands(self): def test_preprocess_interactive_commands(self):
"""Test that interactive commands are preprocessed correctly.""" """Test that interactive commands are preprocessed correctly."""
# Create a mock instance (without actual connection) # Create a mock instance (without actual connection)
with patch.object( with patch.object(self.RuijieTelnetEnhanced, "__init__", lambda self, *args, **kwargs: None):
self.RuijieTelnetEnhanced, "__init__", instance = self.RuijieTelnetEnhanced.__new__(self.RuijieTelnetEnhanced)
lambda self, *args, **kwargs: None
):
instance = self.RuijieTelnetEnhanced.__new__(
self.RuijieTelnetEnhanced
)
# Set INTERACTIVE_PATTERNS from class # Set INTERACTIVE_PATTERNS from class
instance.INTERACTIVE_PATTERNS = ( instance.INTERACTIVE_PATTERNS = self.RuijieTelnetEnhanced.INTERACTIVE_PATTERNS
self.RuijieTelnetEnhanced.INTERACTIVE_PATTERNS
)
# Test commands with router-id (should trigger 'yes') # Test commands with router-id (should trigger 'yes')
commands = [ commands = [
@ -128,34 +117,23 @@ class TestRuijieTelnetEnhancedDriver(unittest.TestCase):
"network 10.0.0.0 0.0.0.255 area 0", "network 10.0.0.0 0.0.0.255 area 0",
] ]
processed = instance._preprocess_interactive_commands( processed = instance._preprocess_interactive_commands(commands)
commands
)
# Should have inserted 'yes' after router-id command # Should have inserted 'yes' after router-id command
self.assertEqual(len(processed), 4) self.assertEqual(len(processed), 4)
self.assertEqual(processed[0], "router ospf 1") self.assertEqual(processed[0], "router ospf 1")
self.assertEqual(processed[1], "router-id 1.1.1.1") self.assertEqual(processed[1], "router-id 1.1.1.1")
self.assertEqual(processed[2], "yes") self.assertEqual(processed[2], "yes")
self.assertEqual( self.assertEqual(processed[3], "network 10.0.0.0 0.0.0.255 area 0")
processed[3], "network 10.0.0.0 0.0.0.255 area 0"
)
def test_preprocess_non_interactive_commands(self): def test_preprocess_non_interactive_commands(self):
"""Test that non-interactive commands are not modified.""" """Test that non-interactive commands are not modified."""
# Create a mock instance # Create a mock instance
with patch.object( with patch.object(self.RuijieTelnetEnhanced, "__init__", lambda self, *args, **kwargs: None):
self.RuijieTelnetEnhanced, "__init__", instance = self.RuijieTelnetEnhanced.__new__(self.RuijieTelnetEnhanced)
lambda self, *args, **kwargs: None
):
instance = self.RuijieTelnetEnhanced.__new__(
self.RuijieTelnetEnhanced
)
# Set INTERACTIVE_PATTERNS from class # Set INTERACTIVE_PATTERNS from class
instance.INTERACTIVE_PATTERNS = ( instance.INTERACTIVE_PATTERNS = self.RuijieTelnetEnhanced.INTERACTIVE_PATTERNS
self.RuijieTelnetEnhanced.INTERACTIVE_PATTERNS
)
# Test commands without interactive prompts # Test commands without interactive prompts
commands = [ commands = [
@ -190,9 +168,7 @@ class TestRuijieTelnetEnhancedDriver(unittest.TestCase):
# Check that default_enter is handled in __init__ # Check that default_enter is handled in __init__
# (The actual logic is in the __init__ method body) # (The actual logic is in the __init__ method body)
init_source = inspect.getsource( init_source = inspect.getsource(self.RuijieTelnetEnhanced.__init__)
self.RuijieTelnetEnhanced.__init__
)
self.assertIn("default_enter", init_source) self.assertIn("default_enter", init_source)
self.assertIn("\\r\\n", init_source) self.assertIn("\\r\\n", init_source)
@ -207,9 +183,7 @@ class TestRuijieTelnetEnhancedIntegration(unittest.TestCase):
) )
# Create a mock instance # Create a mock instance
instance = RuijieTelnetEnhanced.__new__( instance = RuijieTelnetEnhanced.__new__(RuijieTelnetEnhanced)
RuijieTelnetEnhanced
)
# Mock the necessary attributes and methods # Mock the necessary attributes and methods
instance.RETURN = "\r\n" instance.RETURN = "\r\n"
@ -224,9 +198,7 @@ class TestRuijieTelnetEnhancedIntegration(unittest.TestCase):
commands = ["interface GigabitEthernet 0/1", "description Test"] commands = ["interface GigabitEthernet 0/1", "description Test"]
# Call send_config_set # Call send_config_set
instance.send_config_set( instance.send_config_set(commands, enter_config_mode=False, exit_config_mode=False)
commands, enter_config_mode=False, exit_config_mode=False
)
# Verify _send_config_batch was called # Verify _send_config_batch was called
instance._send_config_batch.assert_called_once() instance._send_config_batch.assert_called_once()
@ -238,17 +210,13 @@ class TestRuijieTelnetEnhancedIntegration(unittest.TestCase):
) )
# Create a mock instance # Create a mock instance
instance = RuijieTelnetEnhanced.__new__( instance = RuijieTelnetEnhanced.__new__(RuijieTelnetEnhanced)
RuijieTelnetEnhanced instance.INTERACTIVE_PATTERNS = RuijieTelnetEnhanced.INTERACTIVE_PATTERNS
)
instance.INTERACTIVE_PATTERNS = (
RuijieTelnetEnhanced.INTERACTIVE_PATTERNS
)
# Test commands with multiple interactive prompts # Test commands with multiple interactive prompts
commands = [ commands = [
"router-id 1.1.1.1", # Should trigger 'yes' "router-id 1.1.1.1", # Should trigger 'yes'
"erase startup-config", # Should trigger 'yes' "erase startup-config", # Should trigger 'yes'
"interface GigabitEthernet 0/1", # Should NOT trigger "interface GigabitEthernet 0/1", # Should NOT trigger
] ]
@ -273,12 +241,8 @@ def run_tests():
suite = unittest.TestSuite() suite = unittest.TestSuite()
# Add test cases # Add test cases
suite.addTests( suite.addTests(loader.loadTestsFromTestCase(TestRuijieTelnetEnhancedDriver))
loader.loadTestsFromTestCase(TestRuijieTelnetEnhancedDriver) suite.addTests(loader.loadTestsFromTestCase(TestRuijieTelnetEnhancedIntegration))
)
suite.addTests(
loader.loadTestsFromTestCase(TestRuijieTelnetEnhancedIntegration)
)
# Run tests # Run tests
runner = unittest.TextTestRunner(verbosity=2) runner = unittest.TextTestRunner(verbosity=2)
@ -288,9 +252,7 @@ def run_tests():
print("\n" + "=" * 100) print("\n" + "=" * 100)
print("Test Summary:") print("Test Summary:")
print(f" Run: {result.testsRun}") print(f" Run: {result.testsRun}")
success_count = ( success_count = result.testsRun - len(result.failures) - len(result.errors)
result.testsRun - len(result.failures) - len(result.errors)
)
print(f" Success: {success_count}") print(f" Success: {success_count}")
print(f" Failed: {len(result.failures)}") print(f" Failed: {len(result.failures)}")
print(f" Errors: {len(result.errors)}") print(f" Errors: {len(result.errors)}")

View File

@ -27,9 +27,7 @@ import os
# Add project root to path using relative path # Add project root to path using relative path
test_dir = os.path.dirname(os.path.abspath(__file__)) test_dir = os.path.dirname(os.path.abspath(__file__))
project_root = os.path.dirname( project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(test_dir)))))
os.path.dirname(os.path.dirname(
os.path.dirname(os.path.dirname(test_dir)))))
sys.path.insert(0, project_root) sys.path.insert(0, project_root)
@ -121,6 +119,7 @@ class TestVPCSTelnetInit(unittest.TestCase):
from gns3server.agent.gns3_copilot.utils.custom_netmiko.vpcs_telnet import ( # noqa: E501 from gns3server.agent.gns3_copilot.utils.custom_netmiko.vpcs_telnet import ( # noqa: E501
VPCSTelnet, VPCSTelnet,
) )
cls.VPCSTelnet = VPCSTelnet cls.VPCSTelnet = VPCSTelnet
def test_default_enter_parameter(self): def test_default_enter_parameter(self):
@ -134,6 +133,7 @@ class TestVPCSTelnetInit(unittest.TestCase):
def test_default_parameters_when_none(self): def test_default_parameters_when_none(self):
"""Test default parameters when not provided.""" """Test default parameters when not provided."""
# Mock the BaseConnection.__init__ to avoid actual connection # Mock the BaseConnection.__init__ to avoid actual connection
# But also set device_type since VPCSTelnet.__init__ sets it # But also set device_type since VPCSTelnet.__init__ sets it
def mock_init(self, *args, **kwargs): def mock_init(self, *args, **kwargs):
@ -141,23 +141,20 @@ class TestVPCSTelnetInit(unittest.TestCase):
kwargs.setdefault("device_type", "gns3_vpcs_telnet") kwargs.setdefault("device_type", "gns3_vpcs_telnet")
self.device_type = kwargs.get("device_type") self.device_type = kwargs.get("device_type")
with patch.object( with patch.object(self.VPCSTelnet, "__init__", mock_init):
self.VPCSTelnet, "__init__", mock_init
):
instance = self.VPCSTelnet(host="127.0.0.1") instance = self.VPCSTelnet(host="127.0.0.1")
self.assertEqual(instance.device_type, "gns3_vpcs_telnet") self.assertEqual(instance.device_type, "gns3_vpcs_telnet")
def test_device_type_set(self): def test_device_type_set(self):
"""Test that device_type is set to gns3_vpcs_telnet.""" """Test that device_type is set to gns3_vpcs_telnet."""
# Mock the __init__ to set device_type # Mock the __init__ to set device_type
def mock_init(self, *args, **kwargs): def mock_init(self, *args, **kwargs):
# Simulate what VPCSTelnet.__init__ does # Simulate what VPCSTelnet.__init__ does
kwargs.setdefault("device_type", "gns3_vpcs_telnet") kwargs.setdefault("device_type", "gns3_vpcs_telnet")
self.device_type = kwargs.get("device_type") self.device_type = kwargs.get("device_type")
with patch.object( with patch.object(self.VPCSTelnet, "__init__", mock_init):
self.VPCSTelnet, "__init__", mock_init
):
instance = self.VPCSTelnet(host="127.0.0.1") instance = self.VPCSTelnet(host="127.0.0.1")
self.assertEqual(instance.device_type, "gns3_vpcs_telnet") self.assertEqual(instance.device_type, "gns3_vpcs_telnet")
@ -171,6 +168,7 @@ class TestVPCSTelnetMethods(unittest.TestCase):
from gns3server.agent.gns3_copilot.utils.custom_netmiko.vpcs_telnet import ( # noqa: E501 from gns3server.agent.gns3_copilot.utils.custom_netmiko.vpcs_telnet import ( # noqa: E501
VPCSTelnet, VPCSTelnet,
) )
cls.VPCSTelnet = VPCSTelnet cls.VPCSTelnet = VPCSTelnet
def test_check_config_mode_always_false(self): def test_check_config_mode_always_false(self):
@ -213,6 +211,7 @@ class TestVPCSTelnetSendCommand(unittest.TestCase):
from gns3server.agent.gns3_copilot.utils.custom_netmiko.vpcs_telnet import ( # noqa: E501 from gns3server.agent.gns3_copilot.utils.custom_netmiko.vpcs_telnet import ( # noqa: E501
VPCSTelnet, VPCSTelnet,
) )
cls.VPCSTelnet = VPCSTelnet cls.VPCSTelnet = VPCSTelnet
def test_send_command_writes_bytes(self): def test_send_command_writes_bytes(self):
@ -243,7 +242,7 @@ class TestVPCSTelnetSendCommand(unittest.TestCase):
# Check that expect_string defaults to r"PC\d+>" # Check that expect_string defaults to r"PC\d+>"
source = inspect.getsource(self.VPCSTelnet.send_command) source = inspect.getsource(self.VPCSTelnet.send_command)
self.assertIn("expect_string = r\"PC\\d+>\"", source) self.assertIn('expect_string = r"PC\\d+>"', source)
def test_send_command_timing_calls_send_command(self): def test_send_command_timing_calls_send_command(self):
"""Test that send_command_timing delegates to send_command.""" """Test that send_command_timing delegates to send_command."""
@ -277,6 +276,7 @@ class TestVPCSTelnetTelnetLogin(unittest.TestCase):
from gns3server.agent.gns3_copilot.utils.custom_netmiko.vpcs_telnet import ( # noqa: E501 from gns3server.agent.gns3_copilot.utils.custom_netmiko.vpcs_telnet import ( # noqa: E501
VPCSTelnet, VPCSTelnet,
) )
cls.VPCSTelnet = VPCSTelnet cls.VPCSTelnet = VPCSTelnet
def test_telnet_login_default_prompt_pattern(self): def test_telnet_login_default_prompt_pattern(self):
@ -335,19 +335,16 @@ class TestVPCSTelnetRegistration(unittest.TestCase):
from gns3server.agent.gns3_copilot.utils.custom_netmiko import ( from gns3server.agent.gns3_copilot.utils.custom_netmiko import (
vpcs_telnet, vpcs_telnet,
) )
cls.vpcs_telnet = vpcs_telnet cls.vpcs_telnet = vpcs_telnet
def test_register_function_exists(self): def test_register_function_exists(self):
"""Test that register_custom_device_type function exists.""" """Test that register_custom_device_type function exists."""
self.assertTrue( self.assertTrue(hasattr(self.vpcs_telnet, "register_custom_device_type"))
hasattr(self.vpcs_telnet, "register_custom_device_type")
)
def test_register_function_is_callable(self): def test_register_function_is_callable(self):
"""Test that register_custom_device_type is callable.""" """Test that register_custom_device_type is callable."""
self.assertTrue( self.assertTrue(callable(self.vpcs_telnet.register_custom_device_type))
callable(self.vpcs_telnet.register_custom_device_type)
)
class TestVPCSTelnetAnsiStripping(unittest.TestCase): class TestVPCSTelnetAnsiStripping(unittest.TestCase):
@ -359,6 +356,7 @@ class TestVPCSTelnetAnsiStripping(unittest.TestCase):
from gns3server.agent.gns3_copilot.utils.custom_netmiko.vpcs_telnet import ( # noqa: E501 from gns3server.agent.gns3_copilot.utils.custom_netmiko.vpcs_telnet import ( # noqa: E501
VPCSTelnet, VPCSTelnet,
) )
cls.VPCSTelnet = VPCSTelnet cls.VPCSTelnet = VPCSTelnet
def test_strip_ansi_codes_method_exists(self): def test_strip_ansi_codes_method_exists(self):
@ -435,14 +433,10 @@ IP/MASK : 192.168.1.20/24"""
# Mock necessary attributes # Mock necessary attributes
instance.remote_conn = MagicMock() instance.remote_conn = MagicMock()
instance.remote_conn.write = MagicMock() instance.remote_conn.write = MagicMock()
instance.read_until_pattern = MagicMock( instance.read_until_pattern = MagicMock(return_value="\x1b[1mNAME\x1b[0m : PC2\nPC2>")
return_value="\x1b[1mNAME\x1b[0m : PC2\nPC2>"
)
# Mock _strip_ansi_codes to verify it's called # Mock _strip_ansi_codes to verify it's called
instance._strip_ansi_codes = Mock( instance._strip_ansi_codes = Mock(return_value="NAME : PC2\nPC2>")
return_value="NAME : PC2\nPC2>"
)
# Call send_command # Call send_command
instance.send_command("show ip") instance.send_command("show ip")
@ -476,9 +470,7 @@ def run_tests():
print("\n" + "=" * 100) print("\n" + "=" * 100)
print("Test Summary:") print("Test Summary:")
print(f" Run: {result.testsRun}") print(f" Run: {result.testsRun}")
success_count = ( success_count = result.testsRun - len(result.failures) - len(result.errors)
result.testsRun - len(result.failures) - len(result.errors)
)
print(f" Success: {success_count}") print(f" Success: {success_count}")
print(f" Failed: {len(result.failures)}") print(f" Failed: {len(result.failures)}")
print(f" Errors: {len(result.errors)}") print(f" Errors: {len(result.errors)}")

View File

@ -149,10 +149,7 @@ class VPCSTelnet(BaseConnection):
# Step 3: Wait for VPCS prompt pattern # Step 3: Wait for VPCS prompt pattern
try: try:
# Read until we see the prompt # Read until we see the prompt
output = self.read_until_pattern( output = self.read_until_pattern(pattern=pri_prompt_terminator, read_timeout=10)
pattern=pri_prompt_terminator,
read_timeout=10
)
return_msg += output return_msg += output
if re.search(pri_prompt_terminator, return_msg, flags=re.M): if re.search(pri_prompt_terminator, return_msg, flags=re.M):
@ -291,9 +288,7 @@ class VPCSTelnet(BaseConnection):
strip_command=strip_command, strip_command=strip_command,
) )
def check_config_mode( def check_config_mode(self, check_string: str = "", pattern: str = "", force_regex: bool = False) -> bool:
self, check_string: str = "", pattern: str = "", force_regex: bool = False
) -> bool:
""" """
VPCS has no configuration mode. VPCS has no configuration mode.
@ -326,9 +321,7 @@ class VPCSTelnet(BaseConnection):
""" """
return ANSI_ESCAPE_PATTERN.sub("", text) return ANSI_ESCAPE_PATTERN.sub("", text)
def config_mode( def config_mode(self, config_command: str = "", pattern: str = "", re_flags: int = 0) -> str:
self, config_command: str = "", pattern: str = "", re_flags: int = 0
) -> str:
""" """
VPCS has no configuration mode. VPCS has no configuration mode.
@ -370,6 +363,7 @@ class VPCSTelnet(BaseConnection):
# Register the custom device type with Netmiko # Register the custom device type with Netmiko
_registered = False # Flag to prevent duplicate registration _registered = False # Flag to prevent duplicate registration
def register_custom_device_type() -> None: def register_custom_device_type() -> None:
""" """
Register the custom VPCS Telnet device type with Netmiko. Register the custom VPCS Telnet device type with Netmiko.
@ -431,8 +425,4 @@ try:
register_custom_device_type() register_custom_device_type()
except Exception as e: except Exception as e:
# Log but don't fail on import # Log but don't fail on import
logger.warning( logger.warning("Failed to register VPCS device type: %s", e, exc_info=True)
"Failed to register VPCS device type: %s",
e,
exc_info=True
)

View File

@ -61,10 +61,7 @@ def format_error_message(error: Exception) -> str:
# Check if error message contains HTML # Check if error message contains HTML
if _contains_html(error_str): if _contains_html(error_str):
logger.warning( logger.warning("Detected HTML in error message, likely due to incorrect API configuration or base URL")
"Detected HTML in error message, likely due to incorrect API "
"configuration or base URL"
)
return ( return (
"API request failed. The error response indicates an issue with " "API request failed. The error response indicates an issue with "
"your API configuration. Please check:\n" "your API configuration. Please check:\n"

View File

@ -71,9 +71,7 @@ def get_device_ports_from_topology(
Devices that don't exist or missing console_port will not be included Devices that don't exist or missing console_port will not be included
""" """
# Log received parameters # Log received parameters
logger.info( logger.info("Called with device_names=%s, project_id=%s", device_names, project_id)
"Called with device_names=%s, project_id=%s", device_names, project_id
)
try: try:
# Lazy import to avoid circular dependency # Lazy import to avoid circular dependency
@ -93,9 +91,7 @@ def get_device_ports_from_topology(
for device_name in device_names: for device_name in device_names:
# Check if device exists in topology # Check if device exists in topology
if device_name not in topology.get("nodes", {}): if device_name not in topology.get("nodes", {}):
logger.warning( logger.warning("Device '%s' not found in topology", device_name)
"Device '%s' not found in topology", device_name
)
continue continue
node_info = topology["nodes"][device_name] node_info = topology["nodes"][device_name]
@ -133,9 +129,7 @@ def get_device_ports_from_topology(
f"Current tags: {tags}" f"Current tags: {tags}"
) )
logger.error(error_msg) logger.error(error_msg)
hosts_data[device_name] = { hosts_data[device_name] = {"error": error_msg}
"error": error_msg
}
continue continue
logger.debug( logger.debug(
@ -170,11 +164,7 @@ def get_device_ports_from_topology(
"platform": platform, "platform": platform,
"node_type": node_info.get("type"), "node_type": node_info.get("type"),
"groups": ["network_devices"], # For inheriting hostname, timeout, etc. "groups": ["network_devices"], # For inheriting hostname, timeout, etc.
"connection_options": { "connection_options": {"netmiko": {"extras": {"device_type": device_type}}},
"netmiko": {
"extras": {"device_type": device_type}
}
},
} }
# Per-node default credentials (seeded from the template appliance # Per-node default credentials (seeded from the template appliance

View File

@ -135,14 +135,9 @@ def calculate_two_node_shape(
node2_center_x = node2["x"] + (node2_width / 2) node2_center_x = node2["x"] + (node2_width / 2)
node2_center_y = node2["y"] + (node2_height / 2) node2_center_y = node2["y"] + (node2_height / 2)
distance = math.sqrt( distance = math.sqrt((node2_center_x - node1_center_x) ** 2 + (node2_center_y - node1_center_y) ** 2)
(node2_center_x - node1_center_x) ** 2
+ (node2_center_y - node1_center_y) ** 2
)
angle_rad = math.atan2( angle_rad = math.atan2(node2_center_y - node1_center_y, node2_center_x - node1_center_x)
node2_center_y - node1_center_y, node2_center_x - node1_center_x
)
angle_deg = round(math.degrees(angle_rad)) angle_deg = round(math.degrees(angle_rad))
angle_rad = math.radians(angle_deg) angle_rad = math.radians(angle_deg)
@ -162,16 +157,10 @@ def calculate_two_node_shape(
shape_width = rx * 2 shape_width = rx * 2
shape_height = ry * 2 shape_height = ry * 2
svg_x = center_x - ( svg_x = center_x - (rx * math.cos(angle_rad) - ry * math.sin(angle_rad))
rx * math.cos(angle_rad) - ry * math.sin(angle_rad) svg_y = center_y - (rx * math.sin(angle_rad) + ry * math.cos(angle_rad))
)
svg_y = center_y - (
rx * math.sin(angle_rad) + ry * math.cos(angle_rad)
)
shape_svg = generate_ellipse_svg( shape_svg = generate_ellipse_svg(int(rx), int(ry), color_scheme, int(shape_width), int(shape_height))
int(rx), int(ry), color_scheme, int(shape_width), int(shape_height)
)
offset_distance = ry * text_offset_ratio offset_distance = ry * text_offset_ratio
@ -188,22 +177,12 @@ def calculate_two_node_shape(
else: # rectangle else: # rectangle
shape_width = distance shape_width = distance
shape_height = max( shape_height = max(node1_width, node1_height, node2_width, node2_height)
node1_width, node1_height, node2_width, node2_height
)
svg_x = center_x - ( svg_x = center_x - ((shape_width / 2) * math.cos(angle_rad) - (shape_height / 2) * math.sin(angle_rad))
(shape_width / 2) * math.cos(angle_rad) svg_y = center_y - ((shape_width / 2) * math.sin(angle_rad) + (shape_height / 2) * math.cos(angle_rad))
- (shape_height / 2) * math.sin(angle_rad)
)
svg_y = center_y - (
(shape_width / 2) * math.sin(angle_rad)
+ (shape_height / 2) * math.cos(angle_rad)
)
shape_svg = generate_rectangle_svg( shape_svg = generate_rectangle_svg(int(shape_width), int(shape_height), color_scheme)
int(shape_width), int(shape_height), color_scheme
)
offset_distance = (shape_height / 2) * text_offset_ratio offset_distance = (shape_height / 2) * text_offset_ratio
@ -354,33 +333,15 @@ def _get_color_scheme(area_name: str) -> dict[str, Any]:
return COLOR_SCHEMES["NORMAL_AREA"] return COLOR_SCHEMES["NORMAL_AREA"]
# 3. Logical Isolation # 3. Logical Isolation
if ( if "VRF" in label or "VLAN" in label or "MSTP" in label or "VXLAN" in label or "MPLS" in label:
"VRF" in label
or "VLAN" in label
or "MSTP" in label
or "VXLAN" in label
or "MPLS" in label
):
return COLOR_SCHEMES["ISOLATION"] return COLOR_SCHEMES["ISOLATION"]
# 4. High Availability # 4. High Availability
if ( if "VRRP" in label or "HSRP" in label or "HA" in label or "STACK" in label or "M-LAG" in label:
"VRRP" in label
or "HSRP" in label
or "HA" in label
or "STACK" in label
or "M-LAG" in label
):
return COLOR_SCHEMES["HIGH_AVAILABILITY"] return COLOR_SCHEMES["HIGH_AVAILABILITY"]
# 5. External/Internet # 5. External/Internet
if ( if "INET" in label or "OUT" in label or "EXTERNAL" in label or "INTERNET" in label or "DMZ" in label:
"INET" in label
or "OUT" in label
or "EXTERNAL" in label
or "INTERNET" in label
or "DMZ" in label
):
return COLOR_SCHEMES["EXTERNAL"] return COLOR_SCHEMES["EXTERNAL"]
# 6. Management # 6. Management
@ -392,12 +353,7 @@ def _get_color_scheme(area_name: str) -> dict[str, Any]:
return COLOR_SCHEMES["SECURITY_TRUSTED"] return COLOR_SCHEMES["SECURITY_TRUSTED"]
# 8. Cloud/Tunnel # 8. Cloud/Tunnel
if ( if "TUNNEL" in label or "CLOUD" in label or "GRE" in label or "IPSEC" in label:
"TUNNEL" in label
or "CLOUD" in label
or "GRE" in label
or "IPSEC" in label
):
return COLOR_SCHEMES["CLOUD_TUNNEL"] return COLOR_SCHEMES["CLOUD_TUNNEL"]
# Legacy keyword matching for backward compatibility # Legacy keyword matching for backward compatibility
@ -436,9 +392,7 @@ def calculate_two_node_ellipse(
Wrapper around calculate_two_node_shape with shape_type="ellipse". Wrapper around calculate_two_node_shape with shape_type="ellipse".
""" """
result = calculate_two_node_shape( result = calculate_two_node_shape(node1, node2, area_name, "ellipse", text_offset_ratio)
node1, node2, area_name, "ellipse", text_offset_ratio
)
return { return {
"ellipse": result["shape"], "ellipse": result["shape"],
"text": result["text"], "text": result["text"],
@ -457,9 +411,7 @@ def calculate_two_node_rectangle(
Wrapper around calculate_two_node_shape with shape_type="rectangle". Wrapper around calculate_two_node_shape with shape_type="rectangle".
""" """
result = calculate_two_node_shape( result = calculate_two_node_shape(node1, node2, area_name, "rectangle", text_offset_ratio)
node1, node2, area_name, "rectangle", text_offset_ratio
)
metadata = result["metadata"] metadata = result["metadata"]
return { return {
"rectangle": result["shape"], "rectangle": result["shape"],

View File

@ -53,9 +53,7 @@ from fastapi import FastAPI
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
async def get_user_llm_config_with_app( async def get_user_llm_config_with_app(user_id: UUID, app: FastAPI) -> Optional[Dict[str, Any]]:
user_id: UUID, app: FastAPI
) -> Optional[Dict[str, Any]]:
""" """
Get user's default LLM model configuration with decrypted API key. Get user's default LLM model configuration with decrypted API key.

View File

@ -160,9 +160,7 @@ def parse_tool_content(
# Empty string handling # Empty string handling
if not content.strip(): if not content.strip():
result = {} result = {}
logger.info( logger.info("Content is empty or whitespace, returning: %s", result)
"Content is empty or whitespace, returning: %s", result
)
return result return result
s = content.strip() s = content.strip()
@ -170,9 +168,7 @@ def parse_tool_content(
# Handle empty dictionary case # Handle empty dictionary case
if s == "{}": if s == "{}":
result = {} result = {}
logger.info( logger.info("Content is empty dictionary, returning: %s", result)
"Content is empty dictionary, returning: %s", result
)
return result return result
# Try to parse as Python literal # Try to parse as Python literal
@ -190,9 +186,7 @@ def parse_tool_content(
# Try to parse as JSON # Try to parse as JSON
try: try:
result = json.loads(s) result = json.loads(s)
logger.info( logger.info("Successfully parsed as JSON, returning: %s", result)
"Successfully parsed as JSON, returning: %s", result
)
return result return result
except json.JSONDecodeError: except json.JSONDecodeError:
pass pass
@ -221,8 +215,7 @@ def parse_tool_content(
# Handle unsupported types # Handle unsupported types
error_msg = ( # type: ignore[unreachable] error_msg = ( # type: ignore[unreachable]
"Content must be str, dict, list, int, float, bool, or None, got " f"Content must be str, dict, list, int, float, bool, or None, got {type(content).__name__}"
f"{type(content).__name__}"
) )
logger.error(error_msg) logger.error(error_msg)
@ -238,9 +231,7 @@ def parse_tool_content(
return result return result
def format_tool_response( def format_tool_response(content: str | dict | list | int | float | bool | None, indent: int = 2) -> str:
content: str | dict | list | int | float | bool | None, indent: int = 2
) -> str:
""" """
Format tool response as a beautiful JSON string for UI display. Format tool response as a beautiful JSON string for UI display.
@ -259,9 +250,7 @@ def format_tool_response(
logger.info("format_tool_response parameter indent: %s", indent) logger.info("format_tool_response parameter indent: %s", indent)
try: try:
parsed = parse_tool_content( parsed = parse_tool_content(content, fallback_to_raw=True, strict_mode=False)
content, fallback_to_raw=True, strict_mode=False
)
# Ensure the result can be serialized to JSON # Ensure the result can be serialized to JSON
result = json.dumps(parsed, ensure_ascii=False, indent=indent) result = json.dumps(parsed, ensure_ascii=False, indent=indent)
logger.info("format_tool_response returning: %s", result) logger.info("format_tool_response returning: %s", result)
@ -270,9 +259,7 @@ def format_tool_response(
# If the parsed result cannot be serialized, convert to string and wrap # If the parsed result cannot be serialized, convert to string and wrap
logger.error("Cannot serialize parsed result to JSON: %s", e) logger.error("Cannot serialize parsed result to JSON: %s", e)
try: try:
result = json.dumps( result = json.dumps({"raw": str(content)}, ensure_ascii=False, indent=indent)
{"raw": str(content)}, ensure_ascii=False, indent=indent
)
logger.info("format_tool_response returning fallback: %s", result) logger.info("format_tool_response returning fallback: %s", result)
return result return result
except Exception: except Exception:
@ -286,16 +273,12 @@ def format_tool_response(
return result return result
except Exception as e: except Exception as e:
logger.error("Error formatting tool response: %s", e) logger.error("Error formatting tool response: %s", e)
result = json.dumps( result = json.dumps({"error": str(e)}, ensure_ascii=False, indent=indent)
{"error": str(e)}, ensure_ascii=False, indent=indent
)
logger.info("format_tool_response returning error: %s", result) logger.info("format_tool_response returning error: %s", result)
return result return result
def normalize_tool_response( def normalize_tool_response(response: dict | list | str, tool_name: str = "unknown") -> dict:
response: dict | list | str, tool_name: str = "unknown"
) -> dict:
""" """
Normalize tool response to standard format for consistent frontend display. Normalize tool response to standard format for consistent frontend display.
@ -334,11 +317,7 @@ def normalize_tool_response(
} }
# Handle error responses # Handle error responses
if ( if isinstance(response, dict) and "error" in response and len(response) == 1:
isinstance(response, dict)
and "error" in response
and len(response) == 1
):
return { return {
"success": False, "success": False,
"total": 0, "total": 0,
@ -362,34 +341,21 @@ def normalize_tool_response(
# Handle list responses (most tools return list of device results) # Handle list responses (most tools return list of device results)
if isinstance(response, list): if isinstance(response, list):
successful = sum( successful = sum(1 for item in response if isinstance(item, dict) and item.get("status") == "success")
1
for item in response
if isinstance(item, dict) and item.get("status") == "success"
)
failed = len(response) - successful failed = len(response) - successful
normalized_data = [] normalized_data = []
for item in response: for item in response:
if isinstance(item, dict): if isinstance(item, dict):
normalized_item = { normalized_item = {
"id": item.get("device_id") "id": item.get("device_id") or item.get("node_id") or item.get("id") or "",
or item.get("node_id")
or item.get("id")
or "",
"name": item.get("device_name") or item.get("name") or "", "name": item.get("device_name") or item.get("name") or "",
"status": item.get("status", "unknown"), "status": item.get("status", "unknown"),
} }
if normalized_item["status"] == "success": if normalized_item["status"] == "success":
normalized_item["result"] = ( normalized_item["result"] = item.get("output") or item.get("result") or ""
item.get("output") or item.get("result") or ""
)
else: else:
normalized_item["error"] = ( normalized_item["error"] = item.get("error") or item.get("output") or "Unknown error"
item.get("error")
or item.get("output")
or "Unknown error"
)
normalized_data.append(normalized_item) normalized_data.append(normalized_item)
else: else:
# Non-dict items in list # Non-dict items in list
@ -426,14 +392,8 @@ def normalize_tool_response(
} }
# Legacy format: extract common fields # Legacy format: extract common fields
total = ( total = response.get("total_nodes") or response.get("total") or response.get("count", 1)
response.get("total_nodes") successful = response.get("successful_nodes") or response.get("successful") or 0
or response.get("total")
or response.get("count", 1)
)
successful = (
response.get("successful_nodes") or response.get("successful") or 0
)
failed = response.get("failed_nodes") or response.get("failed") or 0 failed = response.get("failed_nodes") or response.get("failed") or 0
# Extract data from various possible locations # Extract data from various possible locations
@ -458,13 +418,9 @@ def normalize_tool_response(
if not data and "status" in response: if not data and "status" in response:
data = [ data = [
{ {
"name": response.get("device_name") "name": response.get("device_name") or response.get("name") or "",
or response.get("name")
or "",
"status": response["status"], "status": response["status"],
"result": response.get("output") "result": response.get("output") or response.get("result") or "",
or response.get("result")
or "",
"error": response.get("error") or "", "error": response.get("error") or "",
} }
] ]
@ -564,9 +520,7 @@ def _test_parse_tool_content() -> None:
valid = "✓" valid = "✓"
except Exception: except Exception:
valid = "✗" valid = "✗"
print( print(f"Format Test {i + 1}: {valid} Input: {repr(input_data)} -> {result}")
f"Format Test {i + 1}: {valid} Input: {repr(input_data)} -> {result}"
)
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -98,9 +98,7 @@ class ToolCallStreamAccumulator:
"id": tc_id, "id": tc_id,
"type": "function", "type": "function",
"function": { "function": {
"name": self._current_tool_call[ "name": self._current_tool_call["name"],
"name"
],
"arguments": "", "arguments": "",
}, },
}, },
@ -122,9 +120,7 @@ class ToolCallStreamAccumulator:
# Core: string concatenation # Core: string concatenation
if isinstance(args_chunk, str): if isinstance(args_chunk, str):
tool_data[ tool_data["args_string"] += args_chunk
"args_string"
] += args_chunk
# Send updated tool_call event with accumulated args # Send updated tool_call event with accumulated args
chunks.append( chunks.append(
@ -135,9 +131,7 @@ class ToolCallStreamAccumulator:
"type": "function", "type": "function",
"function": { "function": {
"name": tool_data["name"], "name": tool_data["name"],
"arguments": tool_data[ "arguments": tool_data["args_string"],
"args_string"
],
}, },
}, },
} }
@ -147,15 +141,9 @@ class ToolCallStreamAccumulator:
# complete ========== # complete ==========
# Check finish_reason == "tool_calls" or "STOP" # Check finish_reason == "tool_calls" or "STOP"
response_metadata = getattr(chunk, "response_metadata", {}) response_metadata = getattr(chunk, "response_metadata", {})
finish_reason = ( finish_reason = response_metadata.get("finish_reason") if isinstance(response_metadata, dict) else None
response_metadata.get("finish_reason")
if isinstance(response_metadata, dict)
else None
)
if (finish_reason == "tool_calls") or ( if (finish_reason == "tool_calls") or (finish_reason == "stop" and self._current_tool_call is not None):
finish_reason == "stop" and self._current_tool_call is not None
):
if self._current_tool_call: if self._current_tool_call:
tool_data = self._current_tool_call tool_data = self._current_tool_call

File diff suppressed because it is too large Load Diff

View File

@ -28,8 +28,10 @@ log = logging.getLogger(__name__)
# ── Helper ───────────────────────────────────────────────────────────────── # ── Helper ─────────────────────────────────────────────────────────────────
def _get_connector(gns3_ctx: dict[str, Any]): def _get_connector(gns3_ctx: dict[str, Any]):
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
return Gns3Connector( return Gns3Connector(
url=gns3_ctx["server_url"], url=gns3_ctx["server_url"],
jwt_token=gns3_ctx["jwt_token"], jwt_token=gns3_ctx["jwt_token"],
@ -41,12 +43,32 @@ def _get_connector(gns3_ctx: dict[str, Any]):
# ── Tool handlers ────────────────────────────────────────────────────────── # ── Tool handlers ──────────────────────────────────────────────────────────
VALID_APPLIANCE_FIELDS = { VALID_APPLIANCE_FIELDS = {
"appliance_id", "name", "category", "description", "vendor_name", "appliance_id",
"vendor_url", "product_name", "product_url", "documentation_url", "name",
"status", "availability", "maintainer", "usage", "symbol", "category",
"images", "versions", "tags", "builtin", "description",
"first_port_name", "port_name_format", "port_segment_size", "vendor_name",
"linked_clone", "docker", "iou", "dynamips", "qemu", "vendor_url",
"product_name",
"product_url",
"documentation_url",
"status",
"availability",
"maintainer",
"usage",
"symbol",
"images",
"versions",
"tags",
"builtin",
"first_port_name",
"port_name_format",
"port_segment_size",
"linked_clone",
"docker",
"iou",
"dynamips",
"qemu",
} }
@ -56,7 +78,7 @@ def get_appliances_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) ->
fields = params.get("fields") fields = params.get("fields")
if fields: if fields:
if not isinstance(fields, list): if not isinstance(fields, list):
return {"error": "fields must be a list, e.g. [\"name\", \"category\"]"} return {"error": 'fields must be a list, e.g. ["name", "category"]'}
invalid = [f for f in fields if f not in VALID_APPLIANCE_FIELDS] invalid = [f for f in fields if f not in VALID_APPLIANCE_FIELDS]
if invalid: if invalid:
return { return {

View File

@ -27,6 +27,7 @@ log = logging.getLogger(__name__)
def _get_connector(gns3_ctx: dict[str, Any]): def _get_connector(gns3_ctx: dict[str, Any]):
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
return Gns3Connector( return Gns3Connector(
url=gns3_ctx["server_url"], url=gns3_ctx["server_url"],
jwt_token=gns3_ctx["jwt_token"], jwt_token=gns3_ctx["jwt_token"],

View File

@ -81,6 +81,7 @@ def _render_template(template: str, device_configs: list[dict], commands_field:
# ── Tool handlers ────────────────────────────────────────────────────────── # ── Tool handlers ──────────────────────────────────────────────────────────
def device_config_send_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> list[dict[str, Any]]: def device_config_send_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> list[dict[str, Any]]:
"""Send configuration commands to network devices via console.""" """Send configuration commands to network devices via console."""
project_id = params.get("project_id") project_id = params.get("project_id")
@ -97,10 +98,12 @@ def device_config_send_handler(params: dict[str, Any], gns3_ctx: dict[str, Any])
from gns3server.agent.gns3_copilot.tools_v2.config_tools_nornir import ExecuteMultipleDeviceConfigCommands from gns3server.agent.gns3_copilot.tools_v2.config_tools_nornir import ExecuteMultipleDeviceConfigCommands
tool = ExecuteMultipleDeviceConfigCommands() tool = ExecuteMultipleDeviceConfigCommands()
input_data = json.dumps({ input_data = json.dumps(
"project_id": project_id, {
"device_configs": device_configs, "project_id": project_id,
}) "device_configs": device_configs,
}
)
return tool._run( return tool._run(
input_data, input_data,
jwt_token=gns3_ctx["jwt_token"], jwt_token=gns3_ctx["jwt_token"],
@ -114,7 +117,12 @@ def device_show_run_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) ->
device_configs = params.get("device_configs") device_configs = params.get("device_configs")
template = params.get("template") template = params.get("template")
if not project_id or not device_configs: if not project_id or not device_configs:
return [{"status": "failed", "error": "project_id and device_configs (list of {device_name, commands}) are required"}] return [
{
"status": "failed",
"error": "project_id and device_configs (list of {device_name, commands}) are required",
}
]
if template: if template:
device_configs = _render_template(template, device_configs, commands_field="commands") device_configs = _render_template(template, device_configs, commands_field="commands")
@ -124,10 +132,12 @@ def device_show_run_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) ->
from gns3server.agent.gns3_copilot.tools_v2.display_tools_nornir import ExecuteMultipleDeviceCommands from gns3server.agent.gns3_copilot.tools_v2.display_tools_nornir import ExecuteMultipleDeviceCommands
tool = ExecuteMultipleDeviceCommands() tool = ExecuteMultipleDeviceCommands()
input_data = json.dumps({ input_data = json.dumps(
"project_id": project_id, {
"device_configs": device_configs, "project_id": project_id,
}) "device_configs": device_configs,
}
)
return tool._run( return tool._run(
input_data, input_data,
jwt_token=gns3_ctx["jwt_token"], jwt_token=gns3_ctx["jwt_token"],
@ -145,10 +155,12 @@ def vpcs_config_set_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) ->
from gns3server.agent.gns3_copilot.tools_v2.vpcs_tools_netmiko import VPCSCommands from gns3server.agent.gns3_copilot.tools_v2.vpcs_tools_netmiko import VPCSCommands
tool = VPCSCommands() tool = VPCSCommands()
input_data = json.dumps({ input_data = json.dumps(
"project_id": project_id, {
"device_configs": device_configs, "project_id": project_id,
}) "device_configs": device_configs,
}
)
return tool._run( return tool._run(
input_data, input_data,
jwt_token=gns3_ctx["jwt_token"], jwt_token=gns3_ctx["jwt_token"],

View File

@ -28,8 +28,10 @@ log = logging.getLogger(__name__)
# ── Helper ───────────────────────────────────────────────────────────────── # ── Helper ─────────────────────────────────────────────────────────────────
def _get_connector(gns3_ctx: dict[str, Any]): def _get_connector(gns3_ctx: dict[str, Any]):
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
return Gns3Connector( return Gns3Connector(
url=gns3_ctx["server_url"], url=gns3_ctx["server_url"],
jwt_token=gns3_ctx["jwt_token"], jwt_token=gns3_ctx["jwt_token"],
@ -40,6 +42,7 @@ def _get_connector(gns3_ctx: dict[str, Any]):
# ── Tool handlers ────────────────────────────────────────────────────────── # ── Tool handlers ──────────────────────────────────────────────────────────
def get_drawings_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: def get_drawings_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
project_id = params.get("project_id") project_id = params.get("project_id")
if not project_id: if not project_id:

View File

@ -28,8 +28,10 @@ log = logging.getLogger(__name__)
# ── Helper ───────────────────────────────────────────────────────────────── # ── Helper ─────────────────────────────────────────────────────────────────
def _get_connector(gns3_ctx: dict[str, Any]): def _get_connector(gns3_ctx: dict[str, Any]):
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
return Gns3Connector( return Gns3Connector(
url=gns3_ctx["server_url"], url=gns3_ctx["server_url"],
jwt_token=gns3_ctx["jwt_token"], jwt_token=gns3_ctx["jwt_token"],
@ -40,6 +42,7 @@ def _get_connector(gns3_ctx: dict[str, Any]):
# ── Tool handlers ────────────────────────────────────────────────────────── # ── Tool handlers ──────────────────────────────────────────────────────────
def get_images_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: def get_images_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
conn = _get_connector(gns3_ctx) conn = _get_connector(gns3_ctx)
images = conn.http_call("get", f"{conn.base_url}/images").json() images = conn.http_call("get", f"{conn.base_url}/images").json()

View File

@ -31,9 +31,11 @@ log = logging.getLogger(__name__)
# ── Helper ───────────────────────────────────────────────────────────────── # ── Helper ─────────────────────────────────────────────────────────────────
def _get_connector(gns3_ctx: dict[str, Any]): def _get_connector(gns3_ctx: dict[str, Any]):
"""Create a Gns3Connector from the GNS3 context dict.""" """Create a Gns3Connector from the GNS3 context dict."""
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
return Gns3Connector( return Gns3Connector(
url=gns3_ctx["server_url"], url=gns3_ctx["server_url"],
jwt_token=gns3_ctx["jwt_token"], jwt_token=gns3_ctx["jwt_token"],
@ -44,6 +46,7 @@ def _get_connector(gns3_ctx: dict[str, Any]):
# ── Tool handlers ────────────────────────────────────────────────────────── # ── Tool handlers ──────────────────────────────────────────────────────────
def list_projects_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: def list_projects_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
conn = _get_connector(gns3_ctx) conn = _get_connector(gns3_ctx)
projects = conn.http_call("get", f"{conn.base_url}/projects").json() projects = conn.http_call("get", f"{conn.base_url}/projects").json()
@ -141,7 +144,12 @@ def get_project_readme_handler(params: dict[str, Any], gns3_ctx: dict[str, Any])
return {"project_id": project_id, "file": "README.txt", "content": content} return {"project_id": project_id, "file": "README.txt", "content": content}
except Exception as e: except Exception as e:
if "404" in str(e): if "404" in str(e):
return {"project_id": project_id, "file": "README.txt", "content": None, "message": "README.txt does not exist yet"} return {
"project_id": project_id,
"file": "README.txt",
"content": None,
"message": "README.txt does not exist yet",
}
raise raise
@ -286,7 +294,10 @@ PROJECT_TOOLS = [
"show_grid": {"type": "boolean", "description": "Show the grid on the drawing area"}, "show_grid": {"type": "boolean", "description": "Show the grid on the drawing area"},
"grid_size": {"type": "integer", "description": "Grid size for the drawing area for nodes"}, "grid_size": {"type": "integer", "description": "Grid size for the drawing area for nodes"},
"drawing_grid_size": {"type": "integer", "description": "Grid size for the drawing area for drawings"}, "drawing_grid_size": {"type": "integer", "description": "Grid size for the drawing area for drawings"},
"show_interface_labels": {"type": "boolean", "description": "Show interface labels on the drawing area"}, "show_interface_labels": {
"type": "boolean",
"description": "Show interface labels on the drawing area",
},
}, },
"required": ["project_id"], "required": ["project_id"],
}, },

View File

@ -28,8 +28,10 @@ log = logging.getLogger(__name__)
# ── Helper ───────────────────────────────────────────────────────────────── # ── Helper ─────────────────────────────────────────────────────────────────
def _get_connector(gns3_ctx: dict[str, Any]): def _get_connector(gns3_ctx: dict[str, Any]):
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
return Gns3Connector( return Gns3Connector(
url=gns3_ctx["server_url"], url=gns3_ctx["server_url"],
jwt_token=gns3_ctx["jwt_token"], jwt_token=gns3_ctx["jwt_token"],
@ -40,6 +42,7 @@ def _get_connector(gns3_ctx: dict[str, Any]):
# ── Tool handlers ────────────────────────────────────────────────────────── # ── Tool handlers ──────────────────────────────────────────────────────────
def get_version_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: def get_version_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
conn = _get_connector(gns3_ctx) conn = _get_connector(gns3_ctx)
return conn.http_call("get", f"{conn.base_url}/version").json() return conn.http_call("get", f"{conn.base_url}/version").json()

View File

@ -28,8 +28,10 @@ log = logging.getLogger(__name__)
# ── Helper ───────────────────────────────────────────────────────────────── # ── Helper ─────────────────────────────────────────────────────────────────
def _get_connector(gns3_ctx: dict[str, Any]): def _get_connector(gns3_ctx: dict[str, Any]):
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
return Gns3Connector( return Gns3Connector(
url=gns3_ctx["server_url"], url=gns3_ctx["server_url"],
jwt_token=gns3_ctx["jwt_token"], jwt_token=gns3_ctx["jwt_token"],
@ -40,6 +42,7 @@ def _get_connector(gns3_ctx: dict[str, Any]):
# ── Tool handlers ────────────────────────────────────────────────────────── # ── Tool handlers ──────────────────────────────────────────────────────────
def get_snapshots_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: def get_snapshots_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
project_id = params.get("project_id") project_id = params.get("project_id")
if not project_id: if not project_id:

View File

@ -30,8 +30,10 @@ log = logging.getLogger(__name__)
# ── Helper ───────────────────────────────────────────────────────────────── # ── Helper ─────────────────────────────────────────────────────────────────
def _get_connector(gns3_ctx: dict[str, Any]): def _get_connector(gns3_ctx: dict[str, Any]):
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
return Gns3Connector( return Gns3Connector(
url=gns3_ctx["server_url"], url=gns3_ctx["server_url"],
jwt_token=gns3_ctx["jwt_token"], jwt_token=gns3_ctx["jwt_token"],
@ -42,6 +44,7 @@ def _get_connector(gns3_ctx: dict[str, Any]):
# ── Tool handlers ────────────────────────────────────────────────────────── # ── Tool handlers ──────────────────────────────────────────────────────────
def get_symbols_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: def get_symbols_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
conn = _get_connector(gns3_ctx) conn = _get_connector(gns3_ctx)
symbols = conn.http_call("get", f"{conn.base_url}/symbols").json() symbols = conn.http_call("get", f"{conn.base_url}/symbols").json()
@ -57,9 +60,11 @@ def get_symbol_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict
username = gns3_ctx.get("jwt_username") username = gns3_ctx.get("jwt_username")
# short-lived ticket bound to this exact path — LLM clients retyping curl # short-lived ticket bound to this exact path — LLM clients retyping curl
# commands corrupted the long Bearer JWT this used to embed # commands corrupted the long Bearer JWT this used to embed
ticket = access_ticket_service.mint( ticket = (
username, token_version=gns3_ctx.get("jwt_token_version", 0), path=path access_ticket_service.mint(username, token_version=gns3_ctx.get("jwt_token_version", 0), path=path)
) if username else None if username
else None
)
if ticket: if ticket:
download_url += f"?token={ticket}" download_url += f"?token={ticket}"
result = { result = {
@ -68,7 +73,7 @@ def get_symbol_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict
"note": "Symbol files are SVG images.", "note": "Symbol files are SVG images.",
} }
if ticket: if ticket:
safe_name = symbol_id.replace(':', '').replace('/', '_') safe_name = symbol_id.replace(":", "").replace("/", "_")
result["curl_command"] = f"curl -L -o '{safe_name}.svg' '{download_url}'" result["curl_command"] = f"curl -L -o '{safe_name}.svg' '{download_url}'"
result["note"] += " The download URL includes a 10-minute ticket." result["note"] += " The download URL includes a 10-minute ticket."
return result return result

View File

@ -31,8 +31,10 @@ log = logging.getLogger(__name__)
# ── Helper ───────────────────────────────────────────────────────────────── # ── Helper ─────────────────────────────────────────────────────────────────
def _get_connector(gns3_ctx: dict[str, Any]): def _get_connector(gns3_ctx: dict[str, Any]):
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
return Gns3Connector( return Gns3Connector(
url=gns3_ctx["server_url"], url=gns3_ctx["server_url"],
jwt_token=gns3_ctx["jwt_token"], jwt_token=gns3_ctx["jwt_token"],
@ -44,9 +46,19 @@ def _get_connector(gns3_ctx: dict[str, Any]):
# ── Tool handlers ────────────────────────────────────────────────────────── # ── Tool handlers ──────────────────────────────────────────────────────────
VALID_TEMPLATE_FIELDS = { VALID_TEMPLATE_FIELDS = {
"template_id", "name", "version", "category", "default_name_format", "template_id",
"symbol", "template_type", "compute_id", "usage", "tags", "builtin", "name",
"created_at", "updated_at", "version",
"category",
"default_name_format",
"symbol",
"template_type",
"compute_id",
"usage",
"tags",
"builtin",
"created_at",
"updated_at",
} }
TEMPLATE_DEFAULT_FIELDS = ["template_id", "name", "template_type", "category", "default_name_format"] TEMPLATE_DEFAULT_FIELDS = ["template_id", "name", "template_type", "category", "default_name_format"]
@ -67,7 +79,7 @@ def list_templates_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) ->
fields = params.get("fields") fields = params.get("fields")
if fields: if fields:
if not isinstance(fields, list): if not isinstance(fields, list):
return {"error": "fields must be a list, e.g. [\"template_id\", \"name\"]"} return {"error": 'fields must be a list, e.g. ["template_id", "name"]'}
invalid = [f for f in fields if f not in VALID_TEMPLATE_FIELDS] invalid = [f for f in fields if f not in VALID_TEMPLATE_FIELDS]
if invalid: if invalid:
return { return {

View File

@ -83,7 +83,7 @@ class DockerHTTPClient:
self._connected = True self._connected = True
# Parse API version # Parse API version
api_version = parse_version(docker_info['ApiVersion']) api_version = parse_version(docker_info["ApiVersion"])
docker_version = docker_info["Version"] docker_version = docker_info["Version"]
logger.info(f"Connected to Docker {docker_version}, API {api_version}") logger.info(f"Connected to Docker {docker_version}, API {api_version}")
@ -102,7 +102,7 @@ class DockerHTTPClient:
logger.info(f"Using Docker API version {self._api_version}") logger.info(f"Using Docker API version {self._api_version}")
else: else:
# Use Docker daemon's actual API version # Use Docker daemon's actual API version
self._api_version = docker_info['ApiVersion'] self._api_version = docker_info["ApiVersion"]
logger.info(f"Using Docker API version {self._api_version} (daemon native)") logger.info(f"Using Docker API version {self._api_version} (daemon native)")
except (aiohttp.ClientError, FileNotFoundError) as e: except (aiohttp.ClientError, FileNotFoundError) as e:
@ -124,7 +124,7 @@ class DockerHTTPClient:
Response JSON data Response JSON data
""" """
# Check connection and version on first request # Check connection and version on first request
check_connection = kwargs.pop('check_connection', True) check_connection = kwargs.pop("check_connection", True)
if check_connection and not self._connected: if check_connection and not self._connected:
await self._check_connection() await self._check_connection()
@ -147,22 +147,21 @@ class DockerHTTPClient:
except RuntimeError as e: except RuntimeError as e:
# Retry with lower API version if Docker daemon doesn't support current version # Retry with lower API version if Docker daemon doesn't support current version
error_msg = str(e) error_msg = str(e)
if ("400" in error_msg or "not found" in error_msg.lower()) and self._api_version == DOCKER_PREFERRED_API_VERSION: if (
logger.warning(f"Docker daemon doesn't support API version {self._api_version}, falling back to {DOCKER_MINIMUM_API_VERSION}") "400" in error_msg or "not found" in error_msg.lower()
) and self._api_version == DOCKER_PREFERRED_API_VERSION:
logger.warning(
f"Docker daemon doesn't support API version {self._api_version}, falling back to {DOCKER_MINIMUM_API_VERSION}"
)
self._api_version = DOCKER_MINIMUM_API_VERSION self._api_version = DOCKER_MINIMUM_API_VERSION
return await self._request(method, endpoint, check_connection=False, **kwargs) return await self._request(method, endpoint, check_connection=False, **kwargs)
raise raise
async def create_network(self, name: str, driver: str = "bridge", subnet: str = None): async def create_network(self, name: str, driver: str = "bridge", subnet: str = None):
"""Create Docker network.""" """Create Docker network."""
data = { data = {"Name": name, "Driver": driver}
"Name": name,
"Driver": driver
}
if subnet: if subnet:
data["IPAM"] = { data["IPAM"] = {"Config": [{"Subnet": subnet}]}
"Config": [{"Subnet": subnet}]
}
await self._request("POST", "networks/create", json=data) await self._request("POST", "networks/create", json=data)
async def get_network(self, name: str): async def get_network(self, name: str):
@ -176,19 +175,12 @@ class DockerHTTPClient:
async def create_container(self, name: str, image: str, **kwargs): async def create_container(self, name: str, image: str, **kwargs):
"""Create container.""" """Create container."""
data = { data = {"Image": image, "name": name, "HostConfig": {}, "NetworkingConfig": {}}
"Image": image,
"name": name,
"HostConfig": {},
"NetworkingConfig": {}
}
# Handle network config # Handle network config
if "network" in kwargs: if "network" in kwargs:
network_name = kwargs.pop("network") network_name = kwargs.pop("network")
data["NetworkingConfig"]["EndpointsConfig"] = { data["NetworkingConfig"]["EndpointsConfig"] = {network_name: {}}
network_name: {}
}
# Handle environment variables # Handle environment variables
if "environment" in kwargs: if "environment" in kwargs:

View File

@ -92,10 +92,7 @@ logger = logging.getLogger(__name__)
def setup_logging(verbose: bool = False): def setup_logging(verbose: bool = False):
"""Setup logging configuration.""" """Setup logging configuration."""
level = logging.DEBUG if verbose else logging.INFO level = logging.DEBUG if verbose else logging.INFO
logging.basicConfig( logging.basicConfig(level=level, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
level=level,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
async def cmd_start(args) -> int: async def cmd_start(args) -> int:
@ -119,7 +116,7 @@ async def cmd_start(args) -> int:
image=args.image, image=args.image,
memory=args.memory, memory=args.memory,
cpus=args.cpus, cpus=args.cpus,
pids_limit=args.pids_limit pids_limit=args.pids_limit,
) )
print(json.dumps(result, indent=2)) print(json.dumps(result, indent=2))
@ -143,10 +140,7 @@ async def cmd_stop(args) -> int:
""" """
manager = WebWiresharkManager() manager = WebWiresharkManager()
try: try:
await manager.stop_wireshark_session( await manager.stop_wireshark_session(project_id=args.project_id, link_id=args.link_id)
project_id=args.project_id,
link_id=args.link_id
)
print(json.dumps({"status": "stopped"})) print(json.dumps({"status": "stopped"}))
return 0 return 0
except Exception as e: except Exception as e:
@ -172,7 +166,7 @@ async def cmd_restart(args) -> int:
project_id=args.project_id, project_id=args.project_id,
link_id=args.link_id, link_id=args.link_id,
jwt_token=args.jwt_token, jwt_token=args.jwt_token,
capture_stream_url=args.capture_url capture_stream_url=args.capture_url,
) )
print(json.dumps(result, indent=2)) print(json.dumps(result, indent=2))
return 0 return 0
@ -274,14 +268,8 @@ async def cmd_delete(args) -> int:
def create_parser() -> argparse.ArgumentParser: def create_parser() -> argparse.ArgumentParser:
"""Create command line argument parser.""" """Create command line argument parser."""
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(description="Web Wireshark container management")
description="Web Wireshark container management" parser.add_argument("--verbose", "-v", action="store_true", help="Enable verbose logging")
)
parser.add_argument(
"--verbose", "-v",
action="store_true",
help="Enable verbose logging"
)
subparsers = parser.add_subparsers(dest="command", help="Commands") subparsers = parser.add_subparsers(dest="command", help="Commands")
@ -290,32 +278,13 @@ def create_parser() -> argparse.ArgumentParser:
start_parser.add_argument("--project-id", required=True, type=validate_uuid, help="Project ID") start_parser.add_argument("--project-id", required=True, type=validate_uuid, help="Project ID")
start_parser.add_argument("--link-id", required=True, type=validate_uuid, help="Link ID") start_parser.add_argument("--link-id", required=True, type=validate_uuid, help="Link ID")
start_parser.add_argument("--jwt-token", required=True, help="JWT token") start_parser.add_argument("--jwt-token", required=True, help="JWT token")
start_parser.add_argument("--capture-url", help="Capture stream URL (auto-detected if not provided)")
start_parser.add_argument( start_parser.add_argument(
"--capture-url", "--image", default="gns3/web-wireshark:latest", help="Docker image (default: gns3/web-wireshark:latest)"
help="Capture stream URL (auto-detected if not provided)"
)
start_parser.add_argument(
"--image",
default="gns3/web-wireshark:latest",
help="Docker image (default: gns3/web-wireshark:latest)"
)
start_parser.add_argument(
"--memory",
default="2g",
help="Memory limit (default: 2g)"
)
start_parser.add_argument(
"--cpus",
type=float,
default=1.0,
help="CPU cores (default: 1.0)"
)
start_parser.add_argument(
"--pids-limit",
type=int,
default=1000,
help="Process limit (default: 1000)"
) )
start_parser.add_argument("--memory", default="2g", help="Memory limit (default: 2g)")
start_parser.add_argument("--cpus", type=float, default=1.0, help="CPU cores (default: 1.0)")
start_parser.add_argument("--pids-limit", type=int, default=1000, help="Process limit (default: 1000)")
# Stop command # Stop command
stop_parser = subparsers.add_parser("stop", help="Stop Web Wireshark session") stop_parser = subparsers.add_parser("stop", help="Stop Web Wireshark session")
@ -327,10 +296,7 @@ def create_parser() -> argparse.ArgumentParser:
restart_parser.add_argument("--project-id", required=True, type=validate_uuid, help="Project ID") restart_parser.add_argument("--project-id", required=True, type=validate_uuid, help="Project ID")
restart_parser.add_argument("--link-id", required=True, type=validate_uuid, help="Link ID") restart_parser.add_argument("--link-id", required=True, type=validate_uuid, help="Link ID")
restart_parser.add_argument("--jwt-token", required=True, help="JWT token") restart_parser.add_argument("--jwt-token", required=True, help="JWT token")
restart_parser.add_argument( restart_parser.add_argument("--capture-url", help="Capture stream URL (auto-detected if not provided)")
"--capture-url",
help="Capture stream URL (auto-detected if not provided)"
)
# Stop all command # Stop all command
stop_all_parser = subparsers.add_parser("stop-all", help="Stop all sessions") stop_all_parser = subparsers.add_parser("stop-all", help="Stop all sessions")

View File

@ -63,15 +63,16 @@ class WebWiresharkManager:
""" """
try: try:
proc = await asyncio.create_subprocess_exec( proc = await asyncio.create_subprocess_exec(
"docker", "exec", container_id, "docker",
"bash", "-c", "echo 'ping'", "exec",
container_id,
"bash",
"-c",
"echo 'ping'",
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE stderr=asyncio.subprocess.PIPE,
)
stdout, _ = await asyncio.wait_for(
proc.communicate(),
timeout=self.CONTAINER_EXEC_TIMEOUT
) )
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=self.CONTAINER_EXEC_TIMEOUT)
return proc.returncode == 0 and b"ping" in stdout return proc.returncode == 0 and b"ping" in stdout
except asyncio.TimeoutError: except asyncio.TimeoutError:
logger.warning(f"Container {container_id[:12]} health check timeout") logger.warning(f"Container {container_id[:12]} health check timeout")
@ -99,16 +100,17 @@ class WebWiresharkManager:
try: try:
proc = await asyncio.create_subprocess_exec( proc = await asyncio.create_subprocess_exec(
"docker", "exec", container_id, "docker",
"bash", "-c", command, "exec",
container_id,
"bash",
"-c",
command,
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE stderr=asyncio.subprocess.PIPE,
) )
try: try:
stdout, stderr = await asyncio.wait_for( stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
proc.communicate(),
timeout=timeout
)
return (proc.returncode, stdout.decode().strip(), stderr.decode().strip()) return (proc.returncode, stdout.decode().strip(), stderr.decode().strip())
except asyncio.TimeoutError: except asyncio.TimeoutError:
logger.error(f"Command timeout after {timeout}s: {command}") logger.error(f"Command timeout after {timeout}s: {command}")
@ -142,7 +144,7 @@ class WebWiresharkManager:
logger.debug(f"No processes found matching pattern '{pattern}'") logger.debug(f"No processes found matching pattern '{pattern}'")
return return
pids = pids_str.replace('\n', ' ') pids = pids_str.replace("\n", " ")
logger.info(f"Found matching processes for '{pattern}': PIDs={pids}") logger.info(f"Found matching processes for '{pattern}': PIDs={pids}")
# Kill the processes using their container-local PIDs # Kill the processes using their container-local PIDs
@ -170,10 +172,13 @@ class WebWiresharkManager:
try: try:
# Get container init PID from host perspective # Get container init PID from host perspective
proc = await asyncio.create_subprocess_exec( proc = await asyncio.create_subprocess_exec(
"docker", "inspect", container_id, "docker",
"--format", "{{.State.Pid}}", "inspect",
container_id,
"--format",
"{{.State.Pid}}",
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE stderr=asyncio.subprocess.PIPE,
) )
stdout, _ = await proc.communicate() stdout, _ = await proc.communicate()
container_init_pid = stdout.decode().strip() container_init_pid = stdout.decode().strip()
@ -187,15 +192,13 @@ class WebWiresharkManager:
# Check for residual processes from host perspective # Check for residual processes from host perspective
proc = await asyncio.create_subprocess_exec( proc = await asyncio.create_subprocess_exec(
"ps", "-eo", "pid,ppid,args", "ps", "-eo", "pid,ppid,args", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
) )
stdout, _ = await proc.communicate() stdout, _ = await proc.communicate()
# Build parent->children mapping # Build parent->children mapping
children_map = {} children_map = {}
for line in stdout.decode().strip().split('\n'): for line in stdout.decode().strip().split("\n"):
parts = line.split(None, 2) parts = line.split(None, 2)
if len(parts) < 3: if len(parts) < 3:
continue continue
@ -218,14 +221,15 @@ class WebWiresharkManager:
# Check if any descendant processes match display patterns # Check if any descendant processes match display patterns
import re import re
patterns = [ patterns = [
f'xpra.*:{display}', f"xpra.*:{display}",
f'Xvfb.*:{display}', f"Xvfb.*:{display}",
f'wireshark.*:{display}', f"wireshark.*:{display}",
f'pulseaudio.*display=:{display}' f"pulseaudio.*display=:{display}",
] ]
for line in stdout.decode().strip().split('\n'): for line in stdout.decode().strip().split("\n"):
parts = line.split(None, 2) parts = line.split(None, 2)
if len(parts) < 3: if len(parts) < 3:
continue continue
@ -248,13 +252,11 @@ class WebWiresharkManager:
# /proc/<pid>/root points to container filesystem # /proc/<pid>/root points to container filesystem
lock_paths = [ lock_paths = [
f"/proc/{container_init_pid}/root/tmp/.X{display}-lock", f"/proc/{container_init_pid}/root/tmp/.X{display}-lock",
f"/proc/{container_init_pid}/root/tmp/.X11-unix/X{display}" f"/proc/{container_init_pid}/root/tmp/.X11-unix/X{display}",
] ]
for lock_path in lock_paths: for lock_path in lock_paths:
proc = await asyncio.create_subprocess_exec( proc = await asyncio.create_subprocess_exec(
"test", "-e", lock_path, "test", "-e", lock_path, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
) )
await proc.communicate() await proc.communicate()
if proc.returncode == 0: if proc.returncode == 0:
@ -264,9 +266,7 @@ class WebWiresharkManager:
# Check for xpra socket files # Check for xpra socket files
socket_path = f"/proc/{container_init_pid}/root/run/user/1000/xpra/{display}/socket" socket_path = f"/proc/{container_init_pid}/root/run/user/1000/xpra/{display}/socket"
proc = await asyncio.create_subprocess_exec( proc = await asyncio.create_subprocess_exec(
"test", "-e", socket_path, "test", "-e", socket_path, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
) )
await proc.communicate() await proc.communicate()
if proc.returncode == 0: if proc.returncode == 0:
@ -298,10 +298,13 @@ class WebWiresharkManager:
try: try:
# Get container init PID from host perspective # Get container init PID from host perspective
proc = await asyncio.create_subprocess_exec( proc = await asyncio.create_subprocess_exec(
"docker", "inspect", container_id, "docker",
"--format", "{{.State.Pid}}", "inspect",
container_id,
"--format",
"{{.State.Pid}}",
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE stderr=asyncio.subprocess.PIPE,
) )
stdout, _ = await proc.communicate() stdout, _ = await proc.communicate()
container_init_pid = stdout.decode().strip() container_init_pid = stdout.decode().strip()
@ -314,9 +317,7 @@ class WebWiresharkManager:
# List all processes from host perspective with PID, PPID, and command # List all processes from host perspective with PID, PPID, and command
# This is faster than docker exec and allows us to walk the process tree # This is faster than docker exec and allows us to walk the process tree
proc = await asyncio.create_subprocess_exec( proc = await asyncio.create_subprocess_exec(
"ps", "-eo", "pid,ppid,args", "ps", "-eo", "pid,ppid,args", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
) )
stdout, _ = await proc.communicate() stdout, _ = await proc.communicate()
@ -328,7 +329,7 @@ class WebWiresharkManager:
children_map = {} # ppid -> [pid] children_map = {} # ppid -> [pid]
process_info = {} # pid -> (ppid, command) process_info = {} # pid -> (ppid, command)
for line in stdout.decode().strip().split('\n'): for line in stdout.decode().strip().split("\n"):
parts = line.split(None, 2) parts = line.split(None, 2)
if len(parts) < 3: if len(parts) < 3:
continue continue
@ -371,9 +372,7 @@ class WebWiresharkManager:
logger.info(f"Killing {len(matching_pids)} processes: {matching_pids}") logger.info(f"Killing {len(matching_pids)} processes: {matching_pids}")
# Kill all matching PIDs from host perspective # Kill all matching PIDs from host perspective
proc = await asyncio.create_subprocess_exec( proc = await asyncio.create_subprocess_exec(
"kill", "-9", *matching_pids, "kill", "-9", *matching_pids, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
) )
await proc.communicate() await proc.communicate()
logger.debug("Kill completed from host perspective") logger.debug("Kill completed from host perspective")
@ -396,16 +395,12 @@ class WebWiresharkManager:
""" """
try: try:
# Combine all patterns into a single regex using OR operator # Combine all patterns into a single regex using OR operator
combined_pattern = '|'.join(f'({pattern})' for pattern in patterns) combined_pattern = "|".join(f"({pattern})" for pattern in patterns)
# Single pgrep to find all matching processes # Single pgrep to find all matching processes
pgrep_cmd = f'pids=$(pgrep -f "{combined_pattern}" 2>/dev/null || true); if [ -n "$pids" ]; then echo "Found processes: $pids"; kill -9 $pids 2>/dev/null || true; fi' pgrep_cmd = f'pids=$(pgrep -f "{combined_pattern}" 2>/dev/null || true); if [ -n "$pids" ]; then echo "Found processes: $pids"; kill -9 $pids 2>/dev/null || true; fi'
returncode, stdout, stderr = await self._exec_in_container( returncode, stdout, stderr = await self._exec_in_container(container_id, pgrep_cmd, timeout=5)
container_id,
pgrep_cmd,
timeout=5
)
if "Found processes:" in stdout: if "Found processes:" in stdout:
logger.info(stdout.strip()) logger.info(stdout.strip())
@ -422,10 +417,12 @@ class WebWiresharkManager:
""" """
# Clean up X lock files and xpra socket files in a single docker exec # Clean up X lock files and xpra socket files in a single docker exec
# This reduces docker exec calls from 2 to 1, improving stop performance # This reduces docker exec calls from 2 to 1, improving stop performance
cmd = (f"rm -f /tmp/.X{display}-lock /tmp/.X11-unix/X{display} " cmd = (
f"/run/user/1000/xpra/{display}/socket " f"rm -f /tmp/.X{display}-lock /tmp/.X11-unix/X{display} "
f"/run/user/1000/xpra/*-{display} " f"/run/user/1000/xpra/{display}/socket "
f"/home/gns3/.xpra/*-{display} 2>/dev/null || true") f"/run/user/1000/xpra/*-{display} "
f"/home/gns3/.xpra/*-{display} 2>/dev/null || true"
)
returncode, stdout, stderr = await self._exec_in_container(container_id, cmd) returncode, stdout, stderr = await self._exec_in_container(container_id, cmd)
logger.debug(f"Cleanup X locks and xpra sockets: returncode={returncode}") logger.debug(f"Cleanup X locks and xpra sockets: returncode={returncode}")
@ -436,9 +433,11 @@ class WebWiresharkManager:
container_id: Container ID container_id: Container ID
display: Display number (e.g., 10210) display: Display number (e.g., 10210)
""" """
cmd = (f"rm -f /run/user/1000/xpra/{display}/socket " cmd = (
f"/run/user/1000/xpra/*-{display} " f"rm -f /run/user/1000/xpra/{display}/socket "
f"/home/gns3/.xpra/*-{display} 2>/dev/null || true") f"/run/user/1000/xpra/*-{display} "
f"/home/gns3/.xpra/*-{display} 2>/dev/null || true"
)
returncode, stdout, stderr = await self._exec_in_container(container_id, cmd) returncode, stdout, stderr = await self._exec_in_container(container_id, cmd)
logger.debug(f"Cleanup socket files: returncode={returncode}") logger.debug(f"Cleanup socket files: returncode={returncode}")
@ -457,20 +456,15 @@ class WebWiresharkManager:
return 0 return 0
# Extract number and unit # Extract number and unit
match = re.match(r'(\d+(?:\.\d+)?)\s*([kmg]b?)?', memory_str) match = re.match(r"(\d+(?:\.\d+)?)\s*([kmg]b?)?", memory_str)
if not match: if not match:
raise ValueError(f"Invalid memory format: {memory_str}") raise ValueError(f"Invalid memory format: {memory_str}")
value = float(match.group(1)) value = float(match.group(1))
unit = match.group(2) or 'b' unit = match.group(2) or "b"
# Convert to bytes # Convert to bytes
multipliers = { multipliers = {"b": 1, "k": 1024, "m": 1024 * 1024, "g": 1024 * 1024 * 1024}
'b': 1,
'k': 1024,
'm': 1024 * 1024,
'g': 1024 * 1024 * 1024
}
unit = unit[0] # Take first character unit = unit[0] # Take first character
return int(value * multipliers.get(unit, 1)) return int(value * multipliers.get(unit, 1))
@ -511,8 +505,7 @@ class WebWiresharkManager:
if container_id: if container_id:
try: try:
returncode, stdout, stderr = await self._exec_in_container( returncode, stdout, stderr = await self._exec_in_container(
container_id, container_id, "cat /proc/net/route | grep -E '^eth0\\s+00000000' | awk '{print $3}' | head -1"
"cat /proc/net/route | grep -E '^eth0\\s+00000000' | awk '{print $3}' | head -1"
) )
logger.info(f"Gateway detection - fallback method: returncode={returncode}, stdout='{stdout}'") logger.info(f"Gateway detection - fallback method: returncode={returncode}, stdout='{stdout}'")
if returncode == 0 and stdout.strip(): if returncode == 0 and stdout.strip():
@ -557,8 +550,7 @@ class WebWiresharkManager:
# Use 'hostname -I' to get all IP addresses and take the first one # Use 'hostname -I' to get all IP addresses and take the first one
# This works on most Linux systems and is more portable than 'ip' command # This works on most Linux systems and is more portable than 'ip' command
returncode, stdout, stderr = await self._exec_in_container( returncode, stdout, stderr = await self._exec_in_container(
container_id, container_id, "hostname -I 2>/dev/null | awk '{print $1}'"
"hostname -I 2>/dev/null | awk '{print $1}'"
) )
if returncode == 0 and stdout.strip(): if returncode == 0 and stdout.strip():
container_ip = stdout.strip() container_ip = stdout.strip()
@ -585,7 +577,7 @@ class WebWiresharkManager:
""" """
try: try:
parsed = urlparse(url) parsed = urlparse(url)
if parsed.hostname in ('localhost', '127.0.0.1', '0.0.0.0'): if parsed.hostname in ("localhost", "127.0.0.1", "0.0.0.0"):
gateway = await self._get_container_gateway_ip(container_id) gateway = await self._get_container_gateway_ip(container_id)
if gateway: if gateway:
fixed_url = f"{parsed.scheme}://{gateway}:{parsed.port or 3080}{parsed.path}" fixed_url = f"{parsed.scheme}://{gateway}:{parsed.port or 3080}{parsed.path}"
@ -627,11 +619,7 @@ class WebWiresharkManager:
config = Config.instance() config = Config.instance()
subnet = getattr(config.settings.WebWireshark, "network_subnet", "172.31.0.0/22") subnet = getattr(config.settings.WebWireshark, "network_subnet", "172.31.0.0/22")
logger.info(f"Creating network {self.network_name} with subnet {subnet}") logger.info(f"Creating network {self.network_name} with subnet {subnet}")
await self.docker.create_network( await self.docker.create_network(self.network_name, driver="bridge", subnet=subnet)
self.network_name,
driver="bridge",
subnet=subnet
)
async def get_or_create_container( async def get_or_create_container(
self, self,
@ -640,7 +628,7 @@ class WebWiresharkManager:
memory: str = "2g", memory: str = "2g",
memory_swap: str = None, memory_swap: str = None,
cpus: float = 1.0, cpus: float = 1.0,
pids_limit: int = 1000 pids_limit: int = 1000,
) -> str: ) -> str:
"""Get or create project's Web Wireshark container. """Get or create project's Web Wireshark container.
@ -716,21 +704,15 @@ class WebWiresharkManager:
"PidsLimit": pids_limit, "PidsLimit": pids_limit,
"RestartPolicy": {"Name": "unless-stopped"}, "RestartPolicy": {"Name": "unless-stopped"},
"Init": True, # Use init system (tini) as PID 1 to reap zombie processes "Init": True, # Use init system (tini) as PID 1 to reap zombie processes
"LogConfig": { "LogConfig": {"Type": "json-file", "Config": {"max-size": "10m", "max-file": "3"}},
"Type": "json-file",
"Config": {
"max-size": "10m",
"max-file": "3"
}
}
} }
# Health check configuration # Health check configuration
health_config = { health_config = {
"Test": ["CMD-SHELL", "xpra list"], "Test": ["CMD-SHELL", "xpra list"],
"Interval": 30000000000, # 30 seconds (nanoseconds) "Interval": 30000000000, # 30 seconds (nanoseconds)
"Timeout": 10000000000, # 10 seconds "Timeout": 10000000000, # 10 seconds
"Retries": 3 "Retries": 3,
} }
try: try:
@ -740,11 +722,7 @@ class WebWiresharkManager:
network=self.network_name, network=self.network_name,
host_config=host_config, host_config=host_config,
health_config=health_config, health_config=health_config,
environment={ environment={"XDG_RUNTIME_DIR": "/run/user/1000", "LANG": "C.UTF-8", "LC_ALL": "C.UTF-8"},
"XDG_RUNTIME_DIR": "/run/user/1000",
"LANG": "C.UTF-8",
"LC_ALL": "C.UTF-8"
}
) )
except RuntimeError as e: except RuntimeError as e:
error_msg = str(e) error_msg = str(e)
@ -773,7 +751,7 @@ class WebWiresharkManager:
memory: str = "2g", memory: str = "2g",
memory_swap: Optional[str] = None, memory_swap: Optional[str] = None,
cpus: float = 1.0, cpus: float = 1.0,
pids_limit: int = 1000 pids_limit: int = 1000,
) -> dict: ) -> dict:
"""Start Web Wireshark session. """Start Web Wireshark session.
@ -799,20 +777,10 @@ class WebWiresharkManager:
# Auto-detect capture_stream_url if not provided # Auto-detect capture_stream_url if not provided
if not capture_stream_url: if not capture_stream_url:
gns3_url = self.detect_gns3_url() gns3_url = self.detect_gns3_url()
capture_stream_url = ( capture_stream_url = f"{gns3_url}/v3/projects/{project_id}/links/{link_id}/capture/stream"
f"{gns3_url}/v3/projects/{project_id}/links/"
f"{link_id}/capture/stream"
)
logger.info(f"Auto-detected capture stream URL: {capture_stream_url}") logger.info(f"Auto-detected capture stream URL: {capture_stream_url}")
container_id = await self.get_or_create_container( container_id = await self.get_or_create_container(project_id, image, memory, memory_swap, cpus, pids_limit)
project_id,
image,
memory,
memory_swap,
cpus,
pids_limit
)
container_name = f"gns3-wireshark-{project_id}" container_name = f"gns3-wireshark-{project_id}"
# Fix localhost URL after we have container_id # Fix localhost URL after we have container_id
@ -828,13 +796,15 @@ class WebWiresharkManager:
has_process_residuals, has_socket_residuals = await self._check_residuals_exist(container_id, display) has_process_residuals, has_socket_residuals = await self._check_residuals_exist(container_id, display)
if has_process_residuals or has_socket_residuals: if has_process_residuals or has_socket_residuals:
logger.info(f"Found residual processes={has_process_residuals} sockets={has_socket_residuals} on display :{display}, cleaning up...") logger.info(
f"Found residual processes={has_process_residuals} sockets={has_socket_residuals} on display :{display}, cleaning up..."
)
if has_process_residuals: if has_process_residuals:
patterns = [ patterns = [
f'xpra.*:{display}', f"xpra.*:{display}",
f'Xvfb.*:{display}', f"Xvfb.*:{display}",
f'wireshark.*:{display}', f"wireshark.*:{display}",
f'pulseaudio.*display=:{display}' f"pulseaudio.*display=:{display}",
] ]
await self._kill_process_tree_batch(container_id, patterns) await self._kill_process_tree_batch(container_id, patterns)
if has_socket_residuals: if has_socket_residuals:
@ -848,29 +818,24 @@ class WebWiresharkManager:
xpra_cmd = [ xpra_cmd = [
"XPRA_CLIENT_CAN_SHUTDOWN=false", "XPRA_CLIENT_CAN_SHUTDOWN=false",
"xpra", "start", f":{display}", "xpra",
"start",
f":{display}",
'--xvfb="Xvfb -screen 0 1920x1080x24 +extension RANDR"', '--xvfb="Xvfb -screen 0 1920x1080x24 +extension RANDR"',
"--html=off", "--html=off",
f"--bind-ws=0.0.0.0:{port}", f"--bind-ws=0.0.0.0:{port}",
f"--session-name={session_name}", f"--session-name={session_name}",
"--daemon=yes", "--daemon=yes",
"--dbus-launch=no", "--dbus-launch=no",
"--resize-display=yes" "--resize-display=yes",
] ]
# Parallel execution: get container info + start xpra # Parallel execution: get container info + start xpra
container_info_task = asyncio.create_task( container_info_task = asyncio.create_task(self.docker.get_container(container_name))
self.docker.get_container(container_name) xpra_start_task = asyncio.create_task(self._exec_in_container(container_id, " ".join(xpra_cmd)))
)
xpra_start_task = asyncio.create_task(
self._exec_in_container(container_id, " ".join(xpra_cmd))
)
# Wait for both tasks to complete # Wait for both tasks to complete
container, (returncode, stdout, stderr) = await asyncio.gather( container, (returncode, stdout, stderr) = await asyncio.gather(container_info_task, xpra_start_task)
container_info_task,
xpra_start_task
)
# Check xpra start result # Check xpra start result
if returncode != 0: if returncode != 0:
@ -901,10 +866,14 @@ class WebWiresharkManager:
# Execute Wireshark command without waiting for completion # Execute Wireshark command without waiting for completion
await asyncio.create_subprocess_exec( await asyncio.create_subprocess_exec(
"docker", "exec", container_id, "docker",
"bash", "-c", wireshark_cmd, "exec",
container_id,
"bash",
"-c",
wireshark_cmd,
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE stderr=asyncio.subprocess.PIPE,
) )
# Don't wait - Wireshark runs in background # Don't wait - Wireshark runs in background
@ -918,7 +887,7 @@ class WebWiresharkManager:
"container_name": container_name, "container_name": container_name,
"container_id": container_id, "container_id": container_id,
"session_name": session_name, "session_name": session_name,
"capture_stream_url": capture_stream_url "capture_stream_url": capture_stream_url,
} }
logger.info(f"Web Wireshark session started successfully: {result['ws_url']}") logger.info(f"Web Wireshark session started successfully: {result['ws_url']}")
@ -947,11 +916,11 @@ class WebWiresharkManager:
# This ensures child processes are properly terminated, not left as zombies # This ensures child processes are properly terminated, not left as zombies
logger.info(f"Stopping all processes on display :{display}") logger.info(f"Stopping all processes on display :{display}")
patterns = [ patterns = [
f'xpra.*:{display}', f"xpra.*:{display}",
f'Xvfb.*:{display}', f"Xvfb.*:{display}",
f'Xvfb-for-Xpra-{display}', f"Xvfb-for-Xpra-{display}",
f'wireshark.*:{display}', f"wireshark.*:{display}",
f'pulseaudio.*display=:{display}' f"pulseaudio.*display=:{display}",
] ]
await self._kill_process_tree_batch(container["Id"], patterns) await self._kill_process_tree_batch(container["Id"], patterns)
await self._cleanup_x_lock(container["Id"], display) await self._cleanup_x_lock(container["Id"], display)
@ -971,7 +940,7 @@ class WebWiresharkManager:
memory: str = "2g", memory: str = "2g",
memory_swap: str = None, memory_swap: str = None,
cpus: float = 1.0, cpus: float = 1.0,
pids_limit: int = 1000 pids_limit: int = 1000,
): ):
"""Restart Web Wireshark session. """Restart Web Wireshark session.
@ -1001,7 +970,7 @@ class WebWiresharkManager:
memory=memory, memory=memory,
memory_swap=memory_swap, memory_swap=memory_swap,
cpus=cpus, cpus=cpus,
pids_limit=pids_limit pids_limit=pids_limit,
) )
async def stop_all_sessions(self, project_id: str): async def stop_all_sessions(self, project_id: str):
@ -1021,12 +990,7 @@ class WebWiresharkManager:
# Kill all wireshark, xpra and Xvfb processes for link sessions # Kill all wireshark, xpra and Xvfb processes for link sessions
# This ensures clean removal of all session processes # This ensures clean removal of all session processes
patterns = [ patterns = ["xpra.*--session-name=link-", "Xvfb-for-Xpra-", "wireshark.*display :", "pulseaudio.*display :"]
"xpra.*--session-name=link-",
"Xvfb-for-Xpra-",
"wireshark.*display :",
"pulseaudio.*display :"
]
await self._kill_process_tree_batch(container["Id"], patterns) await self._kill_process_tree_batch(container["Id"], patterns)
logger.info(f"All Web Wireshark sessions stopped for project {project_id}") logger.info(f"All Web Wireshark sessions stopped for project {project_id}")

View File

@ -62,11 +62,7 @@ def check_docker():
# Check if Docker daemon is running # Check if Docker daemon is running
try: try:
result = subprocess.run( result = subprocess.run(["docker", "info"], capture_output=True, text=True)
["docker", "info"],
capture_output=True,
text=True
)
if result.returncode != 0: if result.returncode != 0:
print("Error: Docker daemon is not running", file=sys.stderr) print("Error: Docker daemon is not running", file=sys.stderr)
print("Please start Docker and try again", file=sys.stderr) print("Please start Docker and try again", file=sys.stderr)
@ -82,11 +78,7 @@ def image_exists():
"""Check if the Docker image already exists.""" """Check if the Docker image already exists."""
try: try:
result = subprocess.run( result = subprocess.run(["docker", "image", "inspect", DOCKER_IMAGE], capture_output=True, text=True)
["docker", "image", "inspect", DOCKER_IMAGE],
capture_output=True,
text=True
)
return result.returncode == 0 return result.returncode == 0
except Exception: except Exception:
return False return False
@ -115,11 +107,7 @@ def pull_image():
print(f"Pulling Docker image: {DOCKER_IMAGE}") print(f"Pulling Docker image: {DOCKER_IMAGE}")
print("-" * 60) print("-" * 60)
result = subprocess.run( result = subprocess.run(["docker", "pull", DOCKER_IMAGE], capture_output=True, text=True)
["docker", "pull", DOCKER_IMAGE],
capture_output=True,
text=True
)
# Store output for network error detection # Store output for network error detection
pull_output = result.stdout + result.stderr pull_output = result.stdout + result.stderr
@ -140,33 +128,17 @@ def build_image(dockerfile_path):
print("-" * 60) print("-" * 60)
result = subprocess.run( result = subprocess.run(
["docker", "build", "-t", DOCKER_IMAGE, "-f", dockerfile_path, "."], ["docker", "build", "-t", DOCKER_IMAGE, "-f", dockerfile_path, "."], cwd=dockerfile_dir, pass_fds=(1, 2)
cwd=dockerfile_dir,
pass_fds=(1, 2)
) )
return result.returncode == 0 return result.returncode == 0
def main(): def main():
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(description="Setup Web Wireshark Docker image for GNS3")
description="Setup Web Wireshark Docker image for GNS3" parser.add_argument("--force", action="store_true", help="Force rebuild even if image exists")
) parser.add_argument("--build-only", action="store_true", help="Only build locally, skip pull")
parser.add_argument( parser.add_argument("--pull-only", action="store_true", help="Only pull from registry, skip build")
"--force",
action="store_true",
help="Force rebuild even if image exists"
)
parser.add_argument(
"--build-only",
action="store_true",
help="Only build locally, skip pull"
)
parser.add_argument(
"--pull-only",
action="store_true",
help="Only pull from registry, skip build"
)
args = parser.parse_args() args = parser.parse_args()
@ -183,7 +155,7 @@ def main():
if image_exists() and not args.force: if image_exists() and not args.force:
print(f"Image {DOCKER_IMAGE} already exists.") print(f"Image {DOCKER_IMAGE} already exists.")
response = input("Do you want to rebuild it? [y/N]: ").strip().lower() response = input("Do you want to rebuild it? [y/N]: ").strip().lower()
if response != 'y': if response != "y":
print("Setup cancelled.") print("Setup cancelled.")
sys.exit(0) sys.exit(0)
print() print()

View File

@ -30,12 +30,7 @@ async def collect_webwireshark_stats(projects: List) -> Dict:
""" """
from .manager import WebWiresharkManager from .manager import WebWiresharkManager
stats = { stats = {"total_containers": 0, "running_containers": 0, "active_sessions": 0, "containers": []}
"total_containers": 0,
"running_containers": 0,
"active_sessions": 0,
"containers": []
}
# Create a single manager instance and reuse it # Create a single manager instance and reuse it
manager = WebWiresharkManager() manager = WebWiresharkManager()
@ -70,15 +65,14 @@ async def collect_webwireshark_stats(projects: List) -> Dict:
pids_limit = host_config.get("PidsLimit", 0) pids_limit = host_config.get("PidsLimit", 0)
# Count active capture sessions # Count active capture sessions
active_sessions = sum( active_sessions = sum(1 for link in project.links.values() if getattr(link, "capturing", False))
1 for link in project.links.values()
if getattr(link, "capturing", False)
)
stats["active_sessions"] += active_sessions stats["active_sessions"] += active_sessions
container_info["active_sessions"] = active_sessions container_info["active_sessions"] = active_sessions
# Add resource limits # Add resource limits
container_info["memory_limit"] = f"{memory_limit / (1024**3):.1f} GB" if memory_limit > 0 else "unlimited" container_info["memory_limit"] = (
f"{memory_limit / (1024**3):.1f} GB" if memory_limit > 0 else "unlimited"
)
container_info["cpu_limit"] = f"{cpu_quota / 1000000000:.1f}" if cpu_quota > 0 else "unlimited" container_info["cpu_limit"] = f"{cpu_quota / 1000000000:.1f}" if cpu_quota > 0 else "unlimited"
container_info["pids_limit"] = pids_limit if pids_limit > 0 else "unlimited" container_info["pids_limit"] = pids_limit if pids_limit > 0 else "unlimited"
@ -111,21 +105,15 @@ async def _get_container_resource_stats(container_id: str) -> Optional[Dict]:
""" """
try: try:
result = subprocess.run( result = subprocess.run(
["docker", "stats", "--no-stream", "--format", ["docker", "stats", "--no-stream", "--format", "{{.MemUsage}}\t{{.CPUPerc}}\t{{.PIDs}}", container_id],
"{{.MemUsage}}\t{{.CPUPerc}}\t{{.PIDs}}",
container_id],
capture_output=True, capture_output=True,
text=True, text=True,
timeout=2 timeout=2,
) )
if result.returncode == 0: if result.returncode == 0:
parts = result.stdout.strip().split("\t") parts = result.stdout.strip().split("\t")
if len(parts) >= 3: if len(parts) >= 3:
return { return {"memory": parts[0], "cpu": parts[1], "pids": int(parts[2])}
"memory": parts[0],
"cpu": parts[1],
"pids": int(parts[2])
}
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
logger.debug(f"Docker stats timeout for container {container_id[:12]}") logger.debug(f"Docker stats timeout for container {container_id[:12]}")
except Exception as e: except Exception as e:

View File

@ -155,113 +155,71 @@ async def http_exception_handler(request: Request, exc: StarletteHTTPException):
) )
compute_api.include_router( compute_api.include_router(capabilities.router, dependencies=[Depends(compute_authentication)], tags=["Capabilities"])
capabilities.router,
dependencies=[Depends(compute_authentication)],
tags=["Capabilities"]
)
compute_api.include_router( compute_api.include_router(compute.router, dependencies=[Depends(compute_authentication)], tags=["Compute"])
compute.router,
dependencies=[Depends(compute_authentication)],
tags=["Compute"]
)
compute_api.include_router( compute_api.include_router(notifications.router, tags=["Notifications"])
notifications.router,
tags=["Notifications"]
)
compute_api.include_router( compute_api.include_router(projects.router, dependencies=[Depends(compute_authentication)], tags=["Projects"])
projects.router,
dependencies=[Depends(compute_authentication)],
tags=["Projects"]
)
compute_api.include_router( compute_api.include_router(images.router, dependencies=[Depends(compute_authentication)], tags=["Images"])
images.router,
dependencies=[Depends(compute_authentication)],
tags=["Images"]
)
compute_api.include_router( compute_api.include_router(
atm_switch_nodes.router, atm_switch_nodes.router,
dependencies=[Depends(compute_authentication)], dependencies=[Depends(compute_authentication)],
prefix="/projects/{project_id}/atm_switch/nodes", prefix="/projects/{project_id}/atm_switch/nodes",
tags=["ATM switch"] tags=["ATM switch"],
) )
compute_api.include_router( compute_api.include_router(
cloud_nodes.router, cloud_nodes.router,
dependencies=[Depends(compute_authentication)], dependencies=[Depends(compute_authentication)],
prefix="/projects/{project_id}/cloud/nodes", prefix="/projects/{project_id}/cloud/nodes",
tags=["Cloud nodes"] tags=["Cloud nodes"],
) )
compute_api.include_router( compute_api.include_router(docker_nodes.router, prefix="/projects/{project_id}/docker/nodes", tags=["Docker nodes"])
docker_nodes.router,
prefix="/projects/{project_id}/docker/nodes",
tags=["Docker nodes"]
)
compute_api.include_router( compute_api.include_router(
dynamips_nodes.router, dynamips_nodes.router, prefix="/projects/{project_id}/dynamips/nodes", tags=["Dynamips nodes"]
prefix="/projects/{project_id}/dynamips/nodes",
tags=["Dynamips nodes"]
) )
compute_api.include_router( compute_api.include_router(
ethernet_hub_nodes.router, ethernet_hub_nodes.router,
dependencies=[Depends(compute_authentication)], dependencies=[Depends(compute_authentication)],
prefix="/projects/{project_id}/ethernet_hub/nodes", prefix="/projects/{project_id}/ethernet_hub/nodes",
tags=["Ethernet hub nodes"] tags=["Ethernet hub nodes"],
) )
compute_api.include_router( compute_api.include_router(
ethernet_switch_nodes.router, ethernet_switch_nodes.router,
dependencies=[Depends(compute_authentication)], dependencies=[Depends(compute_authentication)],
prefix="/projects/{project_id}/ethernet_switch/nodes", prefix="/projects/{project_id}/ethernet_switch/nodes",
tags=["Ethernet switch nodes"] tags=["Ethernet switch nodes"],
) )
compute_api.include_router( compute_api.include_router(
frame_relay_switch_nodes.router, frame_relay_switch_nodes.router,
dependencies=[Depends(compute_authentication)], dependencies=[Depends(compute_authentication)],
prefix="/projects/{project_id}/frame_relay_switch/nodes", prefix="/projects/{project_id}/frame_relay_switch/nodes",
tags=["Frame Relay switch nodes"] tags=["Frame Relay switch nodes"],
) )
compute_api.include_router( compute_api.include_router(iou_nodes.router, prefix="/projects/{project_id}/iou/nodes", tags=["IOU nodes"])
iou_nodes.router,
prefix="/projects/{project_id}/iou/nodes",
tags=["IOU nodes"])
compute_api.include_router( compute_api.include_router(
nat_nodes.router, nat_nodes.router,
dependencies=[Depends(compute_authentication)], dependencies=[Depends(compute_authentication)],
prefix="/projects/{project_id}/nat/nodes", prefix="/projects/{project_id}/nat/nodes",
tags=["NAT nodes"] tags=["NAT nodes"],
) )
compute_api.include_router( compute_api.include_router(qemu_nodes.router, prefix="/projects/{project_id}/qemu/nodes", tags=["Qemu nodes"])
qemu_nodes.router,
prefix="/projects/{project_id}/qemu/nodes",
tags=["Qemu nodes"]
)
compute_api.include_router( compute_api.include_router(
virtualbox_nodes.router, virtualbox_nodes.router, prefix="/projects/{project_id}/virtualbox/nodes", tags=["VirtualBox nodes"]
prefix="/projects/{project_id}/virtualbox/nodes",
tags=["VirtualBox nodes"]
) )
compute_api.include_router( compute_api.include_router(vmware_nodes.router, prefix="/projects/{project_id}/vmware/nodes", tags=["VMware nodes"])
vmware_nodes.router,
prefix="/projects/{project_id}/vmware/nodes",
tags=["VMware nodes"]
)
compute_api.include_router( compute_api.include_router(vpcs_nodes.router, prefix="/projects/{project_id}/vpcs/nodes", tags=["VPCS nodes"])
vpcs_nodes.router,
prefix="/projects/{project_id}/vpcs/nodes",
tags=["VPCS nodes"]
)

View File

@ -79,8 +79,7 @@ def get_atm_switch(node: ATMSwitch = Depends(dep_node)) -> schemas.ATMSwitch:
@router.post("/{node_id}/duplicate", response_model=schemas.ATMSwitch, status_code=status.HTTP_201_CREATED) @router.post("/{node_id}/duplicate", response_model=schemas.ATMSwitch, status_code=status.HTTP_201_CREATED)
async def duplicate_atm_switch( async def duplicate_atm_switch(
destination_node_id: UUID = Body(..., embed=True), destination_node_id: UUID = Body(..., embed=True), node: ATMSwitch = Depends(dep_node)
node: ATMSwitch = Depends(dep_node)
) -> schemas.ATMSwitch: ) -> schemas.ATMSwitch:
""" """
Duplicate an ATM switch node. Duplicate an ATM switch node.
@ -92,8 +91,7 @@ async def duplicate_atm_switch(
@router.put("/{node_id}", response_model=schemas.ATMSwitch) @router.put("/{node_id}", response_model=schemas.ATMSwitch)
async def update_atm_switch( async def update_atm_switch(
node_data: schemas.ATMSwitchUpdate, node_data: schemas.ATMSwitchUpdate, node: ATMSwitch = Depends(dep_node)
node: ATMSwitch = Depends(dep_node)
) -> schemas.ATMSwitch: ) -> schemas.ATMSwitch:
""" """
Update an ATM switch node. Update an ATM switch node.
@ -124,8 +122,7 @@ def start_atm_switch(node: ATMSwitch = Depends(dep_node)) -> None:
""" """
raise HTTPException( raise HTTPException(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED, status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Start is not supported for ATM switches"
detail="Start is not supported for ATM switches"
) )
@ -135,10 +132,7 @@ def stop_atm_switch(node: ATMSwitch = Depends(dep_node)) -> None:
Stop an ATM switch node. Stop an ATM switch node.
""" """
raise HTTPException( raise HTTPException(status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Stop is not supported for ATM switches")
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
detail="Stop is not supported for ATM switches"
)
@router.post("/{node_id}/suspend", status_code=status.HTTP_204_NO_CONTENT) @router.post("/{node_id}/suspend", status_code=status.HTTP_204_NO_CONTENT)
@ -149,8 +143,7 @@ def suspend_atm_switch(node: ATMSwitch = Depends(dep_node)) -> None:
""" """
raise HTTPException( raise HTTPException(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED, status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Suspend is not supported for ATM switches"
detail="Suspend is not supported for ATM switches"
) )
@ -160,11 +153,11 @@ def suspend_atm_switch(node: ATMSwitch = Depends(dep_node)) -> None:
response_model=schemas.UDPNIO, response_model=schemas.UDPNIO,
) )
async def create_nio( async def create_nio(
*, *,
adapter_number: int = Path(..., ge=0, le=0), adapter_number: int = Path(..., ge=0, le=0),
port_number: int, port_number: int,
nio_data: schemas.UDPNIO, nio_data: schemas.UDPNIO,
node: ATMSwitch = Depends(dep_node) node: ATMSwitch = Depends(dep_node),
) -> schemas.UDPNIO: ) -> schemas.UDPNIO:
""" """
Add a NIO (Network Input/Output) to the node. Add a NIO (Network Input/Output) to the node.
@ -189,11 +182,11 @@ async def delete_nio(adapter_number: int, port_number: int, node: ATMSwitch = De
@router.post("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start") @router.post("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start")
async def start_capture( async def start_capture(
*, *,
adapter_number: int = Path(..., ge=0, le=0), adapter_number: int = Path(..., ge=0, le=0),
port_number: int, port_number: int,
node_capture_data: schemas.NodeCapture, node_capture_data: schemas.NodeCapture,
node: ATMSwitch = Depends(dep_node) node: ATMSwitch = Depends(dep_node),
) -> dict: ) -> dict:
""" """
Start a packet capture on the node. Start a packet capture on the node.
@ -210,10 +203,7 @@ async def start_capture(
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
) )
async def stop_capture( async def stop_capture(
*, *, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: ATMSwitch = Depends(dep_node)
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
node: ATMSwitch = Depends(dep_node)
) -> None: ) -> None:
""" """
Stop a packet capture on the node. Stop a packet capture on the node.
@ -225,10 +215,7 @@ async def stop_capture(
@router.get("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream") @router.get("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream")
async def stream_pcap_file( async def stream_pcap_file(
*, *, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: ATMSwitch = Depends(dep_node)
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
node: ATMSwitch = Depends(dep_node)
) -> StreamingResponse: ) -> StreamingResponse:
""" """
Stream the pcap capture file. Stream the pcap capture file.

View File

@ -122,10 +122,7 @@ async def stop_cloud(node: Cloud = Depends(dep_node)) -> None:
Stop a cloud node. Stop a cloud node.
""" """
raise HTTPException( raise HTTPException(status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Stop is not supported for cloud nodes")
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
detail="Stop is not supported for cloud nodes"
)
@router.post("/{node_id}/suspend", status_code=status.HTTP_204_NO_CONTENT) @router.post("/{node_id}/suspend", status_code=status.HTTP_204_NO_CONTENT)
@ -136,8 +133,7 @@ async def suspend_cloud(node: Cloud = Depends(dep_node)) -> None:
""" """
raise HTTPException( raise HTTPException(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED, status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Suspend is not supported for cloud nodes"
detail="Suspend is not supported for cloud nodes"
) )
@ -147,11 +143,11 @@ async def suspend_cloud(node: Cloud = Depends(dep_node)) -> None:
response_model=Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO], response_model=Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO],
) )
async def create_cloud_nio( async def create_cloud_nio(
*, *,
adapter_number: int = Path(..., ge=0, le=0), adapter_number: int = Path(..., ge=0, le=0),
port_number: int, port_number: int,
nio_data: Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO], nio_data: Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO],
node: Cloud = Depends(dep_node), node: Cloud = Depends(dep_node),
) -> Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO]: ) -> Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO]:
""" """
Add a NIO (Network Input/Output) to the node. Add a NIO (Network Input/Output) to the node.
@ -169,11 +165,11 @@ async def create_cloud_nio(
response_model=Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO], response_model=Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO],
) )
async def update_cloud_nio( async def update_cloud_nio(
*, *,
adapter_number: int = Path(..., ge=0, le=0), adapter_number: int = Path(..., ge=0, le=0),
port_number: int, port_number: int,
nio_data: Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO], nio_data: Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO],
node: Cloud = Depends(dep_node), node: Cloud = Depends(dep_node),
) -> Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO]: ) -> Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO]:
""" """
Update a NIO (Network Input/Output) to the node. Update a NIO (Network Input/Output) to the node.
@ -192,10 +188,7 @@ async def update_cloud_nio(
@router.delete("/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", status_code=status.HTTP_204_NO_CONTENT)
async def delete_cloud_nio( async def delete_cloud_nio(
*, *, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: Cloud = Depends(dep_node)
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
node: Cloud = Depends(dep_node)
) -> None: ) -> None:
""" """
Remove a NIO (Network Input/Output) from the node. Remove a NIO (Network Input/Output) from the node.
@ -207,11 +200,11 @@ async def delete_cloud_nio(
@router.post("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start") @router.post("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start")
async def start_cloud_capture( async def start_cloud_capture(
*, *,
adapter_number: int = Path(..., ge=0, le=0), adapter_number: int = Path(..., ge=0, le=0),
port_number: int, port_number: int,
node_capture_data: schemas.NodeCapture, node_capture_data: schemas.NodeCapture,
node: Cloud = Depends(dep_node) node: Cloud = Depends(dep_node),
) -> dict: ) -> dict:
""" """
Start a packet capture on the node. Start a packet capture on the node.
@ -227,10 +220,7 @@ async def start_cloud_capture(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop", status_code=status.HTTP_204_NO_CONTENT "/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop", status_code=status.HTTP_204_NO_CONTENT
) )
async def stop_cloud_capture( async def stop_cloud_capture(
*, *, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: Cloud = Depends(dep_node)
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
node: Cloud = Depends(dep_node)
) -> None: ) -> None:
""" """
Stop a packet capture on the node. Stop a packet capture on the node.
@ -242,10 +232,7 @@ async def stop_cloud_capture(
@router.get("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream") @router.get("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream")
async def stream_pcap_file( async def stream_pcap_file(
*, *, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: Cloud = Depends(dep_node)
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
node: Cloud = Depends(dep_node)
) -> StreamingResponse: ) -> StreamingResponse:
""" """
Stream the pcap capture file. Stream the pcap capture file.
@ -257,13 +244,9 @@ async def stream_pcap_file(
return StreamingResponse(stream, media_type="application/vnd.tcpdump.pcap") return StreamingResponse(stream, media_type="application/vnd.tcpdump.pcap")
@router.put( @router.put("/{node_id}/markers/{marker_name}")
"/{node_id}/markers/{marker_name}"
)
async def toggle_cloud_marker( async def toggle_cloud_marker(
marker_name: str, marker_name: str, toggle_data: schemas.MarkerToggle, node: Cloud = Depends(dep_node)
toggle_data: schemas.MarkerToggle,
node: Cloud = Depends(dep_node)
) -> dict: ) -> dict:
""" """
Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2). Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2).
@ -278,19 +261,13 @@ async def toggle_cloud_marker(
return {"marker_name": marker_name, "enabled": toggle_data.enabled} return {"marker_name": marker_name, "enabled": toggle_data.enabled}
@router.post( @router.post("/{node_id}/markers/pause", status_code=status.HTTP_204_NO_CONTENT)
"/{node_id}/markers/pause",
status_code=status.HTTP_204_NO_CONTENT
)
async def pause_cloud_markers(node: Cloud = Depends(dep_node)) -> None: async def pause_cloud_markers(node: Cloud = Depends(dep_node)) -> None:
await node._ubridge_marker_pause() await node._ubridge_marker_pause()
@router.post( @router.post("/{node_id}/markers/resume", status_code=status.HTTP_204_NO_CONTENT)
"/{node_id}/markers/resume",
status_code=status.HTTP_204_NO_CONTENT
)
async def resume_cloud_markers(node: Cloud = Depends(dep_node)) -> None: async def resume_cloud_markers(node: Cloud = Depends(dep_node)) -> None:
await node._ubridge_marker_resume() await node._ubridge_marker_resume()
@ -298,7 +275,7 @@ async def resume_cloud_markers(node: Cloud = Depends(dep_node)) -> None:
@router.delete( @router.delete(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/{marker_name}", "/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/{marker_name}",
status_code=status.HTTP_204_NO_CONTENT status_code=status.HTTP_204_NO_CONTENT,
) )
async def delete_cloud_marker_capture( async def delete_cloud_marker_capture(
*, *,
@ -306,7 +283,7 @@ async def delete_cloud_marker_capture(
adapter_number: int = Path(..., ge=0, le=0), adapter_number: int = Path(..., ge=0, le=0),
port_number: int, port_number: int,
link_id: str = "", link_id: str = "",
node: Cloud = Depends(dep_node) node: Cloud = Depends(dep_node),
) -> None: ) -> None:
""" """
Delete a marker's capture pcap (called by the controller when the marker is Delete a marker's capture pcap (called by the controller when the marker is
@ -321,9 +298,7 @@ async def delete_cloud_marker_capture(
@router.put("/{node_id}/markers/{marker_name}/rebuild") @router.put("/{node_id}/markers/{marker_name}/rebuild")
async def rebuild_cloud_marker( async def rebuild_cloud_marker(
marker_name: str, marker_name: str, rebuild_data: schemas.MarkerRebuild, node: Cloud = Depends(dep_node)
rebuild_data: schemas.MarkerRebuild,
node: Cloud = Depends(dep_node)
) -> dict: ) -> dict:
""" """
Re-install a single marker filter with new BPF/tag/direction (delete + add, Re-install a single marker filter with new BPF/tag/direction (delete + add,
@ -331,7 +306,11 @@ async def rebuild_cloud_marker(
""" """
await node.rebuild_marker_filter( await node.rebuild_marker_filter(
marker_name, rebuild_data.link_id, rebuild_data.bpf, marker_name,
rebuild_data.tag, rebuild_data.direction, rebuild_data.enabled, rebuild_data.link_id,
rebuild_data.bpf,
rebuild_data.tag,
rebuild_data.direction,
rebuild_data.enabled,
) )
return {"marker_name": marker_name} return {"marker_name": marker_name}

View File

@ -32,7 +32,7 @@ security = HTTPBasic(auto_error=False)
def compute_authentication(credentials: Optional[HTTPBasicCredentials] = Depends(security)) -> None: def compute_authentication(credentials: Optional[HTTPBasicCredentials] = Depends(security)) -> None:
""" """
Authenticate compute requests. Authenticate compute requests.
Returns None if authentication is disabled or if authentication succeeds Returns None if authentication is disabled or if authentication succeeds
Raises HTTPException if authentication is required but credentials are invalid Raises HTTPException if authentication is required but credentials are invalid
""" """
@ -58,9 +58,9 @@ def compute_authentication(credentials: Optional[HTTPBasicCredentials] = Depends
headers={"WWW-Authenticate": "Basic"}, headers={"WWW-Authenticate": "Basic"},
) )
async def ws_compute_authentication(websocket: WebSocket) -> Union[None, WebSocket]: async def ws_compute_authentication(websocket: WebSocket) -> Union[None, WebSocket]:
""" """ """
"""
server_settings = Config.instance().settings.Server server_settings = Config.instance().settings.Server

View File

@ -51,7 +51,7 @@ def dep_node(project_id: UUID, node_id: UUID) -> DockerVM:
response_model=schemas.Docker, response_model=schemas.Docker,
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
responses={409: {"model": schemas.ErrorMessage, "description": "Could not create Docker node"}}, responses={409: {"model": schemas.ErrorMessage, "description": "Could not create Docker node"}},
dependencies=[Depends(compute_authentication)] dependencies=[Depends(compute_authentication)],
) )
async def create_docker_node(project_id: UUID, node_data: schemas.DockerCreate) -> schemas.Docker: async def create_docker_node(project_id: UUID, node_data: schemas.DockerCreate) -> schemas.Docker:
""" """
@ -87,10 +87,23 @@ async def create_docker_node(project_id: UUID, node_data: schemas.DockerCreate)
# fallback loop below only applies truly extra keys and does not # fallback loop below only applies truly extra keys and does not
# re-trigger console/aux port setter logging. # re-trigger console/aux port setter logging.
for key in ( for key in (
"console", "console_type", "console_resolution", "console_http_port", "console",
"console_http_path", "aux", "aux_type", "start_command", "environment", "console_type",
"adapters", "mac_address", "extra_hosts", "extra_volumes", "extra_configs", "console_resolution",
"memory", "cpus", "image_digest", "console_http_port",
"console_http_path",
"aux",
"aux_type",
"start_command",
"environment",
"adapters",
"mac_address",
"extra_hosts",
"extra_volumes",
"extra_configs",
"memory",
"cpus",
"image_digest",
): ):
node_data.pop(key, None) node_data.pop(key, None)
for name, value in node_data.items(): for name, value in node_data.items():
@ -101,11 +114,7 @@ async def create_docker_node(project_id: UUID, node_data: schemas.DockerCreate)
return container.asdict() return container.asdict()
@router.get( @router.get("/{node_id}", response_model=schemas.Docker, dependencies=[Depends(compute_authentication)])
"/{node_id}",
response_model=schemas.Docker,
dependencies=[Depends(compute_authentication)]
)
def get_docker_node(node: DockerVM = Depends(dep_node)) -> schemas.Docker: def get_docker_node(node: DockerVM = Depends(dep_node)) -> schemas.Docker:
""" """
Return a Docker node. Return a Docker node.
@ -114,11 +123,7 @@ def get_docker_node(node: DockerVM = Depends(dep_node)) -> schemas.Docker:
return node.asdict() return node.asdict()
@router.put( @router.put("/{node_id}", response_model=schemas.Docker, dependencies=[Depends(compute_authentication)])
"/{node_id}",
response_model=schemas.Docker,
dependencies=[Depends(compute_authentication)]
)
async def update_docker_node(node_data: schemas.DockerUpdate, node: DockerVM = Depends(dep_node)) -> schemas.Docker: async def update_docker_node(node_data: schemas.DockerUpdate, node: DockerVM = Depends(dep_node)) -> schemas.Docker:
""" """
Update a Docker node. Update a Docker node.
@ -160,11 +165,7 @@ async def update_docker_node(node_data: schemas.DockerUpdate, node: DockerVM = D
return node.asdict() return node.asdict()
@router.post( @router.post("/{node_id}/start", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
"/{node_id}/start",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
)
async def start_docker_node(node: DockerVM = Depends(dep_node)) -> None: async def start_docker_node(node: DockerVM = Depends(dep_node)) -> None:
""" """
Start a Docker node. Start a Docker node.
@ -173,11 +174,7 @@ async def start_docker_node(node: DockerVM = Depends(dep_node)) -> None:
await node.start() await node.start()
@router.post( @router.post("/{node_id}/stop", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
"/{node_id}/stop",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
)
async def stop_docker_node(node: DockerVM = Depends(dep_node)) -> None: async def stop_docker_node(node: DockerVM = Depends(dep_node)) -> None:
""" """
Stop a Docker node. This is the explicit user stop — the only path that Stop a Docker node. This is the explicit user stop — the only path that
@ -189,9 +186,7 @@ async def stop_docker_node(node: DockerVM = Depends(dep_node)) -> None:
@router.post( @router.post(
"/{node_id}/suspend", "/{node_id}/suspend", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
) )
async def suspend_docker_node(node: DockerVM = Depends(dep_node)) -> None: async def suspend_docker_node(node: DockerVM = Depends(dep_node)) -> None:
""" """
@ -202,9 +197,7 @@ async def suspend_docker_node(node: DockerVM = Depends(dep_node)) -> None:
@router.post( @router.post(
"/{node_id}/reload", "/{node_id}/reload", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
) )
async def reload_docker_node(node: DockerVM = Depends(dep_node)) -> None: async def reload_docker_node(node: DockerVM = Depends(dep_node)) -> None:
""" """
@ -214,11 +207,7 @@ async def reload_docker_node(node: DockerVM = Depends(dep_node)) -> None:
await node.restart() await node.restart()
@router.post( @router.post("/{node_id}/pause", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
"/{node_id}/pause",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
)
async def pause_docker_node(node: DockerVM = Depends(dep_node)) -> None: async def pause_docker_node(node: DockerVM = Depends(dep_node)) -> None:
""" """
Pause a Docker node. Pause a Docker node.
@ -228,9 +217,7 @@ async def pause_docker_node(node: DockerVM = Depends(dep_node)) -> None:
@router.post( @router.post(
"/{node_id}/unpause", "/{node_id}/unpause", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
) )
async def unpause_docker_node(node: DockerVM = Depends(dep_node)) -> None: async def unpause_docker_node(node: DockerVM = Depends(dep_node)) -> None:
""" """
@ -240,11 +227,7 @@ async def unpause_docker_node(node: DockerVM = Depends(dep_node)) -> None:
await node.unpause() await node.unpause()
@router.delete( @router.delete("/{node_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
"/{node_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
)
async def delete_docker_node(node: DockerVM = Depends(dep_node)) -> None: async def delete_docker_node(node: DockerVM = Depends(dep_node)) -> None:
""" """
Delete a Docker node. Delete a Docker node.
@ -258,11 +241,10 @@ async def delete_docker_node(node: DockerVM = Depends(dep_node)) -> None:
"/{node_id}/duplicate", "/{node_id}/duplicate",
response_model=schemas.Docker, response_model=schemas.Docker,
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
dependencies=[Depends(compute_authentication)] dependencies=[Depends(compute_authentication)],
) )
async def duplicate_docker_node( async def duplicate_docker_node(
destination_node_id: UUID = Body(..., embed=True), destination_node_id: UUID = Body(..., embed=True), node: DockerVM = Depends(dep_node)
node: DockerVM = Depends(dep_node)
) -> schemas.Docker: ) -> schemas.Docker:
""" """
Duplicate a Docker node. Duplicate a Docker node.
@ -276,7 +258,7 @@ async def duplicate_docker_node(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", "/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
response_model=schemas.UDPNIO, response_model=schemas.UDPNIO,
dependencies=[Depends(compute_authentication)] dependencies=[Depends(compute_authentication)],
) )
async def create_docker_node_nio( async def create_docker_node_nio(
adapter_number: int, port_number: int, nio_data: schemas.UDPNIO, node: DockerVM = Depends(dep_node) adapter_number: int, port_number: int, nio_data: schemas.UDPNIO, node: DockerVM = Depends(dep_node)
@ -295,7 +277,7 @@ async def create_docker_node_nio(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", "/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
response_model=schemas.UDPNIO, response_model=schemas.UDPNIO,
dependencies=[Depends(compute_authentication)] dependencies=[Depends(compute_authentication)],
) )
async def update_docker_node_nio( async def update_docker_node_nio(
adapter_number: int, port_number: int, nio_data: schemas.UDPNIO, node: DockerVM = Depends(dep_node) adapter_number: int, port_number: int, nio_data: schemas.UDPNIO, node: DockerVM = Depends(dep_node)
@ -318,13 +300,9 @@ async def update_docker_node_nio(
@router.delete( @router.delete(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", "/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)] dependencies=[Depends(compute_authentication)],
) )
async def delete_docker_node_nio( async def delete_docker_node_nio(adapter_number: int, port_number: int, node: DockerVM = Depends(dep_node)) -> None:
adapter_number: int,
port_number: int,
node: DockerVM = Depends(dep_node)
) -> None:
""" """
Delete a NIO (Network Input/Output) from the node. Delete a NIO (Network Input/Output) from the node.
The port number on the Docker node is always 0. The port number on the Docker node is always 0.
@ -335,13 +313,10 @@ async def delete_docker_node_nio(
@router.post( @router.post(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start", "/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start",
dependencies=[Depends(compute_authentication)] dependencies=[Depends(compute_authentication)],
) )
async def start_docker_node_capture( async def start_docker_node_capture(
adapter_number: int, adapter_number: int, port_number: int, node_capture_data: schemas.NodeCapture, node: DockerVM = Depends(dep_node)
port_number: int,
node_capture_data: schemas.NodeCapture,
node: DockerVM = Depends(dep_node)
) -> dict: ) -> dict:
""" """
Start a packet capture on the node. Start a packet capture on the node.
@ -356,13 +331,9 @@ async def start_docker_node_capture(
@router.post( @router.post(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop", "/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)] dependencies=[Depends(compute_authentication)],
) )
async def stop_docker_node_capture( async def stop_docker_node_capture(adapter_number: int, port_number: int, node: DockerVM = Depends(dep_node)) -> None:
adapter_number: int,
port_number: int,
node: DockerVM = Depends(dep_node)
) -> None:
""" """
Stop a packet capture on the node. Stop a packet capture on the node.
The port number on the Docker node is always 0. The port number on the Docker node is always 0.
@ -373,12 +344,10 @@ async def stop_docker_node_capture(
@router.get( @router.get(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream", "/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream",
dependencies=[Depends(compute_authentication)] dependencies=[Depends(compute_authentication)],
) )
async def stream_pcap_file( async def stream_pcap_file(
adapter_number: int, adapter_number: int, port_number: int, node: DockerVM = Depends(dep_node)
port_number: int,
node: DockerVM = Depends(dep_node)
) -> StreamingResponse: ) -> StreamingResponse:
""" """
Stream the pcap capture file. Stream the pcap capture file.
@ -392,8 +361,7 @@ async def stream_pcap_file(
@router.websocket("/{node_id}/console/ws") @router.websocket("/{node_id}/console/ws")
async def console_ws( async def console_ws(
websocket: Union[None, WebSocket] = Depends(ws_compute_authentication), websocket: Union[None, WebSocket] = Depends(ws_compute_authentication), node: DockerVM = Depends(dep_node)
node: DockerVM = Depends(dep_node)
) -> None: ) -> None:
""" """
Console WebSocket. Console WebSocket.
@ -403,12 +371,9 @@ async def console_ws(
await node.start_websocket_console(websocket) await node.start_websocket_console(websocket)
@router.websocket( @router.websocket("/{node_id}/console/vnc")
"/{node_id}/console/vnc"
)
async def vnc_console_ws( async def vnc_console_ws(
websocket: Union[None, WebSocket] = Depends(ws_compute_authentication), websocket: Union[None, WebSocket] = Depends(ws_compute_authentication), node: DockerVM = Depends(dep_node)
node: DockerVM = Depends(dep_node)
) -> None: ) -> None:
""" """
VNC Console WebSocket. VNC Console WebSocket.
@ -419,23 +384,16 @@ async def vnc_console_ws(
@router.post( @router.post(
"/{node_id}/console/reset", "/{node_id}/console/reset", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
) )
async def reset_console(node: DockerVM = Depends(dep_node)) -> None: async def reset_console(node: DockerVM = Depends(dep_node)) -> None:
await node.reset_console() await node.reset_console()
@router.put( @router.put("/{node_id}/markers/{marker_name}", dependencies=[Depends(compute_authentication)])
"/{node_id}/markers/{marker_name}",
dependencies=[Depends(compute_authentication)]
)
async def toggle_docker_marker( async def toggle_docker_marker(
marker_name: str, marker_name: str, toggle_data: schemas.MarkerToggle, node: DockerVM = Depends(dep_node)
toggle_data: schemas.MarkerToggle,
node: DockerVM = Depends(dep_node)
) -> dict: ) -> dict:
""" """
Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2). Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2).
@ -451,9 +409,7 @@ async def toggle_docker_marker(
@router.post( @router.post(
"/{node_id}/markers/pause", "/{node_id}/markers/pause", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
) )
async def pause_docker_markers(node: DockerVM = Depends(dep_node)) -> None: async def pause_docker_markers(node: DockerVM = Depends(dep_node)) -> None:
@ -461,9 +417,7 @@ async def pause_docker_markers(node: DockerVM = Depends(dep_node)) -> None:
@router.post( @router.post(
"/{node_id}/markers/resume", "/{node_id}/markers/resume", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
) )
async def resume_docker_markers(node: DockerVM = Depends(dep_node)) -> None: async def resume_docker_markers(node: DockerVM = Depends(dep_node)) -> None:
@ -473,14 +427,10 @@ async def resume_docker_markers(node: DockerVM = Depends(dep_node)) -> None:
@router.delete( @router.delete(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/{marker_name}", "/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/{marker_name}",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)] dependencies=[Depends(compute_authentication)],
) )
async def delete_docker_marker_capture( async def delete_docker_marker_capture(
marker_name: str, marker_name: str, adapter_number: int, port_number: int, link_id: str = "", node: DockerVM = Depends(dep_node)
adapter_number: int,
port_number: int,
link_id: str = "",
node: DockerVM = Depends(dep_node)
) -> None: ) -> None:
""" """
Delete a marker's capture pcap (called by the controller when the marker is Delete a marker's capture pcap (called by the controller when the marker is
@ -493,14 +443,9 @@ async def delete_docker_marker_capture(
await node.delete_marker_capture(marker_name, link_id, nio) await node.delete_marker_capture(marker_name, link_id, nio)
@router.put( @router.put("/{node_id}/markers/{marker_name}/rebuild", dependencies=[Depends(compute_authentication)])
"/{node_id}/markers/{marker_name}/rebuild",
dependencies=[Depends(compute_authentication)]
)
async def rebuild_docker_marker( async def rebuild_docker_marker(
marker_name: str, marker_name: str, rebuild_data: schemas.MarkerRebuild, node: DockerVM = Depends(dep_node)
rebuild_data: schemas.MarkerRebuild,
node: DockerVM = Depends(dep_node)
) -> dict: ) -> dict:
""" """
Re-install a single marker filter with new BPF/tag/direction (delete + add, Re-install a single marker filter with new BPF/tag/direction (delete + add,
@ -508,7 +453,11 @@ async def rebuild_docker_marker(
""" """
await node.rebuild_marker_filter( await node.rebuild_marker_filter(
marker_name, rebuild_data.link_id, rebuild_data.bpf, marker_name,
rebuild_data.tag, rebuild_data.direction, rebuild_data.enabled, rebuild_data.link_id,
rebuild_data.bpf,
rebuild_data.tag,
rebuild_data.direction,
rebuild_data.enabled,
) )
return {"marker_name": marker_name} return {"marker_name": marker_name}

View File

@ -55,7 +55,7 @@ def dep_node(project_id: UUID, node_id: UUID) -> Router:
response_model=schemas.Dynamips, response_model=schemas.Dynamips,
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
responses={409: {"model": schemas.ErrorMessage, "description": "Could not create Dynamips node"}}, responses={409: {"model": schemas.ErrorMessage, "description": "Could not create Dynamips node"}},
dependencies=[Depends(compute_authentication)] dependencies=[Depends(compute_authentication)],
) )
async def create_router(project_id: UUID, node_data: schemas.DynamipsCreate) -> schemas.Dynamips: async def create_router(project_id: UUID, node_data: schemas.DynamipsCreate) -> schemas.Dynamips:
""" """
@ -86,11 +86,7 @@ async def create_router(project_id: UUID, node_data: schemas.DynamipsCreate) ->
return vm.asdict() return vm.asdict()
@router.get( @router.get("/{node_id}", response_model=schemas.Dynamips, dependencies=[Depends(compute_authentication)])
"/{node_id}",
response_model=schemas.Dynamips,
dependencies=[Depends(compute_authentication)]
)
def get_router(node: Router = Depends(dep_node)) -> schemas.Dynamips: def get_router(node: Router = Depends(dep_node)) -> schemas.Dynamips:
""" """
Return Dynamips router. Return Dynamips router.
@ -99,11 +95,7 @@ def get_router(node: Router = Depends(dep_node)) -> schemas.Dynamips:
return node.asdict() return node.asdict()
@router.put( @router.put("/{node_id}", response_model=schemas.Dynamips, dependencies=[Depends(compute_authentication)])
"/{node_id}",
response_model=schemas.Dynamips,
dependencies=[Depends(compute_authentication)]
)
async def update_router(node_data: schemas.DynamipsUpdate, node: Router = Depends(dep_node)) -> schemas.Dynamips: async def update_router(node_data: schemas.DynamipsUpdate, node: Router = Depends(dep_node)) -> schemas.Dynamips:
""" """
Update a Dynamips router. Update a Dynamips router.
@ -114,11 +106,7 @@ async def update_router(node_data: schemas.DynamipsUpdate, node: Router = Depend
return node.asdict() return node.asdict()
@router.delete( @router.delete("/{node_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
"/{node_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
)
async def delete_router(node: Router = Depends(dep_node)) -> None: async def delete_router(node: Router = Depends(dep_node)) -> None:
""" """
Delete a Dynamips router. Delete a Dynamips router.
@ -127,11 +115,7 @@ async def delete_router(node: Router = Depends(dep_node)) -> None:
await Dynamips.instance().delete_node(node.id) await Dynamips.instance().delete_node(node.id)
@router.post( @router.post("/{node_id}/start", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
"/{node_id}/start",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
)
async def start_router(node: Router = Depends(dep_node)) -> None: async def start_router(node: Router = Depends(dep_node)) -> None:
""" """
Start a Dynamips router. Start a Dynamips router.
@ -144,11 +128,7 @@ async def start_router(node: Router = Depends(dep_node)) -> None:
await node.start() await node.start()
@router.post( @router.post("/{node_id}/stop", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
"/{node_id}/stop",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
)
async def stop_router(node: Router = Depends(dep_node)) -> None: async def stop_router(node: Router = Depends(dep_node)) -> None:
""" """
Stop a Dynamips router. Stop a Dynamips router.
@ -158,9 +138,7 @@ async def stop_router(node: Router = Depends(dep_node)) -> None:
@router.post( @router.post(
"/{node_id}/suspend", "/{node_id}/suspend", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
) )
async def suspend_router(node: Router = Depends(dep_node)) -> None: async def suspend_router(node: Router = Depends(dep_node)) -> None:
@ -168,9 +146,7 @@ async def suspend_router(node: Router = Depends(dep_node)) -> None:
@router.post( @router.post(
"/{node_id}/resume", "/{node_id}/resume", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
) )
async def resume_router(node: Router = Depends(dep_node)) -> None: async def resume_router(node: Router = Depends(dep_node)) -> None:
""" """
@ -181,9 +157,7 @@ async def resume_router(node: Router = Depends(dep_node)) -> None:
@router.post( @router.post(
"/{node_id}/reload", "/{node_id}/reload", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
) )
async def reload_router(node: Router = Depends(dep_node)) -> None: async def reload_router(node: Router = Depends(dep_node)) -> None:
""" """
@ -197,13 +171,10 @@ async def reload_router(node: Router = Depends(dep_node)) -> None:
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", "/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
response_model=schemas.UDPNIO, response_model=schemas.UDPNIO,
dependencies=[Depends(compute_authentication)] dependencies=[Depends(compute_authentication)],
) )
async def create_nio( async def create_nio(
adapter_number: int, adapter_number: int, port_number: int, nio_data: schemas.UDPNIO, node: Router = Depends(dep_node)
port_number: int,
nio_data: schemas.UDPNIO,
node: Router = Depends(dep_node)
) -> schemas.UDPNIO: ) -> schemas.UDPNIO:
""" """
Add a NIO (Network Input/Output) to the node. Add a NIO (Network Input/Output) to the node.
@ -218,13 +189,10 @@ async def create_nio(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", "/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
response_model=schemas.UDPNIO, response_model=schemas.UDPNIO,
dependencies=[Depends(compute_authentication)] dependencies=[Depends(compute_authentication)],
) )
async def update_nio( async def update_nio(
adapter_number: int, adapter_number: int, port_number: int, nio_data: schemas.UDPNIO, node: Router = Depends(dep_node)
port_number: int,
nio_data: schemas.UDPNIO,
node: Router = Depends(dep_node)
) -> schemas.UDPNIO: ) -> schemas.UDPNIO:
""" """
Update a NIO (Network Input/Output) on the node. Update a NIO (Network Input/Output) on the node.
@ -242,7 +210,7 @@ async def update_nio(
@router.delete( @router.delete(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", "/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)] dependencies=[Depends(compute_authentication)],
) )
async def delete_nio(adapter_number: int, port_number: int, node: Router = Depends(dep_node)) -> None: async def delete_nio(adapter_number: int, port_number: int, node: Router = Depends(dep_node)) -> None:
""" """
@ -255,13 +223,10 @@ async def delete_nio(adapter_number: int, port_number: int, node: Router = Depen
@router.post( @router.post(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start", "/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start",
dependencies=[Depends(compute_authentication)] dependencies=[Depends(compute_authentication)],
) )
async def start_capture( async def start_capture(
adapter_number: int, adapter_number: int, port_number: int, node_capture_data: schemas.NodeCapture, node: Router = Depends(dep_node)
port_number: int,
node_capture_data: schemas.NodeCapture,
node: Router = Depends(dep_node)
) -> dict: ) -> dict:
""" """
Start a packet capture on the node. Start a packet capture on the node.
@ -275,7 +240,7 @@ async def start_capture(
@router.post( @router.post(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop", "/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)] dependencies=[Depends(compute_authentication)],
) )
async def stop_capture(adapter_number: int, port_number: int, node: Router = Depends(dep_node)) -> None: async def stop_capture(adapter_number: int, port_number: int, node: Router = Depends(dep_node)) -> None:
""" """
@ -287,12 +252,10 @@ async def stop_capture(adapter_number: int, port_number: int, node: Router = Dep
@router.get( @router.get(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream", "/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream",
dependencies=[Depends(compute_authentication)] dependencies=[Depends(compute_authentication)],
) )
async def stream_pcap_file( async def stream_pcap_file(
adapter_number: int, adapter_number: int, port_number: int, node: Router = Depends(dep_node)
port_number: int,
node: Router = Depends(dep_node)
) -> StreamingResponse: ) -> StreamingResponse:
""" """
Stream the pcap capture file. Stream the pcap capture file.
@ -303,10 +266,7 @@ async def stream_pcap_file(
return StreamingResponse(stream, media_type="application/vnd.tcpdump.pcap") return StreamingResponse(stream, media_type="application/vnd.tcpdump.pcap")
@router.get( @router.get("/{node_id}/idlepc_proposals", dependencies=[Depends(compute_authentication)])
"/{node_id}/idlepc_proposals",
dependencies=[Depends(compute_authentication)]
)
async def get_idlepcs(node: Router = Depends(dep_node)) -> List[str]: async def get_idlepcs(node: Router = Depends(dep_node)) -> List[str]:
""" """
Retrieve Dynamips idle-pc proposals Retrieve Dynamips idle-pc proposals
@ -316,10 +276,7 @@ async def get_idlepcs(node: Router = Depends(dep_node)) -> List[str]:
return await node.get_idle_pc_prop() return await node.get_idle_pc_prop()
@router.get( @router.get("/{node_id}/auto_idlepc", dependencies=[Depends(compute_authentication)])
"/{node_id}/auto_idlepc",
dependencies=[Depends(compute_authentication)]
)
async def get_auto_idlepc(node: Router = Depends(dep_node)) -> dict: async def get_auto_idlepc(node: Router = Depends(dep_node)) -> dict:
""" """
Get an automatically guessed best idle-pc value. Get an automatically guessed best idle-pc value.
@ -333,9 +290,11 @@ async def get_auto_idlepc(node: Router = Depends(dep_node)) -> dict:
"/{node_id}/duplicate", "/{node_id}/duplicate",
response_model=schemas.Dynamips, response_model=schemas.Dynamips,
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
dependencies=[Depends(compute_authentication)] dependencies=[Depends(compute_authentication)],
) )
async def duplicate_router(destination_node_id: UUID = Body(..., embed=True), node: Router = Depends(dep_node)) -> schemas.Dynamips: async def duplicate_router(
destination_node_id: UUID = Body(..., embed=True), node: Router = Depends(dep_node)
) -> schemas.Dynamips:
""" """
Duplicate a router. Duplicate a router.
""" """
@ -346,9 +305,7 @@ async def duplicate_router(destination_node_id: UUID = Body(..., embed=True), no
@router.websocket("/{node_id}/console/ws") @router.websocket("/{node_id}/console/ws")
async def console_ws( async def console_ws(
websocket: Union[None, WebSocket] = Depends(ws_compute_authentication), websocket: Union[None, WebSocket] = Depends(ws_compute_authentication), node: Router = Depends(dep_node)
node: Router = Depends(dep_node)
) -> None: ) -> None:
""" """
Console WebSocket. Console WebSocket.
@ -359,23 +316,16 @@ async def console_ws(
@router.post( @router.post(
"/{node_id}/console/reset", "/{node_id}/console/reset", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
) )
async def reset_console(node: Router = Depends(dep_node)) -> None: async def reset_console(node: Router = Depends(dep_node)) -> None:
await node.reset_console() await node.reset_console()
@router.put( @router.put("/{node_id}/markers/{marker_name}", dependencies=[Depends(compute_authentication)])
"/{node_id}/markers/{marker_name}",
dependencies=[Depends(compute_authentication)]
)
async def toggle_dynamips_marker( async def toggle_dynamips_marker(
marker_name: str, marker_name: str, toggle_data: schemas.MarkerToggle, node: Router = Depends(dep_node)
toggle_data: schemas.MarkerToggle,
node: Router = Depends(dep_node)
) -> dict: ) -> dict:
""" """
Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2). Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2).
@ -391,9 +341,7 @@ async def toggle_dynamips_marker(
@router.post( @router.post(
"/{node_id}/markers/pause", "/{node_id}/markers/pause", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
) )
async def pause_dynamips_markers(node: Router = Depends(dep_node)) -> None: async def pause_dynamips_markers(node: Router = Depends(dep_node)) -> None:
@ -401,9 +349,7 @@ async def pause_dynamips_markers(node: Router = Depends(dep_node)) -> None:
@router.post( @router.post(
"/{node_id}/markers/resume", "/{node_id}/markers/resume", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
) )
async def resume_dynamips_markers(node: Router = Depends(dep_node)) -> None: async def resume_dynamips_markers(node: Router = Depends(dep_node)) -> None:
@ -413,14 +359,10 @@ async def resume_dynamips_markers(node: Router = Depends(dep_node)) -> None:
@router.delete( @router.delete(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/{marker_name}", "/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/{marker_name}",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)] dependencies=[Depends(compute_authentication)],
) )
async def delete_dynamips_marker_capture( async def delete_dynamips_marker_capture(
marker_name: str, marker_name: str, adapter_number: int, port_number: int, link_id: str = "", node: Router = Depends(dep_node)
adapter_number: int,
port_number: int,
link_id: str = "",
node: Router = Depends(dep_node)
) -> None: ) -> None:
""" """
Delete a marker's capture pcap (called by the controller when the marker is Delete a marker's capture pcap (called by the controller when the marker is
@ -433,14 +375,9 @@ async def delete_dynamips_marker_capture(
await node.delete_marker_capture(marker_name, link_id, nio) await node.delete_marker_capture(marker_name, link_id, nio)
@router.put( @router.put("/{node_id}/markers/{marker_name}/rebuild", dependencies=[Depends(compute_authentication)])
"/{node_id}/markers/{marker_name}/rebuild",
dependencies=[Depends(compute_authentication)]
)
async def rebuild_dynamips_marker( async def rebuild_dynamips_marker(
marker_name: str, marker_name: str, rebuild_data: schemas.MarkerRebuild, node: Router = Depends(dep_node)
rebuild_data: schemas.MarkerRebuild,
node: Router = Depends(dep_node)
) -> dict: ) -> dict:
""" """
Re-install a single marker filter with new BPF/tag/direction (delete + add, Re-install a single marker filter with new BPF/tag/direction (delete + add,
@ -448,7 +385,11 @@ async def rebuild_dynamips_marker(
""" """
await node.rebuild_marker_filter( await node.rebuild_marker_filter(
marker_name, rebuild_data.link_id, rebuild_data.bpf, marker_name,
rebuild_data.tag, rebuild_data.direction, rebuild_data.enabled, rebuild_data.link_id,
rebuild_data.bpf,
rebuild_data.tag,
rebuild_data.direction,
rebuild_data.enabled,
) )
return {"marker_name": marker_name} return {"marker_name": marker_name}

View File

@ -91,8 +91,7 @@ async def duplicate_ethernet_hub(
@router.put("/{node_id}", response_model=schemas.EthernetHub) @router.put("/{node_id}", response_model=schemas.EthernetHub)
async def update_ethernet_hub( async def update_ethernet_hub(
node_data: schemas.EthernetHubUpdate, node_data: schemas.EthernetHubUpdate, node: EthernetHub = Depends(dep_node)
node: EthernetHub = Depends(dep_node)
) -> schemas.EthernetHub: ) -> schemas.EthernetHub:
""" """
Update an Ethernet hub. Update an Ethernet hub.
@ -124,8 +123,7 @@ def start_ethernet_hub(node: EthernetHub = Depends(dep_node)) -> None:
""" """
raise HTTPException( raise HTTPException(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED, status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Start is not supported for Ethernet hubs"
detail="Start is not supported for Ethernet hubs"
) )
@ -136,8 +134,7 @@ def stop_ethernet_hub(node: EthernetHub = Depends(dep_node)) -> None:
""" """
raise HTTPException( raise HTTPException(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED, status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Stop is not supported for Ethernet hubs"
detail="Stop is not supported for Ethernet hubs"
) )
@ -148,8 +145,7 @@ def suspend_ethernet_hub(node: EthernetHub = Depends(dep_node)) -> None:
""" """
raise HTTPException( raise HTTPException(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED, status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Suspend is not supported for Ethernet hubs"
detail="Suspend is not supported for Ethernet hubs"
) )
@ -159,11 +155,11 @@ def suspend_ethernet_hub(node: EthernetHub = Depends(dep_node)) -> None:
response_model=schemas.UDPNIO, response_model=schemas.UDPNIO,
) )
async def create_nio( async def create_nio(
*, *,
adapter_number: int = Path(..., ge=0, le=0), adapter_number: int = Path(..., ge=0, le=0),
port_number: int, port_number: int,
nio_data: schemas.UDPNIO, nio_data: schemas.UDPNIO,
node: EthernetHub = Depends(dep_node) node: EthernetHub = Depends(dep_node),
) -> schemas.UDPNIO: ) -> schemas.UDPNIO:
""" """
Add a NIO (Network Input/Output) to the node. Add a NIO (Network Input/Output) to the node.
@ -177,10 +173,7 @@ async def create_nio(
@router.delete("/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", status_code=status.HTTP_204_NO_CONTENT)
async def delete_nio( async def delete_nio(
*, *, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: EthernetHub = Depends(dep_node)
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
node: EthernetHub = Depends(dep_node)
) -> None: ) -> None:
""" """
Delete a NIO (Network Input/Output) from the node. Delete a NIO (Network Input/Output) from the node.
@ -193,11 +186,11 @@ async def delete_nio(
@router.post("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start") @router.post("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start")
async def start_capture( async def start_capture(
*, *,
adapter_number: int = Path(..., ge=0, le=0), adapter_number: int = Path(..., ge=0, le=0),
port_number: int, port_number: int,
node_capture_data: schemas.NodeCapture, node_capture_data: schemas.NodeCapture,
node: EthernetHub = Depends(dep_node) node: EthernetHub = Depends(dep_node),
) -> dict: ) -> dict:
""" """
Start a packet capture on the node. Start a packet capture on the node.
@ -213,10 +206,7 @@ async def start_capture(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop", status_code=status.HTTP_204_NO_CONTENT "/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop", status_code=status.HTTP_204_NO_CONTENT
) )
async def stop_capture( async def stop_capture(
*, *, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: EthernetHub = Depends(dep_node)
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
node: EthernetHub = Depends(dep_node)
) -> None: ) -> None:
""" """
Stop a packet capture on the node. Stop a packet capture on the node.
@ -228,10 +218,7 @@ async def stop_capture(
@router.get("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream") @router.get("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream")
async def stream_pcap_file( async def stream_pcap_file(
*, *, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: EthernetHub = Depends(dep_node)
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
node: EthernetHub = Depends(dep_node)
) -> StreamingResponse: ) -> StreamingResponse:
""" """
Stream the pcap capture file. Stream the pcap capture file.

View File

@ -82,8 +82,7 @@ def get_ethernet_switch(node: EthernetSwitch = Depends(dep_node)) -> schemas.Eth
@router.post("/{node_id}/duplicate", response_model=schemas.EthernetSwitch, status_code=status.HTTP_201_CREATED) @router.post("/{node_id}/duplicate", response_model=schemas.EthernetSwitch, status_code=status.HTTP_201_CREATED)
async def duplicate_ethernet_switch( async def duplicate_ethernet_switch(
destination_node_id: UUID = Body(..., embed=True), destination_node_id: UUID = Body(..., embed=True), node: EthernetSwitch = Depends(dep_node)
node: EthernetSwitch = Depends(dep_node)
) -> schemas.EthernetSwitch: ) -> schemas.EthernetSwitch:
""" """
Duplicate an Ethernet switch. Duplicate an Ethernet switch.
@ -95,8 +94,7 @@ async def duplicate_ethernet_switch(
@router.put("/{node_id}", response_model=schemas.EthernetSwitch) @router.put("/{node_id}", response_model=schemas.EthernetSwitch)
async def update_ethernet_switch( async def update_ethernet_switch(
node_data: schemas.EthernetSwitchUpdate, node_data: schemas.EthernetSwitchUpdate, node: EthernetSwitch = Depends(dep_node)
node: EthernetSwitch = Depends(dep_node)
) -> schemas.EthernetSwitch: ) -> schemas.EthernetSwitch:
""" """
Update an Ethernet switch. Update an Ethernet switch.
@ -132,8 +130,7 @@ def start_ethernet_switch(node: EthernetSwitch = Depends(dep_node)) -> None:
""" """
raise HTTPException( raise HTTPException(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED, status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Start is not supported for Ethernet switches"
detail="Start is not supported for Ethernet switches"
) )
@ -144,8 +141,7 @@ def stop_ethernet_switch(node: EthernetSwitch = Depends(dep_node)) -> None:
""" """
raise HTTPException( raise HTTPException(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED, status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Stop is not supported for Ethernet switches"
detail="Stop is not supported for Ethernet switches"
) )
@ -156,8 +152,7 @@ def suspend_ethernet_switch(node: EthernetSwitch = Depends(dep_node)) -> None:
""" """
raise HTTPException( raise HTTPException(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED, status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Suspend is not supported for Ethernet switches"
detail="Suspend is not supported for Ethernet switches"
) )
@ -169,8 +164,7 @@ def reload_ethernet_switch(node: EthernetSwitch = Depends(dep_node)) -> None:
""" """
raise HTTPException( raise HTTPException(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED, status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Reload is not supported for Ethernet switches"
detail="Reload is not supported for Ethernet switches"
) )
@ -180,11 +174,11 @@ def reload_ethernet_switch(node: EthernetSwitch = Depends(dep_node)) -> None:
response_model=schemas.UDPNIO, response_model=schemas.UDPNIO,
) )
async def create_ethernet_switch_nio( async def create_ethernet_switch_nio(
*, *,
adapter_number: int = Path(..., ge=0, le=0), adapter_number: int = Path(..., ge=0, le=0),
port_number: int, port_number: int,
nio_data: schemas.UDPNIO, nio_data: schemas.UDPNIO,
node: EthernetSwitch = Depends(dep_node) node: EthernetSwitch = Depends(dep_node),
) -> schemas.UDPNIO: ) -> schemas.UDPNIO:
nio = Builtin.instance().create_nio(jsonable_encoder(nio_data, exclude_unset=True)) nio = Builtin.instance().create_nio(jsonable_encoder(nio_data, exclude_unset=True))
@ -198,11 +192,11 @@ async def create_ethernet_switch_nio(
response_model=schemas.UDPNIO, response_model=schemas.UDPNIO,
) )
async def update_ethernet_switch_nio( async def update_ethernet_switch_nio(
*, *,
adapter_number: int = Path(..., ge=0, le=0), adapter_number: int = Path(..., ge=0, le=0),
port_number: int, port_number: int,
nio_data: schemas.UDPNIO, nio_data: schemas.UDPNIO,
node: EthernetSwitch = Depends(dep_node) node: EthernetSwitch = Depends(dep_node),
) -> schemas.UDPNIO: ) -> schemas.UDPNIO:
""" """
Update a NIO (Network Input/Output) on the node: re-apply the packet Update a NIO (Network Input/Output) on the node: re-apply the packet
@ -221,10 +215,7 @@ async def update_ethernet_switch_nio(
@router.delete("/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", status_code=status.HTTP_204_NO_CONTENT)
async def delete_ethernet_switch_nio( async def delete_ethernet_switch_nio(
*, *, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: EthernetSwitch = Depends(dep_node)
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
node: EthernetSwitch = Depends(dep_node)
) -> None: ) -> None:
""" """
Delete a NIO (Network Input/Output) from the node. Delete a NIO (Network Input/Output) from the node.
@ -236,11 +227,11 @@ async def delete_ethernet_switch_nio(
@router.post("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start") @router.post("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start")
async def start_ethernet_switch_capture( async def start_ethernet_switch_capture(
*, *,
adapter_number: int = Path(..., ge=0, le=0), adapter_number: int = Path(..., ge=0, le=0),
port_number: int, port_number: int,
node_capture_data: schemas.NodeCapture, node_capture_data: schemas.NodeCapture,
node: EthernetSwitch = Depends(dep_node), node: EthernetSwitch = Depends(dep_node),
) -> dict: ) -> dict:
""" """
Start a packet capture on the node. Start a packet capture on the node.
@ -256,10 +247,7 @@ async def start_ethernet_switch_capture(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop", status_code=status.HTTP_204_NO_CONTENT "/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop", status_code=status.HTTP_204_NO_CONTENT
) )
async def stop_ethernet_switch_capture( async def stop_ethernet_switch_capture(
*, *, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: EthernetSwitch = Depends(dep_node)
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
node: EthernetSwitch = Depends(dep_node)
) -> None: ) -> None:
""" """
Stop a packet capture on the node. Stop a packet capture on the node.
@ -271,10 +259,7 @@ async def stop_ethernet_switch_capture(
@router.get("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream") @router.get("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream")
async def stream_pcap_file( async def stream_pcap_file(
*, *, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: EthernetSwitch = Depends(dep_node)
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
node: EthernetSwitch = Depends(dep_node)
) -> StreamingResponse: ) -> StreamingResponse:
""" """
Stream the pcap capture file. Stream the pcap capture file.
@ -288,9 +273,7 @@ async def stream_pcap_file(
@router.put("/{node_id}/markers/{marker_name}") @router.put("/{node_id}/markers/{marker_name}")
async def toggle_ethernet_switch_marker( async def toggle_ethernet_switch_marker(
marker_name: str, marker_name: str, toggle_data: schemas.MarkerToggle, node: EthernetSwitch = Depends(dep_node)
toggle_data: schemas.MarkerToggle,
node: EthernetSwitch = Depends(dep_node)
) -> dict: ) -> dict:
""" """
Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2). Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2).
@ -322,12 +305,12 @@ async def resume_ethernet_switch_markers(node: EthernetSwitch = Depends(dep_node
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
) )
async def delete_ethernet_switch_marker_capture( async def delete_ethernet_switch_marker_capture(
*, *,
marker_name: str, marker_name: str,
adapter_number: int = Path(..., ge=0, le=0), adapter_number: int = Path(..., ge=0, le=0),
port_number: int, port_number: int,
link_id: str = "", link_id: str = "",
node: EthernetSwitch = Depends(dep_node) node: EthernetSwitch = Depends(dep_node),
) -> None: ) -> None:
""" """
Delete a marker's capture pcap (called by the controller when the marker is Delete a marker's capture pcap (called by the controller when the marker is
@ -342,9 +325,7 @@ async def delete_ethernet_switch_marker_capture(
@router.put("/{node_id}/markers/{marker_name}/rebuild") @router.put("/{node_id}/markers/{marker_name}/rebuild")
async def rebuild_ethernet_switch_marker( async def rebuild_ethernet_switch_marker(
marker_name: str, marker_name: str, rebuild_data: schemas.MarkerRebuild, node: EthernetSwitch = Depends(dep_node)
rebuild_data: schemas.MarkerRebuild,
node: EthernetSwitch = Depends(dep_node)
) -> dict: ) -> dict:
""" """
Re-install a single marker filter with new BPF/tag/direction (delete + add, Re-install a single marker filter with new BPF/tag/direction (delete + add,
@ -352,7 +333,11 @@ async def rebuild_ethernet_switch_marker(
""" """
await node.rebuild_marker_filter( await node.rebuild_marker_filter(
marker_name, rebuild_data.link_id, rebuild_data.bpf, marker_name,
rebuild_data.tag, rebuild_data.direction, rebuild_data.enabled, rebuild_data.link_id,
rebuild_data.bpf,
rebuild_data.tag,
rebuild_data.direction,
rebuild_data.enabled,
) )
return {"marker_name": marker_name} return {"marker_name": marker_name}

View File

@ -51,8 +51,7 @@ def dep_node(project_id: UUID, node_id: UUID) -> FrameRelaySwitch:
responses={409: {"model": schemas.ErrorMessage, "description": "Could not create Frame Relay switch node"}}, responses={409: {"model": schemas.ErrorMessage, "description": "Could not create Frame Relay switch node"}},
) )
async def create_frame_relay_switch( async def create_frame_relay_switch(
project_id: UUID, project_id: UUID, node_data: schemas.FrameRelaySwitchCreate
node_data: schemas.FrameRelaySwitchCreate
) -> schemas.FrameRelaySwitch: ) -> schemas.FrameRelaySwitch:
""" """
Create a new Frame Relay switch node. Create a new Frame Relay switch node.
@ -82,8 +81,7 @@ def get_frame_relay_switch(node: FrameRelaySwitch = Depends(dep_node)) -> schema
@router.post("/{node_id}/duplicate", response_model=schemas.FrameRelaySwitch, status_code=status.HTTP_201_CREATED) @router.post("/{node_id}/duplicate", response_model=schemas.FrameRelaySwitch, status_code=status.HTTP_201_CREATED)
async def duplicate_frame_relay_switch( async def duplicate_frame_relay_switch(
destination_node_id: UUID = Body(..., embed=True), destination_node_id: UUID = Body(..., embed=True), node: FrameRelaySwitch = Depends(dep_node)
node: FrameRelaySwitch = Depends(dep_node)
) -> schemas.FrameRelaySwitch: ) -> schemas.FrameRelaySwitch:
""" """
Duplicate a Frame Relay switch node. Duplicate a Frame Relay switch node.
@ -95,8 +93,7 @@ async def duplicate_frame_relay_switch(
@router.put("/{node_id}", response_model=schemas.FrameRelaySwitch) @router.put("/{node_id}", response_model=schemas.FrameRelaySwitch)
async def update_frame_relay_switch( async def update_frame_relay_switch(
node_data: schemas.FrameRelaySwitchUpdate, node_data: schemas.FrameRelaySwitchUpdate, node: FrameRelaySwitch = Depends(dep_node)
node: FrameRelaySwitch = Depends(dep_node)
) -> schemas.FrameRelaySwitch: ) -> schemas.FrameRelaySwitch:
""" """
Update an Frame Relay switch node. Update an Frame Relay switch node.
@ -127,8 +124,7 @@ def start_frame_relay_switch(node: FrameRelaySwitch = Depends(dep_node)) -> None
""" """
raise HTTPException( raise HTTPException(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED, status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Start is not supported for Frame Relay switches"
detail="Start is not supported for Frame Relay switches"
) )
@ -139,8 +135,7 @@ def stop_frame_relay_switch(node: FrameRelaySwitch = Depends(dep_node)) -> None:
""" """
raise HTTPException( raise HTTPException(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED, status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Stop is not supported for Frame Relay switches"
detail="Stop is not supported for Frame Relay switches"
) )
@ -151,8 +146,7 @@ def suspend_frame_relay_switch(node: FrameRelaySwitch = Depends(dep_node)) -> No
""" """
raise HTTPException( raise HTTPException(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED, status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Suspend is not supported for Frame Relay switches"
detail="Suspend is not supported for Frame Relay switches"
) )
@ -162,11 +156,11 @@ def suspend_frame_relay_switch(node: FrameRelaySwitch = Depends(dep_node)) -> No
response_model=schemas.UDPNIO, response_model=schemas.UDPNIO,
) )
async def create_nio( async def create_nio(
*, *,
adapter_number: int = Path(..., ge=0, le=0), adapter_number: int = Path(..., ge=0, le=0),
port_number: int, port_number: int,
nio_data: schemas.UDPNIO, nio_data: schemas.UDPNIO,
node: FrameRelaySwitch = Depends(dep_node) node: FrameRelaySwitch = Depends(dep_node),
) -> schemas.UDPNIO: ) -> schemas.UDPNIO:
""" """
Add a NIO (Network Input/Output) to the node. Add a NIO (Network Input/Output) to the node.
@ -180,10 +174,7 @@ async def create_nio(
@router.delete("/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", status_code=status.HTTP_204_NO_CONTENT)
async def delete_nio( async def delete_nio(
*, *, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: FrameRelaySwitch = Depends(dep_node)
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
node: FrameRelaySwitch = Depends(dep_node)
) -> None: ) -> None:
""" """
Remove a NIO (Network Input/Output) from the node. Remove a NIO (Network Input/Output) from the node.
@ -196,11 +187,11 @@ async def delete_nio(
@router.post("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start") @router.post("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start")
async def start_capture( async def start_capture(
*, *,
adapter_number: int = Path(..., ge=0, le=0), adapter_number: int = Path(..., ge=0, le=0),
port_number: int, port_number: int,
node_capture_data: schemas.NodeCapture, node_capture_data: schemas.NodeCapture,
node: FrameRelaySwitch = Depends(dep_node), node: FrameRelaySwitch = Depends(dep_node),
) -> dict: ) -> dict:
""" """
Start a packet capture on the node. Start a packet capture on the node.
@ -216,10 +207,7 @@ async def start_capture(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop", status_code=status.HTTP_204_NO_CONTENT "/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop", status_code=status.HTTP_204_NO_CONTENT
) )
async def stop_capture( async def stop_capture(
*, *, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: FrameRelaySwitch = Depends(dep_node)
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
node: FrameRelaySwitch = Depends(dep_node)
) -> None: ) -> None:
""" """
Stop a packet capture on the node. Stop a packet capture on the node.
@ -231,10 +219,7 @@ async def stop_capture(
@router.get("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream") @router.get("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream")
async def stream_pcap_file( async def stream_pcap_file(
*, *, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: FrameRelaySwitch = Depends(dep_node)
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
node: FrameRelaySwitch = Depends(dep_node)
) -> StreamingResponse: ) -> StreamingResponse:
""" """
Stream the pcap capture file. Stream the pcap capture file.

View File

@ -52,7 +52,7 @@ def dep_node(project_id: UUID, node_id: UUID) -> IOUVM:
response_model=schemas.IOU, response_model=schemas.IOU,
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
responses={409: {"model": schemas.ErrorMessage, "description": "Could not create IOU node"}}, responses={409: {"model": schemas.ErrorMessage, "description": "Could not create IOU node"}},
dependencies=[Depends(compute_authentication)] dependencies=[Depends(compute_authentication)],
) )
async def create_iou_node(project_id: UUID, node_data: schemas.IOUCreate) -> schemas.IOU: async def create_iou_node(project_id: UUID, node_data: schemas.IOUCreate) -> schemas.IOU:
""" """
@ -85,11 +85,7 @@ async def create_iou_node(project_id: UUID, node_data: schemas.IOUCreate) -> sch
return vm.asdict() return vm.asdict()
@router.get( @router.get("/{node_id}", response_model=schemas.IOU, dependencies=[Depends(compute_authentication)])
"/{node_id}",
response_model=schemas.IOU,
dependencies=[Depends(compute_authentication)]
)
def get_iou_node(node: IOUVM = Depends(dep_node)) -> schemas.IOU: def get_iou_node(node: IOUVM = Depends(dep_node)) -> schemas.IOU:
""" """
Return an IOU node. Return an IOU node.
@ -98,11 +94,7 @@ def get_iou_node(node: IOUVM = Depends(dep_node)) -> schemas.IOU:
return node.asdict() return node.asdict()
@router.put( @router.put("/{node_id}", response_model=schemas.IOU, dependencies=[Depends(compute_authentication)])
"/{node_id}",
response_model=schemas.IOU,
dependencies=[Depends(compute_authentication)]
)
async def update_iou_node(node_data: schemas.IOUUpdate, node: IOUVM = Depends(dep_node)) -> schemas.IOU: async def update_iou_node(node_data: schemas.IOUUpdate, node: IOUVM = Depends(dep_node)) -> schemas.IOU:
""" """
Update an IOU node. Update an IOU node.
@ -123,11 +115,7 @@ async def update_iou_node(node_data: schemas.IOUUpdate, node: IOUVM = Depends(de
return node.asdict() return node.asdict()
@router.delete( @router.delete("/{node_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
"/{node_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
)
async def delete_iou_node(node: IOUVM = Depends(dep_node)) -> None: async def delete_iou_node(node: IOUVM = Depends(dep_node)) -> None:
""" """
Delete an IOU node. Delete an IOU node.
@ -140,11 +128,10 @@ async def delete_iou_node(node: IOUVM = Depends(dep_node)) -> None:
"/{node_id}/duplicate", "/{node_id}/duplicate",
response_model=schemas.IOU, response_model=schemas.IOU,
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
dependencies=[Depends(compute_authentication)] dependencies=[Depends(compute_authentication)],
) )
async def duplicate_iou_node( async def duplicate_iou_node(
destination_node_id: UUID = Body(..., embed=True), destination_node_id: UUID = Body(..., embed=True), node: IOUVM = Depends(dep_node)
node: IOUVM = Depends(dep_node)
) -> schemas.IOU: ) -> schemas.IOU:
""" """
Duplicate an IOU node. Duplicate an IOU node.
@ -154,11 +141,7 @@ async def duplicate_iou_node(
return new_node.asdict() return new_node.asdict()
@router.post( @router.post("/{node_id}/start", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
"/{node_id}/start",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
)
async def start_iou_node(start_data: schemas.IOUStart, node: IOUVM = Depends(dep_node)) -> None: async def start_iou_node(start_data: schemas.IOUStart, node: IOUVM = Depends(dep_node)) -> None:
""" """
Start an IOU node. Start an IOU node.
@ -172,11 +155,7 @@ async def start_iou_node(start_data: schemas.IOUStart, node: IOUVM = Depends(dep
await node.start() await node.start()
@router.post( @router.post("/{node_id}/stop", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
"/{node_id}/stop",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
)
async def stop_iou_node(node: IOUVM = Depends(dep_node)) -> None: async def stop_iou_node(node: IOUVM = Depends(dep_node)) -> None:
""" """
Stop an IOU node. Stop an IOU node.
@ -185,25 +164,17 @@ async def stop_iou_node(node: IOUVM = Depends(dep_node)) -> None:
await node.stop() await node.stop()
@router.post( @router.post("/{node_id}/suspend", dependencies=[Depends(compute_authentication)])
"/{node_id}/suspend",
dependencies=[Depends(compute_authentication)]
)
def suspend_iou_node(node: IOUVM = Depends(dep_node)) -> None: def suspend_iou_node(node: IOUVM = Depends(dep_node)) -> None:
""" """
Suspend an IOU node. Suspend an IOU node.
""" """
raise HTTPException( raise HTTPException(status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Suspend is not supported for IOU nodes")
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
detail="Suspend is not supported for IOU nodes"
)
@router.post( @router.post(
"/{node_id}/reload", "/{node_id}/reload", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
) )
async def reload_iou_node(node: IOUVM = Depends(dep_node)) -> None: async def reload_iou_node(node: IOUVM = Depends(dep_node)) -> None:
""" """
@ -217,7 +188,7 @@ async def reload_iou_node(node: IOUVM = Depends(dep_node)) -> None:
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", "/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
response_model=Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO], response_model=Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO],
dependencies=[Depends(compute_authentication)] dependencies=[Depends(compute_authentication)],
) )
async def create_iou_node_nio( async def create_iou_node_nio(
adapter_number: int, adapter_number: int,
@ -238,7 +209,7 @@ async def create_iou_node_nio(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", "/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
response_model=Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO], response_model=Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO],
dependencies=[Depends(compute_authentication)] dependencies=[Depends(compute_authentication)],
) )
async def update_iou_node_nio( async def update_iou_node_nio(
adapter_number: int, adapter_number: int,
@ -263,7 +234,7 @@ async def update_iou_node_nio(
@router.delete( @router.delete(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", "/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)] dependencies=[Depends(compute_authentication)],
) )
async def delete_iou_node_nio(adapter_number: int, port_number: int, node: IOUVM = Depends(dep_node)) -> None: async def delete_iou_node_nio(adapter_number: int, port_number: int, node: IOUVM = Depends(dep_node)) -> None:
""" """
@ -275,13 +246,10 @@ async def delete_iou_node_nio(adapter_number: int, port_number: int, node: IOUVM
@router.post( @router.post(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start", "/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start",
dependencies=[Depends(compute_authentication)] dependencies=[Depends(compute_authentication)],
) )
async def start_iou_node_capture( async def start_iou_node_capture(
adapter_number: int, adapter_number: int, port_number: int, node_capture_data: schemas.NodeCapture, node: IOUVM = Depends(dep_node)
port_number: int,
node_capture_data: schemas.NodeCapture,
node: IOUVM = Depends(dep_node)
) -> dict: ) -> dict:
""" """
Start a packet capture on the node. Start a packet capture on the node.
@ -295,7 +263,7 @@ async def start_iou_node_capture(
@router.post( @router.post(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop", "/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)] dependencies=[Depends(compute_authentication)],
) )
async def stop_iou_node_capture(adapter_number: int, port_number: int, node: IOUVM = Depends(dep_node)) -> None: async def stop_iou_node_capture(adapter_number: int, port_number: int, node: IOUVM = Depends(dep_node)) -> None:
""" """
@ -307,13 +275,9 @@ async def stop_iou_node_capture(adapter_number: int, port_number: int, node: IOU
@router.get( @router.get(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream", "/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream",
dependencies=[Depends(compute_authentication)] dependencies=[Depends(compute_authentication)],
) )
async def stream_pcap_file( async def stream_pcap_file(adapter_number: int, port_number: int, node: IOUVM = Depends(dep_node)) -> StreamingResponse:
adapter_number: int,
port_number: int,
node: IOUVM = Depends(dep_node)
) -> StreamingResponse:
""" """
Stream the pcap capture file. Stream the pcap capture file.
""" """
@ -327,8 +291,7 @@ async def stream_pcap_file(
"/{node_id}/console/ws", "/{node_id}/console/ws",
) )
async def console_ws( async def console_ws(
websocket: Union[None, WebSocket] = Depends(ws_compute_authentication), websocket: Union[None, WebSocket] = Depends(ws_compute_authentication), node: IOUVM = Depends(dep_node)
node: IOUVM = Depends(dep_node)
) -> None: ) -> None:
""" """
Console WebSocket. Console WebSocket.
@ -339,23 +302,16 @@ async def console_ws(
@router.post( @router.post(
"/{node_id}/console/reset", "/{node_id}/console/reset", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
) )
async def reset_console(node: IOUVM = Depends(dep_node)) -> None: async def reset_console(node: IOUVM = Depends(dep_node)) -> None:
await node.reset_console() await node.reset_console()
@router.put( @router.put("/{node_id}/markers/{marker_name}", dependencies=[Depends(compute_authentication)])
"/{node_id}/markers/{marker_name}",
dependencies=[Depends(compute_authentication)]
)
async def toggle_iou_marker( async def toggle_iou_marker(
marker_name: str, marker_name: str, toggle_data: schemas.MarkerToggle, node: IOUVM = Depends(dep_node)
toggle_data: schemas.MarkerToggle,
node: IOUVM = Depends(dep_node)
) -> dict: ) -> dict:
""" """
Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2). Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2).
@ -371,9 +327,7 @@ async def toggle_iou_marker(
@router.post( @router.post(
"/{node_id}/markers/pause", "/{node_id}/markers/pause", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
) )
async def pause_iou_markers(node: IOUVM = Depends(dep_node)) -> None: async def pause_iou_markers(node: IOUVM = Depends(dep_node)) -> None:
@ -381,9 +335,7 @@ async def pause_iou_markers(node: IOUVM = Depends(dep_node)) -> None:
@router.post( @router.post(
"/{node_id}/markers/resume", "/{node_id}/markers/resume", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
) )
async def resume_iou_markers(node: IOUVM = Depends(dep_node)) -> None: async def resume_iou_markers(node: IOUVM = Depends(dep_node)) -> None:
@ -393,14 +345,10 @@ async def resume_iou_markers(node: IOUVM = Depends(dep_node)) -> None:
@router.delete( @router.delete(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/{marker_name}", "/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/{marker_name}",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)] dependencies=[Depends(compute_authentication)],
) )
async def delete_iou_marker_capture( async def delete_iou_marker_capture(
marker_name: str, marker_name: str, adapter_number: int, port_number: int, link_id: str = "", node: IOUVM = Depends(dep_node)
adapter_number: int,
port_number: int,
link_id: str = "",
node: IOUVM = Depends(dep_node)
) -> None: ) -> None:
""" """
Delete a marker's capture pcap (called by the controller when the marker is Delete a marker's capture pcap (called by the controller when the marker is
@ -413,14 +361,9 @@ async def delete_iou_marker_capture(
await node.delete_marker_capture(marker_name, link_id, nio) await node.delete_marker_capture(marker_name, link_id, nio)
@router.put( @router.put("/{node_id}/markers/{marker_name}/rebuild", dependencies=[Depends(compute_authentication)])
"/{node_id}/markers/{marker_name}/rebuild",
dependencies=[Depends(compute_authentication)]
)
async def rebuild_iou_marker( async def rebuild_iou_marker(
marker_name: str, marker_name: str, rebuild_data: schemas.MarkerRebuild, node: IOUVM = Depends(dep_node)
rebuild_data: schemas.MarkerRebuild,
node: IOUVM = Depends(dep_node)
) -> dict: ) -> dict:
""" """
Re-install a single marker filter with new BPF/tag/direction (delete + add, Re-install a single marker filter with new BPF/tag/direction (delete + add,
@ -428,7 +371,11 @@ async def rebuild_iou_marker(
""" """
await node.rebuild_marker_filter( await node.rebuild_marker_filter(
marker_name, rebuild_data.link_id, rebuild_data.bpf, marker_name,
rebuild_data.tag, rebuild_data.direction, rebuild_data.enabled, rebuild_data.link_id,
rebuild_data.bpf,
rebuild_data.tag,
rebuild_data.direction,
rebuild_data.enabled,
) )
return {"marker_name": marker_name} return {"marker_name": marker_name}

View File

@ -117,10 +117,7 @@ async def stop_nat_node(node: Nat = Depends(dep_node)) -> None:
Stop a NAT node. Stop a NAT node.
""" """
raise HTTPException( raise HTTPException(status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Stop is not supported for NAT nodes")
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
detail="Stop is not supported for NAT nodes"
)
@router.post("/{node_id}/suspend", status_code=status.HTTP_204_NO_CONTENT) @router.post("/{node_id}/suspend", status_code=status.HTTP_204_NO_CONTENT)
@ -129,10 +126,7 @@ async def suspend_nat_node(node: Nat = Depends(dep_node)) -> None:
Suspend a NAT node. Suspend a NAT node.
""" """
raise HTTPException( raise HTTPException(status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Suspend is not supported for NAT nodes")
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
detail="Suspend is not supported for NAT nodes"
)
@router.post( @router.post(
@ -141,11 +135,11 @@ async def suspend_nat_node(node: Nat = Depends(dep_node)) -> None:
response_model=Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO], response_model=Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO],
) )
async def create_nat_node_nio( async def create_nat_node_nio(
*, *,
adapter_number: int = Path(..., ge=0, le=0), adapter_number: int = Path(..., ge=0, le=0),
port_number: int, port_number: int,
nio_data: Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO], nio_data: Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO],
node: Nat = Depends(dep_node), node: Nat = Depends(dep_node),
) -> Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO]: ) -> Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO]:
""" """
Add a NIO (Network Input/Output) to the node. Add a NIO (Network Input/Output) to the node.
@ -163,11 +157,11 @@ async def create_nat_node_nio(
response_model=Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO], response_model=Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO],
) )
async def update_nat_node_nio( async def update_nat_node_nio(
*, *,
adapter_number: int = Path(..., ge=0, le=0), adapter_number: int = Path(..., ge=0, le=0),
port_number: int, port_number: int,
nio_data: Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO], nio_data: Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO],
node: Nat = Depends(dep_node), node: Nat = Depends(dep_node),
) -> Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO]: ) -> Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO]:
""" """
Update a NIO (Network Input/Output) to the node. Update a NIO (Network Input/Output) to the node.
@ -184,10 +178,7 @@ async def update_nat_node_nio(
@router.delete("/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", status_code=status.HTTP_204_NO_CONTENT)
async def delete_nat_node_nio( async def delete_nat_node_nio(
*, *, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: Nat = Depends(dep_node)
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
node: Nat = Depends(dep_node)
) -> None: ) -> None:
""" """
Remove a NIO (Network Input/Output) from the node. Remove a NIO (Network Input/Output) from the node.
@ -199,11 +190,11 @@ async def delete_nat_node_nio(
@router.post("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start") @router.post("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start")
async def start_nat_node_capture( async def start_nat_node_capture(
*, *,
adapter_number: int = Path(..., ge=0, le=0), adapter_number: int = Path(..., ge=0, le=0),
port_number: int, port_number: int,
node_capture_data: schemas.NodeCapture, node_capture_data: schemas.NodeCapture,
node: Nat = Depends(dep_node) node: Nat = Depends(dep_node),
) -> dict: ) -> dict:
""" """
Start a packet capture on the node. Start a packet capture on the node.
@ -219,10 +210,7 @@ async def start_nat_node_capture(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop", status_code=status.HTTP_204_NO_CONTENT "/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop", status_code=status.HTTP_204_NO_CONTENT
) )
async def stop_nat_node_capture( async def stop_nat_node_capture(
*, *, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: Nat = Depends(dep_node)
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
node: Nat = Depends(dep_node)
) -> None: ) -> None:
""" """
Stop a packet capture on the node. Stop a packet capture on the node.
@ -234,10 +222,7 @@ async def stop_nat_node_capture(
@router.get("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream") @router.get("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream")
async def stream_pcap_file( async def stream_pcap_file(
*, *, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: Nat = Depends(dep_node)
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
node: Nat = Depends(dep_node)
) -> StreamingResponse: ) -> StreamingResponse:
""" """
Stream the pcap capture file. Stream the pcap capture file.

View File

@ -18,7 +18,6 @@
API routes for compute notifications. API routes for compute notifications.
""" """
from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect
from typing import Union from typing import Union
from websockets.exceptions import ConnectionClosed, WebSocketException from websockets.exceptions import ConnectionClosed, WebSocketException
@ -53,7 +52,6 @@ async def project_ws_notifications(websocket: Union[None, WebSocket] = Depends(w
if __name__ == "__main__": if __name__ == "__main__":
import uvicorn import uvicorn
from fastapi import FastAPI from fastapi import FastAPI
from starlette.responses import HTMLResponse from starlette.responses import HTMLResponse

View File

@ -86,8 +86,7 @@ def create_compute_project(project_data: schemas.ProjectCreate) -> schemas.Proje
@router.put("/projects/{project_id}", response_model=schemas.Project) @router.put("/projects/{project_id}", response_model=schemas.Project)
async def update_compute_project( async def update_compute_project(
project_data: schemas.ProjectUpdate, project_data: schemas.ProjectUpdate, project: Project = Depends(dep_project)
project: Project = Depends(dep_project)
) -> schemas.Project: ) -> schemas.Project:
""" """
Update project on the compute. Update project on the compute.
@ -195,6 +194,7 @@ def _get_existing_nio(node, adapter_number, port_number):
# via get_nio(port). # via get_nio(port).
if hasattr(node, "get_nio"): if hasattr(node, "get_nio"):
import inspect as _inspect import inspect as _inspect
if len(_inspect.signature(node.get_nio).parameters) >= 2: if len(_inspect.signature(node.get_nio).parameters) >= 2:
return node.get_nio(adapter_number, port_number) return node.get_nio(adapter_number, port_number)
return node.get_nio(port_number) return node.get_nio(port_number)
@ -242,9 +242,9 @@ async def _update_nio_binding(node, adapter_number, port_number, nio):
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
) )
async def create_batch_nios( async def create_batch_nios(
project_id: UUID, project_id: UUID,
batch: schemas.BatchNIOCreate, batch: schemas.BatchNIOCreate,
project: Project = Depends(dep_project), project: Project = Depends(dep_project),
) -> dict: ) -> dict:
""" """
Create many NIO bindings across nodes in a single request. Create many NIO bindings across nodes in a single request.
@ -281,9 +281,7 @@ async def create_batch_nios(
nio = node.manager.create_nio(nio_settings) nio = node.manager.create_nio(nio_settings)
await _add_nio_binding(node, entry.adapter_number, entry.port_number, nio) await _add_nio_binding(node, entry.adapter_number, entry.port_number, nio)
await asyncio.gather( await asyncio.gather(*[_create_one_node(nid, ents) for nid, ents in per_node.items()])
*[_create_one_node(nid, ents) for nid, ents in per_node.items()]
)
return {"added": len(batch.nios)} return {"added": len(batch.nios)}
@ -292,9 +290,9 @@ async def create_batch_nios(
status_code=status.HTTP_200_OK, status_code=status.HTTP_200_OK,
) )
async def update_batch_nios( async def update_batch_nios(
project_id: UUID, project_id: UUID,
batch: schemas.BatchNIOCreate, batch: schemas.BatchNIOCreate,
project: Project = Depends(dep_project), project: Project = Depends(dep_project),
) -> dict: ) -> dict:
""" """
Update many NIO bindings (filters + markers) across nodes in a single Update many NIO bindings (filters + markers) across nodes in a single
@ -322,9 +320,7 @@ async def update_batch_nios(
nio.markers = e.nio.markers or {} nio.markers = e.nio.markers or {}
await _update_nio_binding(node, e.adapter_number, e.port_number, nio) await _update_nio_binding(node, e.adapter_number, e.port_number, nio)
await asyncio.gather( await asyncio.gather(*[_update_one_node(nid, ents) for nid, ents in per_node.items()])
*[_update_one_node(nid, ents) for nid, ents in per_node.items()]
)
return {"updated": len(batch.nios)} return {"updated": len(batch.nios)}
@ -343,7 +339,7 @@ async def get_compute_node_files(
node_id: str, node_id: str,
project: Project = Depends(dep_project), project: Project = Depends(dep_project),
path: str = Query("", description="Subdirectory path within node directory"), path: str = Query("", description="Subdirectory path within node directory"),
recursive: bool = Query(False, description="Recursively list all files") recursive: bool = Query(False, description="Recursively list all files"),
) -> List[schemas.NodeFile]: ) -> List[schemas.NodeFile]:
""" """
Return files belonging to a specific node with detailed metadata. Return files belonging to a specific node with detailed metadata.
@ -374,11 +370,7 @@ async def get_compute_project_file(file_path: str, project: Project = Depends(de
@router.post("/projects/{project_id}/files/{file_path:path}", status_code=status.HTTP_204_NO_CONTENT) @router.post("/projects/{project_id}/files/{file_path:path}", status_code=status.HTTP_204_NO_CONTENT)
async def write_compute_project_file( async def write_compute_project_file(file_path: str, request: Request, project: Project = Depends(dep_project)) -> None:
file_path: str,
request: Request,
project: Project = Depends(dep_project)
) -> None:
file_path = urllib.parse.unquote(file_path) file_path = urllib.parse.unquote(file_path)
path = os.path.normpath(file_path) path = os.path.normpath(file_path)
@ -405,10 +397,7 @@ async def write_compute_project_file(
@router.delete("/projects/{project_id}/files/{file_path:path}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/projects/{project_id}/files/{file_path:path}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_compute_project_file( async def delete_compute_project_file(file_path: str, project: Project = Depends(dep_project)) -> None:
file_path: str,
project: Project = Depends(dep_project)
) -> None:
file_path = urllib.parse.unquote(file_path) file_path = urllib.parse.unquote(file_path)
path = os.path.normpath(file_path) path = os.path.normpath(file_path)

View File

@ -33,6 +33,7 @@ from gns3server.compute.qemu.qemu_vm import QemuVM
from .dependencies.authentication import compute_authentication, ws_compute_authentication from .dependencies.authentication import compute_authentication, ws_compute_authentication
import logging import logging
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
responses = {404: {"model": schemas.ErrorMessage, "description": "Could not find project or Qemu node"}} responses = {404: {"model": schemas.ErrorMessage, "description": "Could not find project or Qemu node"}}
@ -55,7 +56,7 @@ def dep_node(project_id: UUID, node_id: UUID) -> QemuVM:
response_model=schemas.Qemu, response_model=schemas.Qemu,
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
responses={409: {"model": schemas.ErrorMessage, "description": "Could not create Qemu node"}}, responses={409: {"model": schemas.ErrorMessage, "description": "Could not create Qemu node"}},
dependencies=[Depends(compute_authentication)] dependencies=[Depends(compute_authentication)],
) )
async def create_qemu_node(project_id: UUID, node_data: schemas.QemuCreate) -> schemas.Qemu: async def create_qemu_node(project_id: UUID, node_data: schemas.QemuCreate) -> schemas.Qemu:
""" """
@ -117,11 +118,7 @@ async def create_qemu_node(project_id: UUID, node_data: schemas.QemuCreate) -> s
return vm.asdict() return vm.asdict()
@router.get( @router.get("/{node_id}", response_model=schemas.Qemu, dependencies=[Depends(compute_authentication)])
"/{node_id}",
response_model=schemas.Qemu,
dependencies=[Depends(compute_authentication)]
)
def get_qemu_node(node: QemuVM = Depends(dep_node)) -> schemas.Qemu: def get_qemu_node(node: QemuVM = Depends(dep_node)) -> schemas.Qemu:
""" """
Return a Qemu node. Return a Qemu node.
@ -130,11 +127,7 @@ def get_qemu_node(node: QemuVM = Depends(dep_node)) -> schemas.Qemu:
return node.asdict() return node.asdict()
@router.put( @router.put("/{node_id}", response_model=schemas.Qemu, dependencies=[Depends(compute_authentication)])
"/{node_id}",
response_model=schemas.Qemu,
dependencies=[Depends(compute_authentication)]
)
async def update_qemu_node(node_data: schemas.QemuUpdate, node: QemuVM = Depends(dep_node)) -> schemas.Qemu: async def update_qemu_node(node_data: schemas.QemuUpdate, node: QemuVM = Depends(dep_node)) -> schemas.Qemu:
""" """
Update a Qemu node. Update a Qemu node.
@ -150,11 +143,7 @@ async def update_qemu_node(node_data: schemas.QemuUpdate, node: QemuVM = Depends
return node.asdict() return node.asdict()
@router.delete( @router.delete("/{node_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
"/{node_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
)
async def delete_qemu_node(node: QemuVM = Depends(dep_node)) -> None: async def delete_qemu_node(node: QemuVM = Depends(dep_node)) -> None:
""" """
Delete a Qemu node. Delete a Qemu node.
@ -167,11 +156,10 @@ async def delete_qemu_node(node: QemuVM = Depends(dep_node)) -> None:
"/{node_id}/duplicate", "/{node_id}/duplicate",
response_model=schemas.Qemu, response_model=schemas.Qemu,
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
dependencies=[Depends(compute_authentication)] dependencies=[Depends(compute_authentication)],
) )
async def duplicate_qemu_node( async def duplicate_qemu_node(
destination_node_id: UUID = Body(..., embed=True), destination_node_id: UUID = Body(..., embed=True), node: QemuVM = Depends(dep_node)
node: QemuVM = Depends(dep_node)
) -> schemas.Qemu: ) -> schemas.Qemu:
""" """
Duplicate a Qemu node. Duplicate a Qemu node.
@ -184,12 +172,10 @@ async def duplicate_qemu_node(
@router.post( @router.post(
"/{node_id}/disk_image/{disk_name}", "/{node_id}/disk_image/{disk_name}",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)] dependencies=[Depends(compute_authentication)],
) )
async def create_qemu_disk_image( async def create_qemu_disk_image(
disk_name: str, disk_name: str, disk_data: schemas.QemuDiskImageCreate, node: QemuVM = Depends(dep_node)
disk_data: schemas.QemuDiskImageCreate,
node: QemuVM = Depends(dep_node)
) -> None: ) -> None:
""" """
Create a Qemu disk image. Create a Qemu disk image.
@ -202,12 +188,10 @@ async def create_qemu_disk_image(
@router.put( @router.put(
"/{node_id}/disk_image/{disk_name}", "/{node_id}/disk_image/{disk_name}",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)] dependencies=[Depends(compute_authentication)],
) )
async def update_qemu_disk_image( async def update_qemu_disk_image(
disk_name: str, disk_name: str, disk_data: schemas.QemuDiskImageUpdate, node: QemuVM = Depends(dep_node)
disk_data: schemas.QemuDiskImageUpdate,
node: QemuVM = Depends(dep_node)
) -> None: ) -> None:
""" """
Update a Qemu disk image. Update a Qemu disk image.
@ -220,12 +204,9 @@ async def update_qemu_disk_image(
@router.delete( @router.delete(
"/{node_id}/disk_image/{disk_name}", "/{node_id}/disk_image/{disk_name}",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)] dependencies=[Depends(compute_authentication)],
) )
async def delete_qemu_disk_image( async def delete_qemu_disk_image(disk_name: str, node: QemuVM = Depends(dep_node)) -> None:
disk_name: str,
node: QemuVM = Depends(dep_node)
) -> None:
""" """
Delete a Qemu disk image. Delete a Qemu disk image.
""" """
@ -233,11 +214,7 @@ async def delete_qemu_disk_image(
node.delete_disk_image(disk_name) node.delete_disk_image(disk_name)
@router.post( @router.post("/{node_id}/start", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
"/{node_id}/start",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
)
async def start_qemu_node(node: QemuVM = Depends(dep_node)) -> None: async def start_qemu_node(node: QemuVM = Depends(dep_node)) -> None:
""" """
Start a Qemu node. Start a Qemu node.
@ -246,11 +223,7 @@ async def start_qemu_node(node: QemuVM = Depends(dep_node)) -> None:
await node.start() await node.start()
@router.post( @router.post("/{node_id}/stop", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
"/{node_id}/stop",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
)
async def stop_qemu_node(node: QemuVM = Depends(dep_node)) -> None: async def stop_qemu_node(node: QemuVM = Depends(dep_node)) -> None:
""" """
Stop a Qemu node. Stop a Qemu node.
@ -260,9 +233,7 @@ async def stop_qemu_node(node: QemuVM = Depends(dep_node)) -> None:
@router.post( @router.post(
"/{node_id}/reload", "/{node_id}/reload", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
) )
async def reload_qemu_node(node: QemuVM = Depends(dep_node)) -> None: async def reload_qemu_node(node: QemuVM = Depends(dep_node)) -> None:
""" """
@ -273,9 +244,7 @@ async def reload_qemu_node(node: QemuVM = Depends(dep_node)) -> None:
@router.post( @router.post(
"/{node_id}/suspend", "/{node_id}/suspend", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
) )
async def suspend_qemu_node(node: QemuVM = Depends(dep_node)) -> None: async def suspend_qemu_node(node: QemuVM = Depends(dep_node)) -> None:
""" """
@ -286,9 +255,7 @@ async def suspend_qemu_node(node: QemuVM = Depends(dep_node)) -> None:
@router.post( @router.post(
"/{node_id}/resume", "/{node_id}/resume", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
) )
async def resume_qemu_node(node: QemuVM = Depends(dep_node)) -> None: async def resume_qemu_node(node: QemuVM = Depends(dep_node)) -> None:
""" """
@ -302,14 +269,14 @@ async def resume_qemu_node(node: QemuVM = Depends(dep_node)) -> None:
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", "/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
response_model=schemas.UDPNIO, response_model=schemas.UDPNIO,
dependencies=[Depends(compute_authentication)] dependencies=[Depends(compute_authentication)],
) )
async def create_qemu_node_nio( async def create_qemu_node_nio(
*, *,
adapter_number: int, adapter_number: int,
port_number: int = Path(..., ge=0, le=0), port_number: int = Path(..., ge=0, le=0),
nio_data: schemas.UDPNIO, nio_data: schemas.UDPNIO,
node: QemuVM = Depends(dep_node) node: QemuVM = Depends(dep_node),
) -> schemas.UDPNIO: ) -> schemas.UDPNIO:
""" """
Add a NIO (Network Input/Output) to the node. Add a NIO (Network Input/Output) to the node.
@ -325,14 +292,14 @@ async def create_qemu_node_nio(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", "/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
response_model=schemas.UDPNIO, response_model=schemas.UDPNIO,
dependencies=[Depends(compute_authentication)] dependencies=[Depends(compute_authentication)],
) )
async def update_qemu_node_nio( async def update_qemu_node_nio(
*, *,
adapter_number: int, adapter_number: int,
port_number: int = Path(..., ge=0, le=0), port_number: int = Path(..., ge=0, le=0),
nio_data: schemas.UDPNIO, nio_data: schemas.UDPNIO,
node: QemuVM = Depends(dep_node) node: QemuVM = Depends(dep_node),
) -> schemas.UDPNIO: ) -> schemas.UDPNIO:
""" """
Update a NIO (Network Input/Output) on the node. Update a NIO (Network Input/Output) on the node.
@ -352,12 +319,10 @@ async def update_qemu_node_nio(
@router.delete( @router.delete(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", "/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)] dependencies=[Depends(compute_authentication)],
) )
async def delete_qemu_node_nio( async def delete_qemu_node_nio(
adapter_number: int, adapter_number: int, port_number: int = Path(..., ge=0, le=0), node: QemuVM = Depends(dep_node)
port_number: int = Path(..., ge=0, le=0),
node: QemuVM = Depends(dep_node)
) -> None: ) -> None:
""" """
Delete a NIO (Network Input/Output) from the node. Delete a NIO (Network Input/Output) from the node.
@ -369,14 +334,14 @@ async def delete_qemu_node_nio(
@router.post( @router.post(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start", "/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start",
dependencies=[Depends(compute_authentication)] dependencies=[Depends(compute_authentication)],
) )
async def start_qemu_node_capture( async def start_qemu_node_capture(
*, *,
adapter_number: int, adapter_number: int,
port_number: int = Path(..., ge=0, le=0), port_number: int = Path(..., ge=0, le=0),
node_capture_data: schemas.NodeCapture, node_capture_data: schemas.NodeCapture,
node: QemuVM = Depends(dep_node) node: QemuVM = Depends(dep_node),
) -> dict: ) -> dict:
""" """
Start a packet capture on the node. Start a packet capture on the node.
@ -391,12 +356,10 @@ async def start_qemu_node_capture(
@router.post( @router.post(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop", "/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)] dependencies=[Depends(compute_authentication)],
) )
async def stop_qemu_node_capture( async def stop_qemu_node_capture(
adapter_number: int, adapter_number: int, port_number: int = Path(..., ge=0, le=0), node: QemuVM = Depends(dep_node)
port_number: int = Path(..., ge=0, le=0),
node: QemuVM = Depends(dep_node)
) -> None: ) -> None:
""" """
Stop a packet capture on the node. Stop a packet capture on the node.
@ -408,12 +371,11 @@ async def stop_qemu_node_capture(
@router.get( @router.get(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream", "/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream",
dependencies=[Depends(compute_authentication)] dependencies=[Depends(compute_authentication)],
) )
async def stream_pcap_file( async def stream_pcap_file(
adapter_number: int, adapter_number: int, port_number: int = Path(..., ge=0, le=0), node: QemuVM = Depends(dep_node)
port_number: int = Path(..., ge=0, le=0), ) -> StreamingResponse:
node: QemuVM = Depends(dep_node)) -> StreamingResponse:
""" """
Stream the pcap capture file. Stream the pcap capture file.
The port number on the Qemu node is always 0. The port number on the Qemu node is always 0.
@ -424,12 +386,9 @@ async def stream_pcap_file(
return StreamingResponse(stream, media_type="application/vnd.tcpdump.pcap") return StreamingResponse(stream, media_type="application/vnd.tcpdump.pcap")
@router.websocket( @router.websocket("/{node_id}/console/ws")
"/{node_id}/console/ws"
)
async def console_ws( async def console_ws(
websocket: Union[None, WebSocket] = Depends(ws_compute_authentication), websocket: Union[None, WebSocket] = Depends(ws_compute_authentication), node: QemuVM = Depends(dep_node)
node: QemuVM = Depends(dep_node)
) -> None: ) -> None:
""" """
Console WebSocket. Console WebSocket.
@ -439,12 +398,9 @@ async def console_ws(
await node.start_websocket_console(websocket) await node.start_websocket_console(websocket)
@router.websocket( @router.websocket("/{node_id}/console/vnc")
"/{node_id}/console/vnc"
)
async def vnc_console_ws( async def vnc_console_ws(
websocket: Union[None, WebSocket] = Depends(ws_compute_authentication), websocket: Union[None, WebSocket] = Depends(ws_compute_authentication), node: QemuVM = Depends(dep_node)
node: QemuVM = Depends(dep_node)
) -> None: ) -> None:
""" """
VNC Console WebSocket. VNC Console WebSocket.
@ -455,23 +411,16 @@ async def vnc_console_ws(
@router.post( @router.post(
"/{node_id}/console/reset", "/{node_id}/console/reset", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
) )
async def reset_console(node: QemuVM = Depends(dep_node)) -> None: async def reset_console(node: QemuVM = Depends(dep_node)) -> None:
await node.reset_console() await node.reset_console()
@router.put( @router.put("/{node_id}/markers/{marker_name}", dependencies=[Depends(compute_authentication)])
"/{node_id}/markers/{marker_name}",
dependencies=[Depends(compute_authentication)]
)
async def toggle_qemu_marker( async def toggle_qemu_marker(
marker_name: str, marker_name: str, toggle_data: schemas.MarkerToggle, node: QemuVM = Depends(dep_node)
toggle_data: schemas.MarkerToggle,
node: QemuVM = Depends(dep_node)
) -> dict: ) -> dict:
""" """
Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2). Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2).
@ -487,9 +436,7 @@ async def toggle_qemu_marker(
@router.post( @router.post(
"/{node_id}/markers/pause", "/{node_id}/markers/pause", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
) )
async def pause_qemu_markers(node: QemuVM = Depends(dep_node)) -> None: async def pause_qemu_markers(node: QemuVM = Depends(dep_node)) -> None:
@ -497,9 +444,7 @@ async def pause_qemu_markers(node: QemuVM = Depends(dep_node)) -> None:
@router.post( @router.post(
"/{node_id}/markers/resume", "/{node_id}/markers/resume", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
) )
async def resume_qemu_markers(node: QemuVM = Depends(dep_node)) -> None: async def resume_qemu_markers(node: QemuVM = Depends(dep_node)) -> None:
@ -509,14 +454,14 @@ async def resume_qemu_markers(node: QemuVM = Depends(dep_node)) -> None:
@router.delete( @router.delete(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/{marker_name}", "/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/{marker_name}",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)] dependencies=[Depends(compute_authentication)],
) )
async def delete_qemu_marker_capture( async def delete_qemu_marker_capture(
marker_name: str, marker_name: str,
adapter_number: int, adapter_number: int,
port_number: int = Path(..., ge=0, le=0), port_number: int = Path(..., ge=0, le=0),
link_id: str = "", link_id: str = "",
node: QemuVM = Depends(dep_node) node: QemuVM = Depends(dep_node),
) -> None: ) -> None:
""" """
Delete a marker's capture pcap (called by the controller when the marker is Delete a marker's capture pcap (called by the controller when the marker is
@ -529,14 +474,9 @@ async def delete_qemu_marker_capture(
await node.delete_marker_capture(marker_name, link_id, nio) await node.delete_marker_capture(marker_name, link_id, nio)
@router.put( @router.put("/{node_id}/markers/{marker_name}/rebuild", dependencies=[Depends(compute_authentication)])
"/{node_id}/markers/{marker_name}/rebuild",
dependencies=[Depends(compute_authentication)]
)
async def rebuild_qemu_marker( async def rebuild_qemu_marker(
marker_name: str, marker_name: str, rebuild_data: schemas.MarkerRebuild, node: QemuVM = Depends(dep_node)
rebuild_data: schemas.MarkerRebuild,
node: QemuVM = Depends(dep_node)
) -> dict: ) -> dict:
""" """
Re-install a single marker filter with new BPF/tag/direction (delete + add, Re-install a single marker filter with new BPF/tag/direction (delete + add,
@ -544,7 +484,11 @@ async def rebuild_qemu_marker(
""" """
await node.rebuild_marker_filter( await node.rebuild_marker_filter(
marker_name, rebuild_data.link_id, rebuild_data.bpf, marker_name,
rebuild_data.tag, rebuild_data.direction, rebuild_data.enabled, rebuild_data.link_id,
rebuild_data.bpf,
rebuild_data.tag,
rebuild_data.direction,
rebuild_data.enabled,
) )
return {"marker_name": marker_name} return {"marker_name": marker_name}

View File

@ -53,7 +53,7 @@ def dep_node(project_id: UUID, node_id: UUID) -> VirtualBoxVM:
response_model=schemas.VirtualBox, response_model=schemas.VirtualBox,
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
responses={409: {"model": schemas.ErrorMessage, "description": "Could not create VirtualBox node"}}, responses={409: {"model": schemas.ErrorMessage, "description": "Could not create VirtualBox node"}},
dependencies=[Depends(compute_authentication)] dependencies=[Depends(compute_authentication)],
) )
async def create_virtualbox_node(project_id: UUID, node_data: schemas.VirtualBoxCreate) -> schemas.VirtualBox: async def create_virtualbox_node(project_id: UUID, node_data: schemas.VirtualBoxCreate) -> schemas.VirtualBox:
""" """
@ -86,11 +86,7 @@ async def create_virtualbox_node(project_id: UUID, node_data: schemas.VirtualBox
return vm.asdict() return vm.asdict()
@router.get( @router.get("/{node_id}", response_model=schemas.VirtualBox, dependencies=[Depends(compute_authentication)])
"/{node_id}",
response_model=schemas.VirtualBox,
dependencies=[Depends(compute_authentication)]
)
def get_virtualbox_node(node: VirtualBoxVM = Depends(dep_node)) -> schemas.VirtualBox: def get_virtualbox_node(node: VirtualBoxVM = Depends(dep_node)) -> schemas.VirtualBox:
""" """
Return a VirtualBox node. Return a VirtualBox node.
@ -99,14 +95,9 @@ def get_virtualbox_node(node: VirtualBoxVM = Depends(dep_node)) -> schemas.Virtu
return node.asdict() return node.asdict()
@router.put( @router.put("/{node_id}", response_model=schemas.VirtualBox, dependencies=[Depends(compute_authentication)])
"/{node_id}",
response_model=schemas.VirtualBox,
dependencies=[Depends(compute_authentication)]
)
async def update_virtualbox_node( async def update_virtualbox_node(
node_data: schemas.VirtualBoxUpdate, node_data: schemas.VirtualBoxUpdate, node: VirtualBoxVM = Depends(dep_node)
node: VirtualBoxVM = Depends(dep_node)
) -> schemas.VirtualBox: ) -> schemas.VirtualBox:
""" """
Update a VirtualBox node. Update a VirtualBox node.
@ -148,11 +139,7 @@ async def update_virtualbox_node(
return node.asdict() return node.asdict()
@router.delete( @router.delete("/{node_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
"/{node_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
)
async def delete_virtualbox_node(node: VirtualBoxVM = Depends(dep_node)) -> None: async def delete_virtualbox_node(node: VirtualBoxVM = Depends(dep_node)) -> None:
""" """
Delete a VirtualBox node. Delete a VirtualBox node.
@ -161,11 +148,7 @@ async def delete_virtualbox_node(node: VirtualBoxVM = Depends(dep_node)) -> None
await VirtualBox.instance().delete_node(node.id) await VirtualBox.instance().delete_node(node.id)
@router.post( @router.post("/{node_id}/start", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
"/{node_id}/start",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
)
async def start_virtualbox_node(node: VirtualBoxVM = Depends(dep_node)) -> None: async def start_virtualbox_node(node: VirtualBoxVM = Depends(dep_node)) -> None:
""" """
Start a VirtualBox node. Start a VirtualBox node.
@ -174,11 +157,7 @@ async def start_virtualbox_node(node: VirtualBoxVM = Depends(dep_node)) -> None:
await node.start() await node.start()
@router.post( @router.post("/{node_id}/stop", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
"/{node_id}/stop",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
)
async def stop_virtualbox_node(node: VirtualBoxVM = Depends(dep_node)) -> None: async def stop_virtualbox_node(node: VirtualBoxVM = Depends(dep_node)) -> None:
""" """
Stop a VirtualBox node. Stop a VirtualBox node.
@ -188,9 +167,7 @@ async def stop_virtualbox_node(node: VirtualBoxVM = Depends(dep_node)) -> None:
@router.post( @router.post(
"/{node_id}/suspend", "/{node_id}/suspend", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
) )
async def suspend_virtualbox_node(node: VirtualBoxVM = Depends(dep_node)) -> None: async def suspend_virtualbox_node(node: VirtualBoxVM = Depends(dep_node)) -> None:
""" """
@ -201,9 +178,7 @@ async def suspend_virtualbox_node(node: VirtualBoxVM = Depends(dep_node)) -> Non
@router.post( @router.post(
"/{node_id}/resume", "/{node_id}/resume", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
) )
async def resume_virtualbox_node(node: VirtualBoxVM = Depends(dep_node)) -> None: async def resume_virtualbox_node(node: VirtualBoxVM = Depends(dep_node)) -> None:
""" """
@ -214,9 +189,7 @@ async def resume_virtualbox_node(node: VirtualBoxVM = Depends(dep_node)) -> None
@router.post( @router.post(
"/{node_id}/reload", "/{node_id}/reload", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
) )
async def reload_virtualbox_node(node: VirtualBoxVM = Depends(dep_node)) -> None: async def reload_virtualbox_node(node: VirtualBoxVM = Depends(dep_node)) -> None:
""" """
@ -230,14 +203,14 @@ async def reload_virtualbox_node(node: VirtualBoxVM = Depends(dep_node)) -> None
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", "/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
response_model=schemas.UDPNIO, response_model=schemas.UDPNIO,
dependencies=[Depends(compute_authentication)] dependencies=[Depends(compute_authentication)],
) )
async def create_virtualbox_node_nio( async def create_virtualbox_node_nio(
*, *,
adapter_number: int, adapter_number: int,
port_number: int = Path(..., ge=0, le=0), port_number: int = Path(..., ge=0, le=0),
nio_data: schemas.UDPNIO, nio_data: schemas.UDPNIO,
node: VirtualBoxVM = Depends(dep_node) node: VirtualBoxVM = Depends(dep_node),
) -> schemas.UDPNIO: ) -> schemas.UDPNIO:
""" """
Add a NIO (Network Input/Output) to the node. Add a NIO (Network Input/Output) to the node.
@ -253,14 +226,14 @@ async def create_virtualbox_node_nio(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", "/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
response_model=schemas.UDPNIO, response_model=schemas.UDPNIO,
dependencies=[Depends(compute_authentication)] dependencies=[Depends(compute_authentication)],
) )
async def update_virtualbox_node_nio( async def update_virtualbox_node_nio(
*, *,
adapter_number: int, adapter_number: int,
port_number: int = Path(..., ge=0, le=0), port_number: int = Path(..., ge=0, le=0),
nio_data: schemas.UDPNIO, nio_data: schemas.UDPNIO,
node: VirtualBoxVM = Depends(dep_node) node: VirtualBoxVM = Depends(dep_node),
) -> schemas.UDPNIO: ) -> schemas.UDPNIO:
""" """
Update a NIO (Network Input/Output) on the node. Update a NIO (Network Input/Output) on the node.
@ -279,12 +252,10 @@ async def update_virtualbox_node_nio(
@router.delete( @router.delete(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", "/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)] dependencies=[Depends(compute_authentication)],
) )
async def delete_virtualbox_node_nio( async def delete_virtualbox_node_nio(
adapter_number: int, adapter_number: int, port_number: int = Path(..., ge=0, le=0), node: VirtualBoxVM = Depends(dep_node)
port_number: int = Path(..., ge=0, le=0),
node: VirtualBoxVM = Depends(dep_node)
) -> None: ) -> None:
""" """
Delete a NIO (Network Input/Output) from the node. Delete a NIO (Network Input/Output) from the node.
@ -296,14 +267,14 @@ async def delete_virtualbox_node_nio(
@router.post( @router.post(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start", "/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start",
dependencies=[Depends(compute_authentication)] dependencies=[Depends(compute_authentication)],
) )
async def start_virtualbox_node_capture( async def start_virtualbox_node_capture(
*, *,
adapter_number: int, adapter_number: int,
port_number: int = Path(..., ge=0, le=0), port_number: int = Path(..., ge=0, le=0),
node_capture_data: schemas.NodeCapture, node_capture_data: schemas.NodeCapture,
node: VirtualBoxVM = Depends(dep_node), node: VirtualBoxVM = Depends(dep_node),
) -> dict: ) -> dict:
""" """
Start a packet capture on the node. Start a packet capture on the node.
@ -318,12 +289,10 @@ async def start_virtualbox_node_capture(
@router.post( @router.post(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop", "/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)] dependencies=[Depends(compute_authentication)],
) )
async def stop_virtualbox_node_capture( async def stop_virtualbox_node_capture(
adapter_number: int, adapter_number: int, port_number: int = Path(..., ge=0, le=0), node: VirtualBoxVM = Depends(dep_node)
port_number: int = Path(..., ge=0, le=0),
node: VirtualBoxVM = Depends(dep_node)
) -> None: ) -> None:
""" """
Stop a packet capture on the node. Stop a packet capture on the node.
@ -335,12 +304,10 @@ async def stop_virtualbox_node_capture(
@router.get( @router.get(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream", "/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream",
dependencies=[Depends(compute_authentication)] dependencies=[Depends(compute_authentication)],
) )
async def stream_pcap_file( async def stream_pcap_file(
adapter_number: int, adapter_number: int, port_number: int = Path(..., ge=0, le=0), node: VirtualBoxVM = Depends(dep_node)
port_number: int = Path(..., ge=0, le=0),
node: VirtualBoxVM = Depends(dep_node)
) -> StreamingResponse: ) -> StreamingResponse:
""" """
Stream the pcap capture file. Stream the pcap capture file.
@ -352,12 +319,9 @@ async def stream_pcap_file(
return StreamingResponse(stream, media_type="application/vnd.tcpdump.pcap") return StreamingResponse(stream, media_type="application/vnd.tcpdump.pcap")
@router.websocket( @router.websocket("/{node_id}/console/ws")
"/{node_id}/console/ws"
)
async def console_ws( async def console_ws(
websocket: Union[None, WebSocket] = Depends(ws_compute_authentication), websocket: Union[None, WebSocket] = Depends(ws_compute_authentication), node: VirtualBoxVM = Depends(dep_node)
node: VirtualBoxVM = Depends(dep_node)
) -> None: ) -> None:
""" """
Console WebSocket. Console WebSocket.
@ -367,11 +331,8 @@ async def console_ws(
@router.post( @router.post(
"/{node_id}/console/reset", "/{node_id}/console/reset", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
) )
async def reset_console(node: VirtualBoxVM = Depends(dep_node)) -> None: async def reset_console(node: VirtualBoxVM = Depends(dep_node)) -> None:
await node.reset_console() await node.reset_console()

View File

@ -52,7 +52,7 @@ def dep_node(project_id: UUID, node_id: UUID) -> VMwareVM:
response_model=schemas.VMware, response_model=schemas.VMware,
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
responses={409: {"model": schemas.ErrorMessage, "description": "Could not create VMware node"}}, responses={409: {"model": schemas.ErrorMessage, "description": "Could not create VMware node"}},
dependencies=[Depends(compute_authentication)] dependencies=[Depends(compute_authentication)],
) )
async def create_vmware_node(project_id: UUID, node_data: schemas.VMwareCreate) -> schemas.VMware: async def create_vmware_node(project_id: UUID, node_data: schemas.VMwareCreate) -> schemas.VMware:
""" """
@ -79,11 +79,7 @@ async def create_vmware_node(project_id: UUID, node_data: schemas.VMwareCreate)
return vm.asdict() return vm.asdict()
@router.get( @router.get("/{node_id}", response_model=schemas.VMware, dependencies=[Depends(compute_authentication)])
"/{node_id}",
response_model=schemas.VMware,
dependencies=[Depends(compute_authentication)]
)
def get_vmware_node(node: VMwareVM = Depends(dep_node)) -> schemas.VMware: def get_vmware_node(node: VMwareVM = Depends(dep_node)) -> schemas.VMware:
""" """
Return a VMware node. Return a VMware node.
@ -92,11 +88,7 @@ def get_vmware_node(node: VMwareVM = Depends(dep_node)) -> schemas.VMware:
return node.asdict() return node.asdict()
@router.put( @router.put("/{node_id}", response_model=schemas.VMware, dependencies=[Depends(compute_authentication)])
"/{node_id}",
response_model=schemas.VMware,
dependencies=[Depends(compute_authentication)]
)
async def update_vmware_node(node_data: schemas.VMwareUpdate, node: VMwareVM = Depends(dep_node)) -> schemas.VMware: async def update_vmware_node(node_data: schemas.VMwareUpdate, node: VMwareVM = Depends(dep_node)) -> schemas.VMware:
""" """
Update a VMware node. Update a VMware node.
@ -113,11 +105,7 @@ async def update_vmware_node(node_data: schemas.VMwareUpdate, node: VMwareVM = D
return node.asdict() return node.asdict()
@router.delete( @router.delete("/{node_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
"/{node_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
)
async def delete_vmware_node(node: VMwareVM = Depends(dep_node)) -> None: async def delete_vmware_node(node: VMwareVM = Depends(dep_node)) -> None:
""" """
Delete a VMware node. Delete a VMware node.
@ -126,11 +114,7 @@ async def delete_vmware_node(node: VMwareVM = Depends(dep_node)) -> None:
await VMware.instance().delete_node(node.id) await VMware.instance().delete_node(node.id)
@router.post( @router.post("/{node_id}/start", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
"/{node_id}/start",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
)
async def start_vmware_node(node: VMwareVM = Depends(dep_node)) -> None: async def start_vmware_node(node: VMwareVM = Depends(dep_node)) -> None:
""" """
Start a VMware node. Start a VMware node.
@ -139,11 +123,7 @@ async def start_vmware_node(node: VMwareVM = Depends(dep_node)) -> None:
await node.start() await node.start()
@router.post( @router.post("/{node_id}/stop", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
"/{node_id}/stop",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
)
async def stop_vmware_node(node: VMwareVM = Depends(dep_node)) -> None: async def stop_vmware_node(node: VMwareVM = Depends(dep_node)) -> None:
""" """
Stop a VMware node. Stop a VMware node.
@ -153,9 +133,7 @@ async def stop_vmware_node(node: VMwareVM = Depends(dep_node)) -> None:
@router.post( @router.post(
"/{node_id}/suspend", "/{node_id}/suspend", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
) )
async def suspend_vmware_node(node: VMwareVM = Depends(dep_node)) -> None: async def suspend_vmware_node(node: VMwareVM = Depends(dep_node)) -> None:
""" """
@ -166,9 +144,7 @@ async def suspend_vmware_node(node: VMwareVM = Depends(dep_node)) -> None:
@router.post( @router.post(
"/{node_id}/resume", "/{node_id}/resume", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
) )
async def resume_vmware_node(node: VMwareVM = Depends(dep_node)) -> None: async def resume_vmware_node(node: VMwareVM = Depends(dep_node)) -> None:
""" """
@ -179,9 +155,7 @@ async def resume_vmware_node(node: VMwareVM = Depends(dep_node)) -> None:
@router.post( @router.post(
"/{node_id}/reload", "/{node_id}/reload", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
) )
async def reload_vmware_node(node: VMwareVM = Depends(dep_node)) -> None: async def reload_vmware_node(node: VMwareVM = Depends(dep_node)) -> None:
""" """
@ -195,14 +169,14 @@ async def reload_vmware_node(node: VMwareVM = Depends(dep_node)) -> None:
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", "/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
response_model=schemas.UDPNIO, response_model=schemas.UDPNIO,
dependencies=[Depends(compute_authentication)] dependencies=[Depends(compute_authentication)],
) )
async def create_vmware_node_nio( async def create_vmware_node_nio(
*, *,
adapter_number: int, adapter_number: int,
port_number: int = Path(..., ge=0, le=0), port_number: int = Path(..., ge=0, le=0),
nio_data: schemas.UDPNIO, nio_data: schemas.UDPNIO,
node: VMwareVM = Depends(dep_node) node: VMwareVM = Depends(dep_node),
) -> schemas.UDPNIO: ) -> schemas.UDPNIO:
""" """
Add a NIO (Network Input/Output) to the node. Add a NIO (Network Input/Output) to the node.
@ -218,14 +192,14 @@ async def create_vmware_node_nio(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", "/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
response_model=schemas.UDPNIO, response_model=schemas.UDPNIO,
dependencies=[Depends(compute_authentication)] dependencies=[Depends(compute_authentication)],
) )
async def update_vmware_node_nio( async def update_vmware_node_nio(
*, *,
adapter_number: int, adapter_number: int,
port_number: int = Path(..., ge=0, le=0), port_number: int = Path(..., ge=0, le=0),
nio_data: schemas.UDPNIO, nio_data: schemas.UDPNIO,
node: VMwareVM = Depends(dep_node) node: VMwareVM = Depends(dep_node),
) -> schemas.UDPNIO: ) -> schemas.UDPNIO:
""" """
Update a NIO (Network Input/Output) on the node. Update a NIO (Network Input/Output) on the node.
@ -243,12 +217,10 @@ async def update_vmware_node_nio(
@router.delete( @router.delete(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", "/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)] dependencies=[Depends(compute_authentication)],
) )
async def delete_vmware_node_nio( async def delete_vmware_node_nio(
adapter_number: int, adapter_number: int, port_number: int = Path(..., ge=0, le=0), node: VMwareVM = Depends(dep_node)
port_number: int = Path(..., ge=0, le=0),
node: VMwareVM = Depends(dep_node)
) -> None: ) -> None:
""" """
Delete a NIO (Network Input/Output) from the node. Delete a NIO (Network Input/Output) from the node.
@ -260,14 +232,14 @@ async def delete_vmware_node_nio(
@router.post( @router.post(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start", "/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start",
dependencies=[Depends(compute_authentication)] dependencies=[Depends(compute_authentication)],
) )
async def start_vmware_node_capture( async def start_vmware_node_capture(
*, *,
adapter_number: int, adapter_number: int,
port_number: int = Path(..., ge=0, le=0), port_number: int = Path(..., ge=0, le=0),
node_capture_data: schemas.NodeCapture, node_capture_data: schemas.NodeCapture,
node: VMwareVM = Depends(dep_node) node: VMwareVM = Depends(dep_node),
) -> dict: ) -> dict:
""" """
Start a packet capture on the node. Start a packet capture on the node.
@ -282,12 +254,10 @@ async def start_vmware_node_capture(
@router.post( @router.post(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop", "/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)] dependencies=[Depends(compute_authentication)],
) )
async def stop_vmware_node_capture( async def stop_vmware_node_capture(
adapter_number: int, adapter_number: int, port_number: int = Path(..., ge=0, le=0), node: VMwareVM = Depends(dep_node)
port_number: int = Path(..., ge=0, le=0),
node: VMwareVM = Depends(dep_node)
) -> None: ) -> None:
""" """
Stop a packet capture on the node. Stop a packet capture on the node.
@ -299,12 +269,10 @@ async def stop_vmware_node_capture(
@router.get( @router.get(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream", "/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream",
dependencies=[Depends(compute_authentication)] dependencies=[Depends(compute_authentication)],
) )
async def stream_pcap_file( async def stream_pcap_file(
adapter_number: int, adapter_number: int, port_number: int = Path(..., ge=0, le=0), node: VMwareVM = Depends(dep_node)
port_number: int = Path(..., ge=0, le=0),
node: VMwareVM = Depends(dep_node)
) -> StreamingResponse: ) -> StreamingResponse:
""" """
Stream the pcap capture file. Stream the pcap capture file.
@ -317,9 +285,7 @@ async def stream_pcap_file(
@router.post( @router.post(
"/{node_id}/interfaces/vmnet", "/{node_id}/interfaces/vmnet", status_code=status.HTTP_201_CREATED, dependencies=[Depends(compute_authentication)]
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(compute_authentication)]
) )
def allocate_vmnet(node: VMwareVM = Depends(dep_node)) -> dict: def allocate_vmnet(node: VMwareVM = Depends(dep_node)) -> dict:
""" """
@ -335,8 +301,7 @@ def allocate_vmnet(node: VMwareVM = Depends(dep_node)) -> dict:
@router.websocket("/{node_id}/console/ws") @router.websocket("/{node_id}/console/ws")
async def console_ws( async def console_ws(
websocket: Union[None, WebSocket] = Depends(ws_compute_authentication), websocket: Union[None, WebSocket] = Depends(ws_compute_authentication), node: VMwareVM = Depends(dep_node)
node: VMwareVM = Depends(dep_node)
) -> None: ) -> None:
""" """
Console WebSocket. Console WebSocket.
@ -347,9 +312,7 @@ async def console_ws(
@router.post( @router.post(
"/{node_id}/console/reset", "/{node_id}/console/reset", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
) )
async def reset_console(node: VMwareVM = Depends(dep_node)) -> None: async def reset_console(node: VMwareVM = Depends(dep_node)) -> None:

View File

@ -51,7 +51,7 @@ def dep_node(project_id: UUID, node_id: UUID) -> VPCSVM:
response_model=schemas.VPCS, response_model=schemas.VPCS,
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
responses={409: {"model": schemas.ErrorMessage, "description": "Could not create VMware node"}}, responses={409: {"model": schemas.ErrorMessage, "description": "Could not create VMware node"}},
dependencies=[Depends(compute_authentication)] dependencies=[Depends(compute_authentication)],
) )
async def create_vpcs_node(project_id: UUID, node_data: schemas.VPCSCreate) -> schemas.VPCS: async def create_vpcs_node(project_id: UUID, node_data: schemas.VPCSCreate) -> schemas.VPCS:
""" """
@ -72,11 +72,7 @@ async def create_vpcs_node(project_id: UUID, node_data: schemas.VPCSCreate) -> s
return vm.asdict() return vm.asdict()
@router.get( @router.get("/{node_id}", response_model=schemas.VPCS, dependencies=[Depends(compute_authentication)])
"/{node_id}",
response_model=schemas.VPCS,
dependencies=[Depends(compute_authentication)]
)
def get_vpcs_node(node: VPCSVM = Depends(dep_node)) -> schemas.VPCS: def get_vpcs_node(node: VPCSVM = Depends(dep_node)) -> schemas.VPCS:
""" """
Return a VPCS node. Return a VPCS node.
@ -85,11 +81,7 @@ def get_vpcs_node(node: VPCSVM = Depends(dep_node)) -> schemas.VPCS:
return node.asdict() return node.asdict()
@router.put( @router.put("/{node_id}", response_model=schemas.VPCS, dependencies=[Depends(compute_authentication)])
"/{node_id}",
response_model=schemas.VPCS,
dependencies=[Depends(compute_authentication)]
)
async def update_vpcs_node(node_data: schemas.VPCSUpdate, node: VPCSVM = Depends(dep_node)) -> schemas.VPCS: async def update_vpcs_node(node_data: schemas.VPCSUpdate, node: VPCSVM = Depends(dep_node)) -> schemas.VPCS:
""" """
Update a VPCS node. Update a VPCS node.
@ -109,11 +101,7 @@ async def update_vpcs_node(node_data: schemas.VPCSUpdate, node: VPCSVM = Depends
return node.asdict() return node.asdict()
@router.delete( @router.delete("/{node_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
"/{node_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
)
async def delete_vpcs_node(node: VPCSVM = Depends(dep_node)) -> None: async def delete_vpcs_node(node: VPCSVM = Depends(dep_node)) -> None:
""" """
Delete a VPCS node. Delete a VPCS node.
@ -126,11 +114,11 @@ async def delete_vpcs_node(node: VPCSVM = Depends(dep_node)) -> None:
"/{node_id}/duplicate", "/{node_id}/duplicate",
response_model=schemas.VPCS, response_model=schemas.VPCS,
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
dependencies=[Depends(compute_authentication)] dependencies=[Depends(compute_authentication)],
) )
async def duplicate_vpcs_node( async def duplicate_vpcs_node(
destination_node_id: UUID = Body(..., embed=True), destination_node_id: UUID = Body(..., embed=True), node: VPCSVM = Depends(dep_node)
node: VPCSVM = Depends(dep_node)) -> None: ) -> None:
""" """
Duplicate a VPCS node. Duplicate a VPCS node.
""" """
@ -139,11 +127,7 @@ async def duplicate_vpcs_node(
return new_node.asdict() return new_node.asdict()
@router.post( @router.post("/{node_id}/start", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
"/{node_id}/start",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
)
async def start_vpcs_node(node: VPCSVM = Depends(dep_node)) -> None: async def start_vpcs_node(node: VPCSVM = Depends(dep_node)) -> None:
""" """
Start a VPCS node. Start a VPCS node.
@ -152,11 +136,7 @@ async def start_vpcs_node(node: VPCSVM = Depends(dep_node)) -> None:
await node.start() await node.start()
@router.post( @router.post("/{node_id}/stop", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
"/{node_id}/stop",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
)
async def stop_vpcs_node(node: VPCSVM = Depends(dep_node)) -> None: async def stop_vpcs_node(node: VPCSVM = Depends(dep_node)) -> None:
""" """
Stop a VPCS node. Stop a VPCS node.
@ -166,9 +146,7 @@ async def stop_vpcs_node(node: VPCSVM = Depends(dep_node)) -> None:
@router.post( @router.post(
"/{node_id}/suspend", "/{node_id}/suspend", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
) )
async def suspend_vpcs_node(node: VPCSVM = Depends(dep_node)) -> None: async def suspend_vpcs_node(node: VPCSVM = Depends(dep_node)) -> None:
""" """
@ -176,15 +154,12 @@ async def suspend_vpcs_node(node: VPCSVM = Depends(dep_node)) -> None:
""" """
raise HTTPException( raise HTTPException(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED, status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Suspend is not supported for VPCS nodes"
detail="Suspend is not supported for VPCS nodes"
) )
@router.post( @router.post(
"/{node_id}/reload", "/{node_id}/reload", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
) )
async def reload_vpcs_node(node: VPCSVM = Depends(dep_node)) -> None: async def reload_vpcs_node(node: VPCSVM = Depends(dep_node)) -> None:
""" """
@ -198,14 +173,14 @@ async def reload_vpcs_node(node: VPCSVM = Depends(dep_node)) -> None:
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", "/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
response_model=schemas.UDPNIO, response_model=schemas.UDPNIO,
dependencies=[Depends(compute_authentication)] dependencies=[Depends(compute_authentication)],
) )
async def create_vpcs_node_nio( async def create_vpcs_node_nio(
*, *,
adapter_number: int = Path(..., ge=0, le=0), adapter_number: int = Path(..., ge=0, le=0),
port_number: int, port_number: int,
nio_data: schemas.UDPNIO, nio_data: schemas.UDPNIO,
node: VPCSVM = Depends(dep_node) node: VPCSVM = Depends(dep_node),
) -> schemas.UDPNIO: ) -> schemas.UDPNIO:
""" """
Add a NIO (Network Input/Output) to the node. Add a NIO (Network Input/Output) to the node.
@ -221,14 +196,14 @@ async def create_vpcs_node_nio(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", "/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
response_model=schemas.UDPNIO, response_model=schemas.UDPNIO,
dependencies=[Depends(compute_authentication)] dependencies=[Depends(compute_authentication)],
) )
async def update_vpcs_node_nio( async def update_vpcs_node_nio(
*, *,
adapter_number: int = Path(..., ge=0, le=0), adapter_number: int = Path(..., ge=0, le=0),
port_number: int, port_number: int,
nio_data: schemas.UDPNIO, nio_data: schemas.UDPNIO,
node: VPCSVM = Depends(dep_node) node: VPCSVM = Depends(dep_node),
) -> schemas.UDPNIO: ) -> schemas.UDPNIO:
""" """
Update a NIO (Network Input/Output) on the node. Update a NIO (Network Input/Output) on the node.
@ -247,13 +222,10 @@ async def update_vpcs_node_nio(
@router.delete( @router.delete(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", "/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)] dependencies=[Depends(compute_authentication)],
) )
async def delete_vpcs_node_nio( async def delete_vpcs_node_nio(
*, *, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: VPCSVM = Depends(dep_node)
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
node: VPCSVM = Depends(dep_node)
) -> None: ) -> None:
""" """
Delete a NIO (Network Input/Output) from the node. Delete a NIO (Network Input/Output) from the node.
@ -265,14 +237,14 @@ async def delete_vpcs_node_nio(
@router.post( @router.post(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start", "/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start",
dependencies=[Depends(compute_authentication)] dependencies=[Depends(compute_authentication)],
) )
async def start_vpcs_node_capture( async def start_vpcs_node_capture(
*, *,
adapter_number: int = Path(..., ge=0, le=0), adapter_number: int = Path(..., ge=0, le=0),
port_number: int, port_number: int,
node_capture_data: schemas.NodeCapture, node_capture_data: schemas.NodeCapture,
node: VPCSVM = Depends(dep_node) node: VPCSVM = Depends(dep_node),
) -> dict: ) -> dict:
""" """
Start a packet capture on the node. Start a packet capture on the node.
@ -287,13 +259,10 @@ async def start_vpcs_node_capture(
@router.post( @router.post(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop", "/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)] dependencies=[Depends(compute_authentication)],
) )
async def stop_vpcs_node_capture( async def stop_vpcs_node_capture(
*, *, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: VPCSVM = Depends(dep_node)
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
node: VPCSVM = Depends(dep_node)
) -> None: ) -> None:
""" """
Stop a packet capture on the node. Stop a packet capture on the node.
@ -303,16 +272,12 @@ async def stop_vpcs_node_capture(
await node.stop_capture(port_number) await node.stop_capture(port_number)
@router.get( @router.get(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream", "/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream",
dependencies=[Depends(compute_authentication)] dependencies=[Depends(compute_authentication)],
) )
async def stream_pcap_file( async def stream_pcap_file(
*, *, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: VPCSVM = Depends(dep_node)
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
node: VPCSVM = Depends(dep_node)
) -> StreamingResponse: ) -> StreamingResponse:
""" """
Stream the pcap capture file. Stream the pcap capture file.
@ -324,12 +289,10 @@ async def stream_pcap_file(
return StreamingResponse(stream, media_type="application/vnd.tcpdump.pcap") return StreamingResponse(stream, media_type="application/vnd.tcpdump.pcap")
@router.websocket( @router.websocket("/{node_id}/console/ws")
"/{node_id}/console/ws"
)
async def console_ws( async def console_ws(
websocket: Union[None, WebSocket] = Depends(ws_compute_authentication), websocket: Union[None, WebSocket] = Depends(ws_compute_authentication), node: VPCSVM = Depends(dep_node)
node: VPCSVM = Depends(dep_node)) -> None: ) -> None:
""" """
Console WebSocket. Console WebSocket.
""" """
@ -338,23 +301,16 @@ async def console_ws(
@router.post( @router.post(
"/{node_id}/console/reset", "/{node_id}/console/reset", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
) )
async def reset_console(node: VPCSVM = Depends(dep_node)) -> None: async def reset_console(node: VPCSVM = Depends(dep_node)) -> None:
await node.reset_console() await node.reset_console()
@router.put( @router.put("/{node_id}/markers/{marker_name}", dependencies=[Depends(compute_authentication)])
"/{node_id}/markers/{marker_name}",
dependencies=[Depends(compute_authentication)]
)
async def toggle_vpcs_marker( async def toggle_vpcs_marker(
marker_name: str, marker_name: str, toggle_data: schemas.MarkerToggle, node: VPCSVM = Depends(dep_node)
toggle_data: schemas.MarkerToggle,
node: VPCSVM = Depends(dep_node)
) -> dict: ) -> dict:
""" """
Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2). Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2).
@ -370,9 +326,7 @@ async def toggle_vpcs_marker(
@router.post( @router.post(
"/{node_id}/markers/pause", "/{node_id}/markers/pause", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
) )
async def pause_vpcs_markers(node: VPCSVM = Depends(dep_node)) -> None: async def pause_vpcs_markers(node: VPCSVM = Depends(dep_node)) -> None:
@ -380,9 +334,7 @@ async def pause_vpcs_markers(node: VPCSVM = Depends(dep_node)) -> None:
@router.post( @router.post(
"/{node_id}/markers/resume", "/{node_id}/markers/resume", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
) )
async def resume_vpcs_markers(node: VPCSVM = Depends(dep_node)) -> None: async def resume_vpcs_markers(node: VPCSVM = Depends(dep_node)) -> None:
@ -392,7 +344,7 @@ async def resume_vpcs_markers(node: VPCSVM = Depends(dep_node)) -> None:
@router.delete( @router.delete(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/{marker_name}", "/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/{marker_name}",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)] dependencies=[Depends(compute_authentication)],
) )
async def delete_vpcs_marker_capture( async def delete_vpcs_marker_capture(
*, *,
@ -400,7 +352,7 @@ async def delete_vpcs_marker_capture(
adapter_number: int = Path(..., ge=0, le=0), adapter_number: int = Path(..., ge=0, le=0),
port_number: int, port_number: int,
link_id: str = "", link_id: str = "",
node: VPCSVM = Depends(dep_node) node: VPCSVM = Depends(dep_node),
) -> None: ) -> None:
""" """
Delete a marker's capture pcap (called by the controller when the marker is Delete a marker's capture pcap (called by the controller when the marker is
@ -413,14 +365,9 @@ async def delete_vpcs_marker_capture(
await node.delete_marker_capture(marker_name, link_id, nio) await node.delete_marker_capture(marker_name, link_id, nio)
@router.put( @router.put("/{node_id}/markers/{marker_name}/rebuild", dependencies=[Depends(compute_authentication)])
"/{node_id}/markers/{marker_name}/rebuild",
dependencies=[Depends(compute_authentication)]
)
async def rebuild_vpcs_marker( async def rebuild_vpcs_marker(
marker_name: str, marker_name: str, rebuild_data: schemas.MarkerRebuild, node: VPCSVM = Depends(dep_node)
rebuild_data: schemas.MarkerRebuild,
node: VPCSVM = Depends(dep_node)
) -> dict: ) -> dict:
""" """
Re-install a single marker filter with new BPF/tag/direction (delete + add, Re-install a single marker filter with new BPF/tag/direction (delete + add,
@ -428,7 +375,11 @@ async def rebuild_vpcs_marker(
""" """
await node.rebuild_marker_filter( await node.rebuild_marker_filter(
marker_name, rebuild_data.link_id, rebuild_data.bpf, marker_name,
rebuild_data.tag, rebuild_data.direction, rebuild_data.enabled, rebuild_data.link_id,
rebuild_data.bpf,
rebuild_data.tag,
rebuild_data.direction,
rebuild_data.enabled,
) )
return {"marker_name": marker_name} return {"marker_name": marker_name}

View File

@ -24,6 +24,7 @@ if AI_COPILOT_AVAILABLE:
from . import chat from . import chat
from . import copilot from . import copilot
from . import llm_model_configs from . import llm_model_configs
_chat_router = chat.router _chat_router = chat.router
_copilot_router = copilot.router _copilot_router = copilot.router
_llm_router = llm_model_configs.router _llm_router = llm_model_configs.router
@ -39,9 +40,10 @@ else:
async def ai_not_available(path: str = ""): async def ai_not_available(path: str = ""):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED, status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="AI Copilot is not available. Install AI dependencies with: pip install gns3-server[ai-features]" detail="AI Copilot is not available. Install AI dependencies with: pip install gns3-server[ai-features]",
) )
from . import controller from . import controller
from . import appliances from . import appliances
from . import computes from . import computes
@ -68,148 +70,65 @@ from .dependencies.authentication import get_current_active_user
router = APIRouter() router = APIRouter()
router.include_router(controller.router, tags=["Controller"])
router.include_router(settings.router, prefix="/settings", tags=["Server settings"])
router.include_router(users.router, prefix="/access/users", tags=["Users"])
router.include_router(groups.router, prefix="/access/groups", tags=["Users groups"])
router.include_router(roles.router, prefix="/access/roles", tags=["Roles"])
router.include_router( router.include_router(
controller.router, privileges.router, dependencies=[Depends(get_current_active_user)], prefix="/access/privileges", tags=["Privileges"]
tags=["Controller"] )
router.include_router(acl.router, prefix="/access/acl", tags=["ACL"])
router.include_router(images.router, prefix="/images", tags=["Images"])
router.include_router(templates.router, prefix="/templates", tags=["Templates"])
router.include_router(projects.router, prefix="/projects", tags=["Projects"])
router.include_router(nodes.router, prefix="/projects/{project_id}/nodes", tags=["Nodes"])
router.include_router(links.router, prefix="/projects/{project_id}/links", tags=["Links"])
router.include_router(drawings.router, prefix="/projects/{project_id}/drawings", tags=["Drawings"])
router.include_router(symbols.router, prefix="/symbols", tags=["Symbols"])
router.include_router(snapshots.router, prefix="/projects/{project_id}/snapshots", tags=["Snapshots"])
router.include_router(
computes.router, dependencies=[Depends(get_current_active_user)], prefix="/computes", tags=["Computes"]
)
router.include_router(appliances.router, prefix="/appliances", tags=["Appliances"])
router.include_router(netmiko.router, prefix="/netmiko", tags=["Netmiko"])
router.include_router(pools.router, prefix="/pools", tags=["Resource pools"])
router.include_router(
gns3vm.router, dependencies=[Depends(get_current_active_user)], deprecated=True, prefix="/gns3vm", tags=["GNS3 VM"]
) )
router.include_router( router.include_router(
settings.router, _llm_router, prefix="/access", dependencies=[Depends(get_current_active_user)], tags=["LLM Model Configurations"]
prefix="/settings",
tags=["Server settings"]
) )
router.include_router( router.include_router(
users.router, _copilot_router, prefix="/copilot", dependencies=[Depends(get_current_active_user)], tags=["GNS3 Copilot"]
prefix="/access/users",
tags=["Users"]
)
router.include_router(
groups.router,
prefix="/access/groups",
tags=["Users groups"]
)
router.include_router(
roles.router,
prefix="/access/roles",
tags=["Roles"]
)
router.include_router(
privileges.router,
dependencies=[Depends(get_current_active_user)],
prefix="/access/privileges",
tags=["Privileges"]
)
router.include_router(
acl.router,
prefix="/access/acl",
tags=["ACL"]
)
router.include_router(
images.router,
prefix="/images",
tags=["Images"]
)
router.include_router(
templates.router,
prefix="/templates",
tags=["Templates"]
)
router.include_router(
projects.router,
prefix="/projects",
tags=["Projects"])
router.include_router(
nodes.router,
prefix="/projects/{project_id}/nodes",
tags=["Nodes"]
)
router.include_router(
links.router,
prefix="/projects/{project_id}/links",
tags=["Links"]
)
router.include_router(
drawings.router,
prefix="/projects/{project_id}/drawings",
tags=["Drawings"])
router.include_router(
symbols.router,
prefix="/symbols", tags=["Symbols"]
)
router.include_router(
snapshots.router,
prefix="/projects/{project_id}/snapshots",
tags=["Snapshots"])
router.include_router(
computes.router,
dependencies=[Depends(get_current_active_user)],
prefix="/computes",
tags=["Computes"]
)
router.include_router(
appliances.router,
prefix="/appliances",
tags=["Appliances"]
)
router.include_router(
netmiko.router,
prefix="/netmiko",
tags=["Netmiko"]
)
router.include_router(
pools.router,
prefix="/pools",
tags=["Resource pools"]
)
router.include_router(
gns3vm.router,
dependencies=[Depends(get_current_active_user)],
deprecated=True,
prefix="/gns3vm",
tags=["GNS3 VM"]
)
router.include_router(
_llm_router,
prefix="/access",
dependencies=[Depends(get_current_active_user)],
tags=["LLM Model Configurations"]
)
router.include_router(
_copilot_router,
prefix="/copilot",
dependencies=[Depends(get_current_active_user)],
tags=["GNS3 Copilot"]
) )
router.include_router( router.include_router(
_chat_router, _chat_router,
prefix="/copilot/projects/{project_id}/chat", prefix="/copilot/projects/{project_id}/chat",
dependencies=[Depends(get_current_active_user)], dependencies=[Depends(get_current_active_user)],
tags=["GNS3 Copilot"] tags=["GNS3 Copilot"],
) )
router.include_router( router.include_router(api_keys.router, dependencies=[Depends(get_current_active_user)], tags=["API Keys"])
api_keys.router,
dependencies=[Depends(get_current_active_user)],
tags=["API Keys"]
)

View File

@ -29,10 +29,7 @@ from typing import Iterator, List, Sequence
from gns3server import schemas from gns3server import schemas
from gns3server.controller.controller_error import ( from gns3server.controller.controller_error import ControllerBadRequestError, ControllerNotFoundError
ControllerBadRequestError,
ControllerNotFoundError
)
from gns3server.controller import Controller from gns3server.controller import Controller
from gns3server.db.repositories.users import UsersRepository from gns3server.db.repositories.users import UsersRepository
@ -69,7 +66,9 @@ def _iter_route_paths(routes: Sequence[BaseRoute], prefix: str = "", include_mou
for route in routes: for route in routes:
if isinstance(route, _IncludedRouter): if isinstance(route, _IncludedRouter):
include_prefix = route.include_context.prefix or "" include_prefix = route.include_context.prefix or ""
yield from _iter_route_paths(route.original_router.routes, _join_paths(prefix, include_prefix), include_mounted_routes) yield from _iter_route_paths(
route.original_router.routes, _join_paths(prefix, include_prefix), include_mounted_routes
)
continue continue
if isinstance(route, APIRoute): if isinstance(route, APIRoute):
@ -82,16 +81,12 @@ def _iter_route_paths(routes: Sequence[BaseRoute], prefix: str = "", include_mou
yield from _iter_route_paths(mounted_routes, _join_paths(prefix, route.path), include_mounted_routes) yield from _iter_route_paths(mounted_routes, _join_paths(prefix, route.path), include_mounted_routes)
@router.get( @router.get("/endpoints", status_code=status.HTTP_201_CREATED, dependencies=[Depends(has_privilege("ACE.Audit"))])
"/endpoints",
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(has_privilege("ACE.Audit"))]
)
async def endpoints( async def endpoints(
users_repo: UsersRepository = Depends(get_repository(UsersRepository)), users_repo: UsersRepository = Depends(get_repository(UsersRepository)),
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)), rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)),
images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)), images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)),
templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)) templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)),
) -> List[dict]: ) -> List[dict]:
""" """
List all endpoints to be used in ACL entries. List all endpoints to be used in ACL entries.
@ -123,7 +118,7 @@ async def endpoints(
add_to_endpoints( add_to_endpoints(
f"/projects/{project.id}/nodes/{node['node_id']}", f"/projects/{project.id}/nodes/{node['node_id']}",
f'Node "{node["name"]}" in project "{project.name}"', f'Node "{node["name"]}" in project "{project.name}"',
endpoint_type="node" endpoint_type="node",
) )
# links # links
@ -140,7 +135,7 @@ async def endpoints(
add_to_endpoints( add_to_endpoints(
f"/projects/{project.id}/links/{link['link_id']}", f"/projects/{project.id}/links/{link['link_id']}",
f'Link from "{node_name_1}" to "{node_name_2}" in project "{project.name}"', f'Link from "{node_name_1}" to "{node_name_2}" in project "{project.name}"',
endpoint_type="link" endpoint_type="link",
) )
# users # users
@ -179,14 +174,8 @@ async def endpoints(
return endpoints return endpoints
@router.get( @router.get("", response_model=List[schemas.ACE], dependencies=[Depends(has_privilege("ACE.Audit"))])
"", async def get_aces(rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))) -> List[schemas.ACE]:
response_model=List[schemas.ACE],
dependencies=[Depends(has_privilege("ACE.Audit"))]
)
async def get_aces(
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
) -> List[schemas.ACE]:
""" """
Get all ACL entries. Get all ACL entries.
@ -200,12 +189,10 @@ async def get_aces(
"", "",
response_model=schemas.ACE, response_model=schemas.ACE,
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
dependencies=[Depends(has_privilege("ACE.Allocate"))] dependencies=[Depends(has_privilege("ACE.Allocate"))],
) )
async def create_ace( async def create_ace(
request: Request, request: Request, ace_create: schemas.ACECreate, rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
ace_create: schemas.ACECreate,
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
) -> schemas.ACE: ) -> schemas.ACE:
""" """
Create a new ACL entry. Create a new ACL entry.
@ -217,11 +204,12 @@ async def create_ace(
print(route_path) print(route_path)
for route_path in _iter_route_paths(request.app.routes, include_mounted_routes=True): for route_path in _iter_route_paths(request.app.routes, include_mounted_routes=True):
# remove the prefix (e.g. "/v3") from the route path # remove the prefix (e.g. "/v3") from the route path
normalized_path = re.sub(r"^/v[0-9]", "", route_path) normalized_path = re.sub(r"^/v[0-9]", "", route_path)
# replace route path ID parameters by a UUID regex # replace route path ID parameters by a UUID regex
normalized_path = re.sub(r"{\w+_id}", "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}", normalized_path) normalized_path = re.sub(
r"{\w+_id}", "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}", normalized_path
)
# replace remaining route path parameters by a word matching regex # replace remaining route path parameters by a word matching regex
normalized_path = re.sub(r"/{[\w:]+}", r"/\\w+", normalized_path) normalized_path = re.sub(r"/{[\w:]+}", r"/\\w+", normalized_path)
@ -232,14 +220,10 @@ async def create_ace(
raise ControllerBadRequestError(f"Path '{ace_create.path}' doesn't match any existing endpoint") raise ControllerBadRequestError(f"Path '{ace_create.path}' doesn't match any existing endpoint")
@router.get( @router.get("/{ace_id}", response_model=schemas.ACE, dependencies=[Depends(has_privilege("ACE.Audit"))])
"/{ace_id}",
response_model=schemas.ACE,
dependencies=[Depends(has_privilege("ACE.Audit"))]
)
async def get_ace( async def get_ace(
ace_id: UUID, ace_id: UUID,
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)), rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)),
) -> schemas.ACE: ) -> schemas.ACE:
""" """
Get an ACL entry. Get an ACL entry.
@ -253,15 +237,9 @@ async def get_ace(
return ace return ace
@router.put( @router.put("/{ace_id}", response_model=schemas.ACE, dependencies=[Depends(has_privilege("ACE.Modify"))])
"/{ace_id}",
response_model=schemas.ACE,
dependencies=[Depends(has_privilege("ACE.Modify"))]
)
async def update_ace( async def update_ace(
ace_id: UUID, ace_id: UUID, ace_update: schemas.ACEUpdate, rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
ace_update: schemas.ACEUpdate,
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
) -> schemas.ACE: ) -> schemas.ACE:
""" """
Update an ACL entry. Update an ACL entry.
@ -277,9 +255,7 @@ async def update_ace(
@router.delete( @router.delete(
"/{ace_id}", "/{ace_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(has_privilege("ACE.Allocate"))]
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(has_privilege("ACE.Allocate"))]
) )
async def delete_ace( async def delete_ace(
ace_id: UUID, ace_id: UUID,

View File

@ -26,11 +26,7 @@ from uuid import UUID
from gns3server import schemas from gns3server import schemas
from gns3server.controller import Controller from gns3server.controller import Controller
from gns3server.controller.controller_error import ( from gns3server.controller.controller_error import ControllerError, ControllerBadRequestError, ControllerNotFoundError
ControllerError,
ControllerBadRequestError,
ControllerNotFoundError
)
from gns3server.db.repositories.images import ImagesRepository from gns3server.db.repositories.images import ImagesRepository
from gns3server.db.repositories.templates import TemplatesRepository from gns3server.db.repositories.templates import TemplatesRepository
@ -50,12 +46,9 @@ router = APIRouter()
"", "",
response_model=List[schemas.Appliance], response_model=List[schemas.Appliance],
response_model_exclude_unset=True, response_model_exclude_unset=True,
dependencies=[Depends(has_privilege("Appliance.Audit"))] dependencies=[Depends(has_privilege("Appliance.Audit"))],
) )
async def get_appliances( async def get_appliances(update: Optional[bool] = False, symbol_theme: Optional[str] = None) -> List[schemas.Appliance]:
update: Optional[bool] = False,
symbol_theme: Optional[str] = None
) -> List[schemas.Appliance]:
""" """
Return all appliances known by the controller. Return all appliances known by the controller.
@ -73,7 +66,7 @@ async def get_appliances(
"/{appliance_id}", "/{appliance_id}",
response_model=schemas.Appliance, response_model=schemas.Appliance,
response_model_exclude_unset=True, response_model_exclude_unset=True,
dependencies=[Depends(has_privilege("Appliance.Audit"))] dependencies=[Depends(has_privilege("Appliance.Audit"))],
) )
def get_appliance(appliance_id: UUID) -> schemas.Appliance: def get_appliance(appliance_id: UUID) -> schemas.Appliance:
""" """
@ -92,9 +85,11 @@ def get_appliance(appliance_id: UUID) -> schemas.Appliance:
@router.post( @router.post(
"/{appliance_id}/version", "/{appliance_id}/version",
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
dependencies=[Depends(has_privilege("Appliance.Allocate"))] dependencies=[Depends(has_privilege("Appliance.Allocate"))],
) )
def add_appliance_version(appliance_id: UUID, appliance_version: Union[schemas.ApplianceVersion, schemas.ApplianceVersionV8]) -> dict: def add_appliance_version(
appliance_id: UUID, appliance_version: Union[schemas.ApplianceVersion, schemas.ApplianceVersionV8]
) -> dict:
""" """
Add a version to an appliance. Add a version to an appliance.
@ -124,15 +119,15 @@ def add_appliance_version(appliance_id: UUID, appliance_version: Union[schemas.A
"/{appliance_id}/install", "/{appliance_id}/install",
response_model=schemas.Template, response_model=schemas.Template,
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
dependencies=[Depends(has_privilege("Appliance.Allocate"))] dependencies=[Depends(has_privilege("Appliance.Allocate"))],
) )
async def install_appliance( async def install_appliance(
appliance_id: UUID, appliance_id: UUID,
version: Optional[str] = None, version: Optional[str] = None,
images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)), images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)),
templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)), templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)),
current_user: schemas.User = Depends(get_current_active_user), current_user: schemas.User = Depends(get_current_active_user),
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)) rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)),
) -> schemas.Template: ) -> schemas.Template:
""" """
Install an appliance and return the created template. Install an appliance and return the created template.
@ -142,10 +137,5 @@ async def install_appliance(
controller = Controller.instance() controller = Controller.instance()
return await controller.appliance_manager.install_appliance( return await controller.appliance_manager.install_appliance(
appliance_id, appliance_id, version, images_repo, templates_repo, rbac_repo, current_user
version,
images_repo,
templates_repo,
rbac_repo,
current_user
) )

View File

@ -69,7 +69,7 @@ def dep_project(project_id: UUID) -> Project:
"/stream", "/stream",
response_model=None, response_model=None,
summary="Stream chat responses from GNS3 Copilot", summary="Stream chat responses from GNS3 Copilot",
description="Send a message to GNS3 Copilot and stream the response via Server-Sent Events (SSE)." description="Send a message to GNS3 Copilot and stream the response via Server-Sent Events (SSE).",
) )
async def stream_chat( async def stream_chat(
request: schemas.ChatRequest, request: schemas.ChatRequest,
@ -96,11 +96,11 @@ async def stream_chat(
"Chat rejected: project not opened. user_id=%s, project_id=%s, status=%s", "Chat rejected: project not opened. user_id=%s, project_id=%s, status=%s",
user_id, user_id,
project.id, project.id,
project.status project.status,
) )
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail=f"Project must be opened to use chat. Current status: {project.status}" detail=f"Project must be opened to use chat. Current status: {project.status}",
) )
log.info( log.info(
@ -124,7 +124,7 @@ async def stream_chat(
log.warning("LLM config not found for user: %s", user_id) log.warning("LLM config not found for user: %s", user_id)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="LLM configuration not found. Please configure your LLM settings first." detail="LLM configuration not found. Please configure your LLM settings first.",
) )
log.debug( log.debug(
@ -161,7 +161,7 @@ async def stream_chat(
user_id=user_id, user_id=user_id,
jwt_token=jwt_token, jwt_token=jwt_token,
mode=request.mode, mode=request.mode,
llm_config=llm_config llm_config=llm_config,
): ):
try: try:
# Validate and serialize chunk # Validate and serialize chunk
@ -187,7 +187,7 @@ async def stream_chat(
"Cache-Control": "no-cache", "Cache-Control": "no-cache",
"X-Accel-Buffering": "no", "X-Accel-Buffering": "no",
"Connection": "keep-alive", "Connection": "keep-alive",
} },
) )
@ -195,7 +195,7 @@ async def stream_chat(
"/sessions", "/sessions",
response_model=List[schemas.ChatSession], response_model=List[schemas.ChatSession],
summary="List chat sessions", summary="List chat sessions",
description="List all chat sessions for a project, optionally filtered by copilot_mode." description="List all chat sessions for a project, optionally filtered by copilot_mode.",
) )
async def list_sessions( async def list_sessions(
project: Project = Depends(dep_project), project: Project = Depends(dep_project),
@ -213,7 +213,7 @@ async def list_sessions(
if project.status != "opened": if project.status != "opened":
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail=f"Project must be opened to access chat sessions. Current status: {project.status}" detail=f"Project must be opened to access chat sessions. Current status: {project.status}",
) )
# Get AgentService for this project # Get AgentService for this project
@ -221,10 +221,7 @@ async def list_sessions(
agent_service = await agent_manager.get_agent(str(project.id), project.path) agent_service = await agent_manager.get_agent(str(project.id), project.path)
# List sessions with optional copilot_mode filter # List sessions with optional copilot_mode filter
sessions = await agent_service.list_sessions( sessions = await agent_service.list_sessions(user_id=str(current_user.user_id), copilot_mode=copilot_mode)
user_id=str(current_user.user_id),
copilot_mode=copilot_mode
)
# Convert to schemas # Convert to schemas
return [schemas.ChatSession(**s) for s in sessions] return [schemas.ChatSession(**s) for s in sessions]
@ -234,7 +231,7 @@ async def list_sessions(
"/sessions/{session_id}/history", "/sessions/{session_id}/history",
response_model=schemas.ConversationHistory, response_model=schemas.ConversationHistory,
summary="Get conversation history", summary="Get conversation history",
description="Retrieve the conversation history for a specific session/thread." description="Retrieve the conversation history for a specific session/thread.",
) )
async def get_history( async def get_history(
session_id: str, session_id: str,
@ -250,7 +247,7 @@ async def get_history(
if project.status != "opened": if project.status != "opened":
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail=f"Project must be opened to access chat history. Current status: {project.status}" detail=f"Project must be opened to access chat history. Current status: {project.status}",
) )
# Get AgentService for this project # Get AgentService for this project
@ -267,7 +264,7 @@ async def get_history(
"/sessions/{session_id}", "/sessions/{session_id}",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
summary="Delete a chat session", summary="Delete a chat session",
description="Delete a specific chat session and its checkpoints." description="Delete a specific chat session and its checkpoints.",
) )
async def delete_session( async def delete_session(
session_id: str, session_id: str,
@ -282,7 +279,7 @@ async def delete_session(
if project.status != "opened": if project.status != "opened":
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail=f"Project must be opened to delete chat sessions. Current status: {project.status}" detail=f"Project must be opened to delete chat sessions. Current status: {project.status}",
) )
# Get AgentService for this project # Get AgentService for this project
@ -293,17 +290,14 @@ async def delete_session(
deleted = await agent_service.delete_session(session_id) deleted = await agent_service.delete_session(session_id)
if not deleted: if not deleted:
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Session '{session_id}' not found")
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Session '{session_id}' not found"
)
@router.post( @router.post(
"/sessions/{session_id}/abort", "/sessions/{session_id}/abort",
status_code=status.HTTP_200_OK, status_code=status.HTTP_200_OK,
summary="Abort a streaming session", summary="Abort a streaming session",
description="Abort an ongoing streaming session for a specific session." description="Abort an ongoing streaming session for a specific session.",
) )
async def abort_session( async def abort_session(
session_id: str, session_id: str,
@ -321,7 +315,7 @@ async def abort_session(
if project.status != "opened": if project.status != "opened":
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail=f"Project must be opened to abort chat session. Current status: {project.status}" detail=f"Project must be opened to abort chat session. Current status: {project.status}",
) )
# Get AgentService for this project # Get AgentService for this project
@ -338,7 +332,7 @@ async def abort_session(
"/sessions/{session_id}", "/sessions/{session_id}",
response_model=schemas.ChatSession, response_model=schemas.ChatSession,
summary="Rename a chat session", summary="Rename a chat session",
description="Rename a specific chat session." description="Rename a specific chat session.",
) )
async def rename_session( async def rename_session(
session_id: str, session_id: str,
@ -354,7 +348,7 @@ async def rename_session(
if project.status != "opened": if project.status != "opened":
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail=f"Project must be opened to rename chat sessions. Current status: {project.status}" detail=f"Project must be opened to rename chat sessions. Current status: {project.status}",
) )
# Get AgentService for this project # Get AgentService for this project
@ -365,10 +359,7 @@ async def rename_session(
session = await agent_service.rename_session(session_id, request.title) session = await agent_service.rename_session(session_id, request.title)
if not session: if not session:
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Session '{session_id}' not found")
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Session '{session_id}' not found"
)
return schemas.ChatSession(**session) return schemas.ChatSession(**session)
@ -377,7 +368,7 @@ async def rename_session(
"/sessions/{session_id}/pin", "/sessions/{session_id}/pin",
response_model=schemas.ChatSession, response_model=schemas.ChatSession,
summary="Pin a chat session", summary="Pin a chat session",
description="Pin a chat session to the top of the list." description="Pin a chat session to the top of the list.",
) )
async def pin_session( async def pin_session(
session_id: str, session_id: str,
@ -392,7 +383,7 @@ async def pin_session(
if project.status != "opened": if project.status != "opened":
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail=f"Project must be opened to pin chat sessions. Current status: {project.status}" detail=f"Project must be opened to pin chat sessions. Current status: {project.status}",
) )
# Get AgentService for this project # Get AgentService for this project
@ -403,10 +394,7 @@ async def pin_session(
session = await agent_service.pin_session(session_id, pinned=True) session = await agent_service.pin_session(session_id, pinned=True)
if not session: if not session:
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Session '{session_id}' not found")
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Session '{session_id}' not found"
)
return schemas.ChatSession(**session) return schemas.ChatSession(**session)
@ -415,7 +403,7 @@ async def pin_session(
"/sessions/{session_id}/pin", "/sessions/{session_id}/pin",
response_model=schemas.ChatSession, response_model=schemas.ChatSession,
summary="Unpin a chat session", summary="Unpin a chat session",
description="Unpin a chat session from the top of the list." description="Unpin a chat session from the top of the list.",
) )
async def unpin_session( async def unpin_session(
session_id: str, session_id: str,
@ -430,7 +418,7 @@ async def unpin_session(
if project.status != "opened": if project.status != "opened":
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail=f"Project must be opened to unpin chat sessions. Current status: {project.status}" detail=f"Project must be opened to unpin chat sessions. Current status: {project.status}",
) )
# Get AgentService for this project # Get AgentService for this project
@ -441,10 +429,7 @@ async def unpin_session(
session = await agent_service.pin_session(session_id, pinned=False) session = await agent_service.pin_session(session_id, pinned=False)
if not session: if not session:
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Session '{session_id}' not found")
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Session '{session_id}' not found"
)
return schemas.ChatSession(**session) return schemas.ChatSession(**session)
@ -453,7 +438,7 @@ async def unpin_session(
"/inject", "/inject",
response_model=None, response_model=None,
summary="Inject a network fault for troubleshooting practice", summary="Inject a network fault for troubleshooting practice",
description="Inject a realistic network fault into the GNS3 lab for troubleshooting training." description="Inject a realistic network fault into the GNS3 lab for troubleshooting training.",
) )
async def inject_issue( async def inject_issue(
request: schemas.ChatRequest, request: schemas.ChatRequest,
@ -482,11 +467,11 @@ async def inject_issue(
"Fault injection rejected: project not opened. user_id=%s, project_id=%s, status=%s", "Fault injection rejected: project not opened. user_id=%s, project_id=%s, status=%s",
user_id, user_id,
project.id, project.id,
project.status project.status,
) )
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail=f"Project must be opened to use fault injection. Current status: {project.status}" detail=f"Project must be opened to use fault injection. Current status: {project.status}",
) )
log.info( log.info(
@ -510,7 +495,7 @@ async def inject_issue(
log.warning("LLM config not found for user: %s", user_id) log.warning("LLM config not found for user: %s", user_id)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="LLM configuration not found. Please configure your LLM settings first." detail="LLM configuration not found. Please configure your LLM settings first.",
) )
# Override copilot_mode to troubleshooting_injection # Override copilot_mode to troubleshooting_injection
@ -546,7 +531,7 @@ async def inject_issue(
user_id=user_id, user_id=user_id,
jwt_token=jwt_token, jwt_token=jwt_token,
mode=request.mode, mode=request.mode,
llm_config=llm_config llm_config=llm_config,
): ):
try: try:
# Validate and serialize chunk # Validate and serialize chunk
@ -572,5 +557,5 @@ async def inject_issue(
"Cache-Control": "no-cache", "Cache-Control": "no-cache",
"X-Accel-Buffering": "no", "X-Accel-Buffering": "no",
"Connection": "keep-alive", "Connection": "keep-alive",
} },
) )

Some files were not shown because too many files have changed in this diff Show More