Merge branch '3.1' into update-dependencies

This commit is contained in:
Jeremy Grossmann 2026-09-27 19:01:34 +02:00 committed by GitHub
commit 539d9f94f9
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
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
```

View File

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

View File

@ -21,26 +21,23 @@ jobs:
steps:
- uses: actions/checkout@v7
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
cache: pip
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install .[ai-features,dev]
- name: Install sharkd (marker replay engine)
run: |
sudo apt-get update
sudo apt-get install -y wireshark-common
- name: Lint with flake8
- name: Run all checks
run: |
# stop the build if there are Python syntax errors or undefined names
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
just test

4
.gitignore vendored
View File

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

View File

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

View File

@ -1,7 +1,10 @@
pytest==9.1.1
flake8==7.3.0
pytest-timeout==2.4.0
pytest-asyncio==1.4.0
httpx==0.28.1
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
@app.exception_handler(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}")
return JSONResponse(
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
# Direct commands (single/batch)
device_config_send(project_id, device_configs=[
{"device_name": "R1", "config_commands": ["int lo0", "ip add 1.1.1.1 255.255.255.255"]},
])
device_config_send(
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)
device_config_send(project_id,
device_config_send(
project_id,
template="interface lo{{ n }}\nip address {{ ip }} 255.255.255.255",
device_configs=[
{"device_name": "R1", "vars": {"n": 0, "ip": "1.1.1.1"}},
{"device_name": "R2", "vars": {"n": 0, "ip": "2.2.2.2"}},
])
],
)
# Show commands with template
device_show_run(project_id,
device_show_run(
project_id,
template="show ip route {{ protocol }}",
device_configs=[
{"device_name": "R1", "vars": {"protocol": "ospf"}},
{"device_name": "R2", "vars": {"protocol": "bgp"}},
])
],
)
```
### Best Practices
@ -273,9 +280,12 @@ device_show_run(project_id,
```python
# Save config on device
device_show_run(project_id, device_configs=[
{"device_name": "R1", "commands": ["write memory"]},
])
device_show_run(
project_id,
device_configs=[
{"device_name": "R1", "commands": ["write memory"]},
],
)
# Backup
config = node_file_get(project_id, node_id, "startup-config.cfg")
# Restore if config breaks

View File

@ -51,8 +51,8 @@ Each IOU VM creation spawned `ld-linux --verify` and `iou-image -h` subprocesses
```python
# Class-level caches shared across all instances
IOUVM._loader_cache = {} # image path → loader command
IOUVM._default_values_cache = {} # image path → (ram, nvram)
IOUVM._loader_cache = {} # image path → loader command
IOUVM._default_values_cache = {} # image path → (ram, nvram)
```
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
tool_call_id=tool_call["id"],
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**:
```python
router.include_router(
chat.router,
prefix="/{project_id}/chat",
tags=["Chat"]
)
router.include_router(chat.router, prefix="/{project_id}/chat", tags=["Chat"])
```
**Main Endpoint Implementation**:

View File

@ -79,9 +79,11 @@ PC1> ping 10.10.0.254
def check_config_mode(self) -> bool:
return False # VPCS has no config mode
def config_mode(self) -> str:
return "" # No config mode to enter
def exit_config_mode(self) -> str:
return "" # No config mode to exit
```
@ -354,6 +356,7 @@ The custom driver must be registered with Netmiko's global mappings:
```python
def register_custom_device_type() -> None:
import importlib
sd = importlib.import_module("netmiko.ssh_dispatcher")
# 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**
```python
INTERACTIVE_PATTERNS = [
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'^delete\s+', re.IGNORECASE), # delete files
re.compile(r'^format\s+', re.IGNORECASE), # format filesystem
re.compile(r'^reload\b', re.IGNORECASE), # reload/reboot
re.compile(r'^boot\s+system\s+', re.IGNORECASE), # change boot image
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"^delete\s+", re.IGNORECASE), # delete files
re.compile(r"^format\s+", re.IGNORECASE), # format filesystem
re.compile(r"^reload\b", re.IGNORECASE), # reload/reboot
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
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import (
build_gns3_ctx, create_node_handler, create_link_handler,
get_nodes_handler, start_node_handler, stop_node_handler,
suspend_node_handler, update_node_handler,
build_gns3_ctx,
create_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
@ -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
tool = GNS3StartNodeTool()
result = tool._run(json.dumps({
"project_id": "abc-123-def",
"node_ids": ["node-1", "node-2", "node-3"]
}))
result = tool._run(json.dumps({"project_id": "abc-123-def", "node_ids": ["node-1", "node-2", "node-3"]}))
# 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
tool = GNS3StopNodeTool()
result = tool._run(json.dumps({
"project_id": "abc-123-def",
"node_ids": ["node-1", "node-2"]
}))
result = tool._run(json.dumps({"project_id": "abc-123-def", "node_ids": ["node-1", "node-2"]}))
# Immediate return with stop status
```
@ -677,19 +676,13 @@ result = tool._run(json.dumps({
```python
# Lab deployment
start_tool = GNS3StartNodeTool()
start_result = start_tool._run(json.dumps({
"project_id": project_id,
"node_ids": all_node_ids
}))
start_result = start_tool._run(json.dumps({"project_id": project_id, "node_ids": all_node_ids}))
# ... Run tests ...
# Lab shutdown
stop_tool = GNS3StopNodeTool()
stop_result = stop_tool._run(json.dumps({
"project_id": project_id,
"node_ids": all_node_ids
}))
stop_result = stop_tool._run(json.dumps({"project_id": project_id, "node_ids": all_node_ids}))
```
### Example 4: Lab Pause and Resume
@ -699,56 +692,45 @@ from gns3server.agent.gns3_copilot.tools_v2 import GNS3SuspendNodeTool
# Start lab
start_tool = GNS3StartNodeTool()
start_result = start_tool._run(json.dumps({
"project_id": project_id,
"node_ids": all_node_ids
}))
start_result = start_tool._run(json.dumps({"project_id": project_id, "node_ids": all_node_ids}))
# ... Configure devices ...
# ... Run some tests ...
# Suspend lab (preserves all state)
suspend_tool = GNS3SuspendNodeTool()
suspend_result = suspend_tool._run(json.dumps({
"project_id": project_id,
"node_ids": all_node_ids
}))
suspend_result = suspend_tool._run(json.dumps({"project_id": project_id, "node_ids": all_node_ids}))
# Nodes suspended - state preserved in memory
# ... Take a break, work on something else ...
# Resume lab (quick recovery)
start_result = start_tool._run(json.dumps({
"project_id": project_id,
"node_ids": all_node_ids
}))
start_result = start_tool._run(json.dumps({"project_id": project_id, "node_ids": all_node_ids}))
# Back to previous state in seconds!
```
### Example 5: Suspend While Renaming Nodes
```python
from gns3server.agent.gns3_copilot.tools_v2 import (
GNS3SuspendNodeTool,
GNS3UpdateNodeNameTool
)
from gns3server.agent.gns3_copilot.tools_v2 import GNS3SuspendNodeTool, GNS3UpdateNodeNameTool
# Suspend nodes first (allows renaming)
suspend_tool = GNS3SuspendNodeTool()
suspend_result = suspend_tool._run(json.dumps({
"project_id": project_id,
"node_ids": node_ids
}))
suspend_result = suspend_tool._run(json.dumps({"project_id": project_id, "node_ids": node_ids}))
# Now rename nodes (possible while suspended!)
rename_tool = GNS3UpdateNodeNameTool()
rename_result = rename_tool._run(json.dumps({
"project_id": project_id,
"nodes": [
{"node_id": "node-1", "new_name": "Router-Primary"},
{"node_id": "node-2", "new_name": "Router-Backup"}
]
}))
rename_result = rename_tool._run(
json.dumps(
{
"project_id": project_id,
"nodes": [
{"node_id": "node-1", "new_name": "Router-Primary"},
{"node_id": "node-2", "new_name": "Router-Backup"},
],
}
)
)
# Resume when ready
# 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
tool = GNS3CreateNodeTool()
result = tool._run(json.dumps({
"project_id": "abc-123-def",
"nodes": [
result = tool._run(
json.dumps(
{
"template_id": "uuid-of-router-template",
"x": 100,
"y": -200
},
{
"template_id": "uuid-of-switch-template",
"x": -200,
"y": 300
"project_id": "abc-123-def",
"nodes": [
{"template_id": "uuid-of-router-template", "x": 100, "y": -200},
{"template_id": "uuid-of-switch-template", "x": -200, "y": 300},
],
}
]
}))
)
)
# 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
tool = GNS3LinkTool()
result = tool._run(json.dumps({
"project_id": "abc-123-def",
"links": [
result = tool._run(
json.dumps(
{
"node_id1": "uuid-of-node1",
"port1": "Ethernet0/0",
"node_id2": "uuid-of-node2",
"port2": "Ethernet0/0"
},
{
"node_id1": "uuid-of-node1",
"port1": "Ethernet0/1",
"node_id2": "uuid-of-node3",
"port2": "Ethernet0/0"
"project_id": "abc-123-def",
"links": [
{
"node_id1": "uuid-of-node1",
"port1": "Ethernet0/0",
"node_id2": "uuid-of-node2",
"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
```
@ -829,15 +811,19 @@ result = tool._run(json.dumps({
from gns3server.agent.gns3_copilot.tools_v2 import GNS3UpdateNodeNameTool
tool = GNS3UpdateNodeNameTool()
result = tool._run(json.dumps({
"project_id": "abc-123-def",
"nodes": [
{"node_id": "node-1", "new_name": "R1-Core"},
{"node_id": "node-2", "new_name": "R2-Core"},
{"node_id": "node-3", "new_name": "S1-Access"},
{"node_id": "node-4", "new_name": "S2-Access"}
]
}))
result = tool._run(
json.dumps(
{
"project_id": "abc-123-def",
"nodes": [
{"node_id": "node-1", "new_name": "R1-Core"},
{"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
```
@ -850,7 +836,7 @@ from gns3server.agent.gns3_copilot.tools_v2 import (
GNS3CreateNodeTool,
GNS3LinkTool,
GNS3UpdateNodeNameTool,
GNS3StartNodeTool
GNS3StartNodeTool,
)
# Step 1: Get available templates
@ -860,44 +846,63 @@ templates = template_tool._run("")
# Step 2: Create nodes
create_tool = GNS3CreateNodeTool()
nodes = create_tool._run(json.dumps({
"project_id": project_id,
"nodes": [
{"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}
]
}))
nodes = create_tool._run(
json.dumps(
{
"project_id": project_id,
"nodes": [
{"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
link_tool = GNS3LinkTool()
links = link_tool._run(json.dumps({
"project_id": project_id,
"links": [
{"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"}
]
}))
links = link_tool._run(
json.dumps(
{
"project_id": project_id,
"links": [
{
"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
name_tool = GNS3UpdateNodeNameTool()
names = name_tool._run(json.dumps({
"project_id": project_id,
"nodes": [
{"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"}
]
}))
names = name_tool._run(
json.dumps(
{
"project_id": project_id,
"nodes": [
{"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
start_tool = GNS3StartNodeTool()
start_result = start_tool._run(json.dumps({
"project_id": project_id,
"node_ids": [n["node_id"] for n in nodes["created_nodes"]]
}))
start_result = start_tool._run(
json.dumps({"project_id": project_id, "node_ids": [n["node_id"] for n in nodes["created_nodes"]]})
)
```
## Error Handling

View File

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

View File

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

View File

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

View File

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

View File

@ -67,9 +67,12 @@ logger = logging.getLogger(__name__)
# Initialize tiktoken encoding (required dependency)
import time
logger.debug("Initializing tiktoken encoding (cl100k_base)...")
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()
_tiktoken_encoding = tiktoken.get_encoding("cl100k_base")
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:
try:
# Try Pydantic v2 method (model_json_schema)
tool_schema["function"]["parameters"] = (
tool.args_schema.model_json_schema()
)
tool_schema["function"]["parameters"] = tool.args_schema.model_json_schema()
except AttributeError:
# Fallback to Pydantic v1 method (schema)
try:
tool_schema["function"]["parameters"] = (
tool.args_schema.schema()
)
tool_schema["function"]["parameters"] = tool.args_schema.schema()
except Exception:
# Both methods failed, use empty schema
tool_name = getattr(tool, "name", "unknown")
logger.debug(
"Failed to get schema for tool %s, using empty "
"parameters",
"Failed to get schema for tool %s, using empty parameters",
tool_name,
)
tool_schema["function"]["parameters"] = {}
@ -171,15 +169,12 @@ def estimate_tool_tokens(tools: list[Any]) -> int:
# model_json_schema() raised an exception
tool_name = getattr(tool, "name", "unknown")
logger.debug(
"model_json_schema() failed for tool %s: %s, "
"trying v1 fallback",
"model_json_schema() failed for tool %s: %s, trying v1 fallback",
tool_name,
e,
)
try:
tool_schema["function"]["parameters"] = (
tool.args_schema.schema()
)
tool_schema["function"]["parameters"] = tool.args_schema.schema()
except Exception:
tool_schema["function"]["parameters"] = {}
@ -331,14 +326,11 @@ def create_pre_model_hook(
if not llm_config:
logger.error("LLM config not found. context_limit is required.")
raise ValueError(
"LLM config not found. context_limit is required."
)
raise ValueError("LLM config not found. context_limit is required.")
if "context_limit" not in llm_config:
logger.error(
"context_limit not found in LLM config. "
"This is a required field. Please configure context_limit."
"context_limit not found in LLM config. This is a required field. Please configure context_limit."
)
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)
# Calculate tokens for messages_with_system (including system)
messages_with_system_tokens = sum(
_count_tokens_for_message(m) for m in messages_with_system
)
messages_with_system_tokens = sum(_count_tokens_for_message(m) for m in messages_with_system)
# Calculate available budget
model_limit_tokens = context_limit_k * TOKENS_PER_K
@ -449,14 +439,11 @@ def create_pre_model_hook(
# Calculate final token counts
final_total = sum(_count_tokens_for_message(m) for m in trimmed)
usage_percent = (
(final_total + tool_tokens) / model_limit_tokens * 100
)
usage_percent = (final_total + tool_tokens) / model_limit_tokens * 100
if len(trimmed) < len(messages_with_system):
logger.info(
"Messages trimmed: %d → %d msgs. Total: ~%d tokens + %d "
"tools = %d / %dK (%.1f%%), strategy=%s",
"Messages trimmed: %d → %d msgs. Total: ~%d tokens + %d tools = %d / %dK (%.1f%%), strategy=%s",
len(messages_with_system),
len(trimmed),
final_total,
@ -468,8 +455,7 @@ def create_pre_model_hook(
)
else:
logger.info(
"Context ready: %d msgs, ~%d tokens + %d tools = %d / %dK "
"(%.1f%%), strategy=%s",
"Context ready: %d msgs, ~%d tokens + %d tools = %d / %dK (%.1f%%), strategy=%s",
len(trimmed),
final_total,
tool_tokens,
@ -520,33 +506,23 @@ def _inject_topology_into_system(
if topology_data:
topology_str = str(topology_data)
formatted_prompt = system_prompt.replace(
"{{topology_info}}", f"\n\n## Current Topology\n{topology_str}"
)
formatted_prompt = system_prompt.replace("{{topology_info}}", f"\n\n## Current Topology\n{topology_str}")
logger.info(
"✓ Topology injected: %d chars, nodes: %s",
len(topology_str),
list(topology_data.get("nodes", {}).keys())[:5],
) # Show first 5 node names
logger.debug(
"Full topology data: %s", topology_str[:500]
) # First 500 chars
logger.debug("Full topology data: %s", topology_str[:500]) # First 500 chars
else:
formatted_prompt = system_prompt.replace(
"{{topology_info}}", "(No topology information available)"
)
formatted_prompt = system_prompt.replace("{{topology_info}}", "(No topology information available)")
logger.warning("✗ Topology data is None, injecting placeholder")
# Filter out existing SystemMessage instances
non_system_messages = [
m for m in messages if not isinstance(m, SystemMessage)
]
non_system_messages = [m for m in messages if not isinstance(m, SystemMessage)]
filtered_count = len(messages) - len(non_system_messages)
if filtered_count > 0:
logger.debug(
"Filtered out %d existing SystemMessage(s)", filtered_count
)
logger.debug("Filtered out %d existing SystemMessage(s)", filtered_count)
return [SystemMessage(content=formatted_prompt)] + non_system_messages
@ -568,8 +544,7 @@ def prepare_context_messages(
**DEPRECATED**: Use create_pre_model_hook() instead.
"""
warnings.warn(
"prepare_context_messages() is deprecated. Use "
"create_pre_model_hook() instead.",
"prepare_context_messages() is deprecated. Use create_pre_model_hook() instead.",
DeprecationWarning,
stacklevel=2,
)
@ -577,13 +552,9 @@ def prepare_context_messages(
if "{{topology_info}}" not in system_prompt:
formatted_prompt = system_prompt
elif topology_context:
formatted_prompt = system_prompt.replace(
"{{topology_info}}", f"\n\n## Current Topology\n{topology_context}"
)
formatted_prompt = system_prompt.replace("{{topology_info}}", f"\n\n## Current Topology\n{topology_context}")
else:
formatted_prompt = system_prompt.replace(
"{{topology_info}}", "(No topology information available)"
)
formatted_prompt = system_prompt.replace("{{topology_info}}", "(No topology information available)")
return [SystemMessage(content=formatted_prompt)] + state_messages
@ -629,9 +600,7 @@ if __name__ == "__main__":
# Test invocation
print("\nTest 3: Invoke pre_model_hook")
test_state = {
"messages": [
HumanMessage(f"Message {i}: {'x' * 50}") for i in range(5)
],
"messages": [HumanMessage(f"Message {i}: {'x' * 50}") for i in range(5)],
"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:
topology_info = topology
logger.info(
"Successfully retrieved topology for project_id: %s, "
"name: %s",
"Successfully retrieved topology for project_id: %s, name: %s",
project_id,
topology.get("name"),
)
@ -320,9 +319,7 @@ def llm_call(state: dict, config: RunnableConfig | None = None):
logger.warning(
"Failed to retrieve topology for project_id %s: %s",
project_id,
topology.get("error", "Unknown error")
if topology
else "No result",
topology.get("error", "Unknown error") if topology else "No result",
)
except Exception as e:
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()
if copilot_mode == "lab_automation_assistant":
mode_tools = LAB_AUTOMATION_ASSISTANT_MODE_TOOLS
logger.info(
"Using LAB_AUTOMATION_ASSISTANT mode tools (includes "
"configuration tools)"
)
logger.info("Using LAB_AUTOMATION_ASSISTANT mode tools (includes configuration tools)")
elif copilot_mode == "troubleshooting_injection":
mode_tools = TROUBLESHOOTING_INJECTION_MODE_TOOLS
logger.info(
"Using TROUBLESHOOTING_INJECTION mode tools (fault injection)"
)
logger.info("Using TROUBLESHOOTING_INJECTION mode tools (fault injection)")
else: # teaching_assistant mode (default)
mode_tools = TEACHING_ASSISTANT_MODE_TOOLS
logger.info(
"Using TEACHING_ASSISTANT mode tools (diagnostic tools only)"
)
logger.info("Using TEACHING_ASSISTANT mode tools (diagnostic tools only)")
# Create pre_model_hook for automatic topology injection and trimming
# Load system prompt based on copilot_mode configuration
@ -372,22 +362,16 @@ def llm_call(state: dict, config: RunnableConfig | None = None):
copilot_mode,
len(mode_tools),
)
model_with_tools = create_base_model_with_tools(
mode_tools, llm_config=llm_config
)
model_with_tools = create_base_model_with_tools(mode_tools, llm_config=llm_config)
# Call pre_hook directly to prepare messages (topology injection +
# trimming)
# Note: LangGraph's pre_model_hook only works with prebuilt agents, not
# custom StateGraph
logger.debug("Calling pre_hook to prepare %d messages", len(messages))
prepared_state = pre_hook(
{"messages": messages, "topology_info": topology_info}
)
prepared_state = pre_hook({"messages": messages, "topology_info": topology_info})
prepared_messages = prepared_state["messages"]
logger.info(
"Messages prepared: %d → %d", len(messages), len(prepared_messages)
)
logger.info("Messages prepared: %d → %d", len(messages), len(prepared_messages))
# Invoke model with 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
def generate_title(
state: MessagesState, config: RunnableConfig | None = None
) -> dict:
def generate_title(state: MessagesState, config: RunnableConfig | None = None) -> dict:
"""
Generate a conversation title using a lightweight assistant LLM
(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
if not new_title or len(new_title) < 3:
raise ValueError(
f"Generated title too short or empty: '{new_title}'"
)
raise ValueError(f"Generated title too short or empty: '{new_title}'")
if new_title in [
"New Conversation",
"Untitled Session",
"GNS3 Session",
]:
raise ValueError(
f"Generated title is a default value: '{new_title}'"
)
raise ValueError(f"Generated title is a default value: '{new_title}'")
# Safety: truncate long titles and avoid line breaks
if len(new_title) > TITLE_MAX_LENGTH:
new_title = new_title[: TITLE_MAX_LENGTH - 2] + "..."
# Remove unwanted characters
new_title = (
new_title.replace("\n", " ").replace('"', "").replace("'", "")
)
new_title = new_title.replace("\n", " ").replace('"', "").replace("'", "")
logger.debug("Generated new title: %s", new_title)
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 = []
for tool_call in tool_calls:
tool_name = tool_call["name"]
logger.debug(
"Executing tool: %s with args: %s", tool_name, tool_call["args"]
)
logger.debug("Executing tool: %s with args: %s", tool_name, tool_call["args"])
tool = tools_by_name[tool_name]
try:
observation = tool.invoke(tool_call["args"])
@ -590,11 +564,13 @@ def abort_handler_node(state: dict) -> dict:
result = []
for tool_call in last_message.tool_calls:
tool_msg = ToolMessage(
content=json.dumps({
"status": "aborted",
"message": "Tool execution was aborted by user",
"tool_call_id": tool_call["id"],
}),
content=json.dumps(
{
"status": "aborted",
"message": "Tool execution was aborted by user",
"tool_call_id": tool_call["id"],
}
),
tool_call_id=tool_call["id"],
name=tool_call["name"],
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)
logger.info(
"Creating base model: name=%s, provider=%s, base_url=%s, "
"temperature=%s",
"Creating base model: name=%s, provider=%s, base_url=%s, temperature=%s",
config_vars["model_name"],
config_vars["model_provider"],
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)
logger.info(
"Creating title model: name=%s, provider=%s, base_url=%s, "
"temperature=1.0",
"Creating title model: name=%s, provider=%s, base_url=%s, temperature=1.0",
config_vars["model_name"],
config_vars["model_provider"],
config_vars["base_url"] if config_vars["base_url"] else "default",

View File

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

View File

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

View File

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

View File

@ -70,22 +70,53 @@ MAX_NODE_FILE_BYTES = 50 * 1024 # 50 KiB
VALID_NODE_FIELDS = {
# NodeBase
"compute_id", "name", "node_type", "node_id",
"console", "console_type", "console_auto_start",
"aux", "aux_type", "properties", "label", "symbol",
"x", "y", "z", "locked",
"port_name_format", "port_segment_size", "first_port_name",
"custom_adapters", "tags",
"compute_id",
"name",
"node_type",
"node_id",
"console",
"console_type",
"console_auto_start",
"aux",
"aux_type",
"properties",
"label",
"symbol",
"x",
"y",
"z",
"locked",
"port_name_format",
"port_segment_size",
"first_port_name",
"custom_adapters",
"tags",
# Node
"template_id", "project_id", "node_directory", "status",
"command_line", "width", "height", "ports", "console_host",
"template_id",
"project_id",
"node_directory",
"status",
"command_line",
"width",
"height",
"ports",
"console_host",
}
VALID_LINK_FIELDS = {
"link_id", "project_id", "link_type", "nodes", "suspend",
"link_style", "filters", "show_filters_icon",
"capturing", "capture_file_name", "capture_file_path",
"capture_compute_id", "wireshark",
"link_id",
"project_id",
"link_type",
"nodes",
"suspend",
"link_style",
"filters",
"show_filters_icon",
"capturing",
"capture_file_name",
"capture_file_path",
"capture_compute_id",
"wireshark",
}
LINK_DEFAULT_FIELDS = ["link_id", "link_type", "nodes"]
@ -93,6 +124,7 @@ LINK_DEFAULT_FIELDS = ["link_id", "link_type", "nodes"]
# ── Helpers ────────────────────────────────────────────────────────────────
def _get_connector(gns3_ctx: dict[str, Any]):
return Gns3Connector(
url=gns3_ctx["server_url"],
@ -102,9 +134,7 @@ def _get_connector(gns3_ctx: dict[str, Any]):
)
def build_gns3_ctx(
jwt_token: str | None = None, url: str | None = None
) -> dict[str, Any] | None:
def build_gns3_ctx(jwt_token: str | None = None, url: str | None = None) -> dict[str, Any] | None:
"""
Build a handler ``gns3_ctx`` for in-process copilot callers.
@ -183,13 +213,14 @@ def _normalize_link_nodes(nodes) -> list[dict[str, Any]]:
]
raise ValueError(
f"Unrecognized link nodes format. "
f"Use standard [{{\"node_id\":\"..\",\"adapter_number\":0,\"port_number\":0}},...] "
f"or compact [\"id\",0,0,\"id\",0,0], got: {nodes}"
f'Use standard [{{"node_id":"..","adapter_number":0,"port_number":0}},...] '
f'or compact ["id",0,0,"id",0,0], got: {nodes}'
)
# ── Node handlers ──────────────────────────────────────────────────────────
def get_nodes_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
project_id = params.get("project_id")
if not project_id:
@ -199,7 +230,7 @@ def get_nodes_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[
fields = params.get("fields")
if fields:
if not isinstance(fields, list):
return {"error": "fields must be a list of field names, e.g. [\"name\", \"status\"]"}
return {"error": 'fields must be a list of field names, e.g. ["name", "status"]'}
invalid = [f for f in fields if f not in VALID_NODE_FIELDS]
if invalid:
return {
@ -221,7 +252,7 @@ def get_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[s
fields = params.get("fields")
if fields:
if not isinstance(fields, list):
return {"error": "fields must be a list of field names, e.g. [\"name\", \"status\"]"}
return {"error": 'fields must be a list of field names, e.g. ["name", "status"]'}
invalid = [f for f in fields if f not in VALID_NODE_FIELDS]
if invalid:
return {
@ -235,12 +266,14 @@ def get_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[s
def _batch_lifecycle(project_id, node_ids, action, conn, action_label):
"""Helper to run a lifecycle action on multiple nodes in parallel."""
def _act(nid):
try:
conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/{nid}/{action}")
return {"node_id": nid, "status": "success", "message": f"Node {nid} {action_label}"}
except Exception as e:
return {"node_id": nid, "status": "error", "error": str(e)}
with ThreadPoolExecutor(max_workers=min(len(node_ids), BATCH_MAX_WORKERS)) as pool:
return list(pool.map(_act, node_ids))
@ -307,7 +340,7 @@ def create_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic
fields = params.get("fields")
if fields is not None and not isinstance(fields, list):
return {"error": "fields must be a list, e.g. [\"node_id\", \"name\"]"}
return {"error": 'fields must be a list, e.g. ["node_id", "name"]'}
nodes = params.get("nodes")
# Batch mode: nodes=[{template_id?, x, y, name?, compute_id?}]
@ -317,6 +350,7 @@ def create_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic
return {"error": "nodes must be a non-empty array"}
default_tid = params.get("template_id")
conn = _get_connector(gns3_ctx)
def _create_one(node_data):
tid = node_data.get("template_id", default_tid)
if not tid:
@ -335,6 +369,7 @@ def create_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic
return {"template_id": tid, "status": "success", "node": _filter_node_response(resp, fields)}
except Exception as e:
return {"template_id": tid, "status": "error", "error": str(e)}
if any(not node.get("name") for node in nodes):
# The controller assigns default names (R-1, R-2, ...) and console
# ports in request arrival order, and a parallel fan-out makes the
@ -375,12 +410,14 @@ def delete_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic
if not isinstance(node_ids, list):
return {"error": "node_ids must be a list"}
conn = _get_connector(gns3_ctx)
def _del(nid):
try:
conn.http_call("delete", f"{conn.base_url}/projects/{project_id}/nodes/{nid}")
return {"node_id": nid, "status": "success", "message": f"Node {nid} deleted"}
except Exception as e:
return {"node_id": nid, "status": "error", "error": str(e)}
with ThreadPoolExecutor(max_workers=min(len(node_ids), BATCH_MAX_WORKERS)) as pool:
return list(pool.map(_del, node_ids))
node_id = params.get("node_id")
@ -423,12 +460,16 @@ def get_node_console_info_handler(params: dict[str, Any], gns3_ctx: dict[str, An
# the ~200-char JWT previously embedded here (dropped header segment →
# "Missing 'alg' value in header" on the server).
username = gns3_ctx.get("jwt_username")
ticket = access_ticket_service.mint(
username,
token_version=gns3_ctx.get("jwt_token_version", 0),
project_id=project_id,
node_id=node_id,
) if username else None
ticket = (
access_ticket_service.mint(
username,
token_version=gns3_ctx.get("jwt_token_version", 0),
project_id=project_id,
node_id=node_id,
)
if username
else None
)
raw_url = f"{gns3_ctx['server_url']}/v3/projects/{project_id}/nodes/{node_id}/console/ws"
if ticket:
raw_url += f"?token={ticket}"
@ -499,7 +540,7 @@ def get_node_file_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> d
total_lines = len(lines)
# Apply offset/limit
selected = lines[offset: offset + limit] if offset < total_lines else []
selected = lines[offset : offset + limit] if offset < total_lines else []
has_more = (offset + limit) < total_lines or truncated
content = "".join(selected)
@ -581,7 +622,9 @@ def duplicate_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) ->
return {"error": "project_id and node_id are required"}
conn = _get_connector(gns3_ctx)
data = {k: v for k, v in params.items() if k not in ("project_id", "node_id") and v is not None}
result = conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/duplicate", json_data=data).json()
result = conn.http_call(
"post", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/duplicate", json_data=data
).json()
return {"message": f"Node {node_id} duplicated", "node": result}
@ -617,6 +660,7 @@ def get_node_links_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) ->
# ── Link handlers ──────────────────────────────────────────────────────────
def get_links_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
project_id = params.get("project_id")
if not project_id:
@ -626,7 +670,7 @@ def get_links_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[
fields = params.get("fields")
if fields:
if not isinstance(fields, list):
return {"error": "fields must be a list, e.g. [\"link_id\", \"nodes\"]"}
return {"error": 'fields must be a list, e.g. ["link_id", "nodes"]'}
invalid = [f for f in fields if f not in VALID_LINK_FIELDS]
if invalid:
return {
@ -669,7 +713,7 @@ def create_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic
fields = params.get("fields")
if fields is not None and not isinstance(fields, list):
return {"error": "fields must be a list, e.g. [\"link_id\", \"nodes\"]"}
return {"error": 'fields must be a list, e.g. ["link_id", "nodes"]'}
links = params.get("links")
# Batch mode: links=[{nodes, link_type?, filters?, suspend?}]
@ -677,6 +721,7 @@ def create_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic
if not isinstance(links, list) or not links:
return {"error": "links must be a non-empty array"}
conn = _get_connector(gns3_ctx)
def _create_one(link_data):
raw_nodes = link_data.get("nodes")
if not raw_nodes:
@ -694,6 +739,7 @@ def create_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic
return {"status": "success", "link": _filter_link_response(resp, fields)}
except Exception as e:
return {"status": "error", "error": str(e)}
with ThreadPoolExecutor(max_workers=min(len(links), BATCH_MAX_WORKERS)) as pool:
# pool.map keeps the submission order, so callers can correlate
# results with the links they sent regardless of completion order
@ -725,12 +771,14 @@ def delete_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic
if not isinstance(link_ids, list):
return {"error": "link_ids must be a list"}
conn = _get_connector(gns3_ctx)
def _del(lid):
try:
conn.http_call("delete", f"{conn.base_url}/projects/{project_id}/links/{lid}")
return {"link_id": lid, "status": "success", "message": f"Link {lid} deleted"}
except Exception as e:
return {"link_id": lid, "status": "error", "error": str(e)}
with ThreadPoolExecutor(max_workers=min(len(link_ids), BATCH_MAX_WORKERS)) as pool:
return list(pool.map(_del, link_ids))
link_id = params.get("link_id")
@ -770,6 +818,7 @@ def reset_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict
if not isinstance(link_ids, list):
return {"error": "link_ids must be a list"}
conn = _get_connector(gns3_ctx)
def _rst(lid):
try:
url = f"{conn.base_url}/projects/{project_id}/links/{lid}/reset"
@ -777,6 +826,7 @@ def reset_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict
return {"link_id": lid, "status": "reset", "link": r}
except Exception as e:
return {"link_id": lid, "status": "error", "error": str(e)}
with ThreadPoolExecutor(max_workers=min(len(link_ids), BATCH_MAX_WORKERS)) as pool:
return list(pool.map(_rst, link_ids))
link_id = params.get("link_id")
@ -790,6 +840,7 @@ def reset_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict
def _batch_capture(project_id, link_ids, action, data_builder, conn):
"""Helper for batch capture start/stop."""
def _act(lid):
try:
url = f"{conn.base_url}/projects/{project_id}/links/{lid}/capture/{action}"
@ -798,6 +849,7 @@ def _batch_capture(project_id, link_ids, action, data_builder, conn):
return {"link_id": lid, "status": "success"}
except Exception as e:
return {"link_id": lid, "status": "error", "error": str(e)}
with ThreadPoolExecutor(max_workers=min(len(link_ids), BATCH_MAX_WORKERS)) as pool:
return list(pool.map(_act, link_ids))
@ -814,11 +866,13 @@ def start_capture_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> d
dlt = params.get("data_link_type", "DLT_EN10MB")
ws = params.get("wireshark", False)
fname = params.get("capture_file_name")
def _build(lid):
data = {"data_link_type": dlt, "wireshark": ws}
if fname:
data["capture_file_name"] = fname
return {"json_data": data}
return _batch_capture(project_id, link_ids, "start", _build, conn)
link_id = params.get("link_id")
if not link_id:
@ -879,7 +933,11 @@ def download_capture_file_handler(params: dict[str, Any], gns3_ctx: dict[str, An
if not isinstance(link_ids, list):
return {"error": "link_ids must be a list"}
results = [_download(lid) for lid in link_ids]
return {"downloads": results, "count": len(results), "note": "Files are in pcap format. URLs include a 10-minute ticket."}
return {
"downloads": results,
"count": len(results),
"note": "Files are in pcap format. URLs include a 10-minute ticket.",
}
link_id = params.get("link_id")
if not link_id:
@ -946,12 +1004,18 @@ def link_marker_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic
elif direction in ("tx", "rx"):
body["direction"] = direction
if not body:
return {"error": "At least one update field is required (bpf, tag, enabled, direction, color, highlight_duration)"}
return {
"error": "At least one update field is required (bpf, tag, enabled, direction, color, highlight_duration)"
}
return conn.http_call("put", url, json_data=body).json()
# action == "delete"
conn.http_call("delete", url)
return {"message": f"Marker '{marker_name}' deleted from link {link_id}", "link_id": link_id, "marker_name": marker_name}
return {
"message": f"Marker '{marker_name}' deleted from link {link_id}",
"link_id": link_id,
"marker_name": marker_name,
}
def marker_definition_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
@ -1002,7 +1066,9 @@ def marker_definition_handler(params: dict[str, Any], gns3_ctx: dict[str, Any])
if params.get(opt) is not None:
body[opt] = params[opt]
if not body:
return {"error": "At least one update field is required (bpf, tag, color, highlight_duration, data_link_type)"}
return {
"error": "At least one update field is required (bpf, tag, color, highlight_duration, data_link_type)"
}
return conn.http_call("put", url, json_data=body).json()
# action == "delete"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -61,9 +61,7 @@ class ProjectAgentManager:
cls._instance._lock = asyncio.Lock()
return cls._instance
async def get_agent(
self, project_id: str, project_path: str
) -> AgentService:
async def get_agent(self, project_id: str, project_path: str) -> AgentService:
"""
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.
"""
if not llm_config:
logger.debug(
"No LLM config provided, using default TEACHING_ASSISTANT "
"prompt mode"
)
logger.debug("No LLM config provided, using default TEACHING_ASSISTANT prompt mode")
return _load_prompt("teaching_assistant")
# 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()
if mode == "lab_automation_assistant":
logger.debug(
"Using LAB_AUTOMATION_ASSISTANT prompt mode (diagnostics + "
"configuration)"
)
logger.debug("Using LAB_AUTOMATION_ASSISTANT prompt mode (diagnostics + configuration)")
return _load_prompt("lab_automation_assistant")
elif mode == "troubleshooting_injection":
logger.debug(
"Using TROUBLESHOOTING_INJECTION prompt mode (fault injection)"
)
logger.debug("Using TROUBLESHOOTING_INJECTION prompt mode (fault injection)")
return _load_prompt("troubleshooting_injection")
else:
logger.debug("Using TEACHING_ASSISTANT prompt mode (diagnostics only)")

View File

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

View File

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

View File

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

View File

@ -199,18 +199,12 @@ class ExecuteMultipleDeviceCommands(BaseTool):
# Validate input
device_configs_list, project_id = self._validate_tool_input(tool_input)
if (
isinstance(device_configs_list, list)
and len(device_configs_list) > 0
and "error" in device_configs_list[0]
):
if isinstance(device_configs_list, list) and len(device_configs_list) > 0 and "error" in device_configs_list[0]:
return device_configs_list
# Filter forbidden commands and store blocked commands info
device_configs_list, blocked_commands_map = (
self._filter_forbidden_commands_from_device_configs(
device_configs_list
)
device_configs_list, blocked_commands_map = self._filter_forbidden_commands_from_device_configs(
device_configs_list
)
# Create a mapping of device names to their display commands
@ -218,30 +212,17 @@ class ExecuteMultipleDeviceCommands(BaseTool):
# Prepare device hosts data
try:
hosts_data = self._prepare_device_hosts_data(
device_configs_list, project_id, jwt_token=jwt_token, url=url
)
hosts_data = self._prepare_device_hosts_data(device_configs_list, project_id, jwt_token=jwt_token, url=url)
except ValueError as e:
logger.error("Failed to prepare device hosts data: %s", e)
return [{"status": "failed", "error": str(e)}]
# Check if any devices have errors (e.g., missing device_type tag)
error_devices = {
name: data
for name, data in hosts_data.items()
if "error" in data
}
error_devices = {name: data for name, data in hosts_data.items() if "error" in data}
if error_devices:
logger.error(
"Devices with configuration errors: %s",
list(error_devices.keys())
)
logger.error("Devices with configuration errors: %s", list(error_devices.keys()))
return [
{
"device_name": name,
"status": "failed",
"error": data["error"]
}
{"device_name": name, "status": "failed", "error": data["error"]}
for name, data in error_devices.items()
]
@ -281,17 +262,13 @@ class ExecuteMultipleDeviceCommands(BaseTool):
return results
def _run_all_device_configs_with_single_retry(
self, task: Task, device_configs_map: dict[str, list[str]]
) -> Result:
def _run_all_device_configs_with_single_retry(self, task: Task, device_configs_map: dict[str, list[str]]) -> Result:
"""Execute READ-ONLY diagnostic commands with single retry."""
device_name = task.host.name
diagnostic_commands = device_configs_map.get(device_name, [])
if not diagnostic_commands:
return Result(
host=task.host, result="No diagnostic commands to execute"
)
return Result(host=task.host, result="No diagnostic commands to execute")
try:
_result = task.run(
@ -366,9 +343,7 @@ class ExecuteMultipleDeviceCommands(BaseTool):
parsed_input = json.loads(tool_input)
logger.info("Successfully parsed tool input from JSON string.")
except json.JSONDecodeError as e:
logger.error(
"Invalid JSON string received as tool input: %s", e
)
logger.error("Invalid JSON string received as tool input: %s", e)
return (
[{"status": "failed", "error": f"Invalid JSON string input from model: {e}"}],
None,
@ -412,9 +387,7 @@ class ExecuteMultipleDeviceCommands(BaseTool):
# Handle legacy format: [...]
elif isinstance(parsed_input, list):
logger.warning(
"Legacy input format without project_id. Use new format."
)
logger.warning("Legacy input format without project_id. Use new format.")
return parsed_input, None
else:
@ -435,9 +408,7 @@ class ExecuteMultipleDeviceCommands(BaseTool):
Returns:
True if valid UUID format, False otherwise
"""
uuid_pattern = (
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
)
uuid_pattern = r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
return bool(re.match(uuid_pattern, project_id, re.IGNORECASE))
def _filter_forbidden_commands_from_device_configs(
@ -462,9 +433,7 @@ class ExecuteMultipleDeviceCommands(BaseTool):
commands = device_config["commands"]
# Filter commands
allowed_commands, blocked_info = filter_forbidden_commands(
commands
)
allowed_commands, blocked_info = filter_forbidden_commands(commands)
# Update device config with allowed commands only
filtered_config = device_config.copy()
@ -483,9 +452,7 @@ class ExecuteMultipleDeviceCommands(BaseTool):
return filtered_list, blocked_commands_map
def _configs_map(
self, device_config_list: list[dict[str, Any]]
) -> dict[str, list[str]]:
def _configs_map(self, device_config_list: list[dict[str, Any]]) -> dict[str, list[str]]:
"""Create a mapping of device names to their diagnostic commands."""
device_diagnostic_map = {}
for device_config in device_config_list:
@ -504,21 +471,13 @@ class ExecuteMultipleDeviceCommands(BaseTool):
) -> dict[str, dict[str, Any]]:
"""Prepare device hosts data from topology information."""
# Extract device names list
device_names = [
device_config["device_name"]
for device_config in device_config_list
]
device_names = [device_config["device_name"] for device_config in device_config_list]
# Get device port information with project_id
hosts_data = get_device_ports_from_topology(
device_names, project_id, jwt_token=jwt_token, url=url
)
hosts_data = get_device_ports_from_topology(device_names, project_id, jwt_token=jwt_token, url=url)
if not hosts_data:
error_msg = (
f"Failed to get device info from topology. "
f"Project: {project_id}, Devices: {device_names}"
)
error_msg = f"Failed to get device info from topology. Project: {project_id}, Devices: {device_names}"
raise ValueError(error_msg)
# Check for missing devices
@ -532,9 +491,7 @@ class ExecuteMultipleDeviceCommands(BaseTool):
return hosts_data
def _initialize_nornir(
self, hosts_data: dict[str, dict[str, Any]]
) -> Nornir:
def _initialize_nornir(self, hosts_data: dict[str, dict[str, Any]]) -> Nornir:
"""
Initialize Nornir with the provided hosts data.
@ -559,8 +516,7 @@ class ExecuteMultipleDeviceCommands(BaseTool):
# Log device types being configured
device_types = [
host["connection_options"]["netmiko"]["extras"]["device_type"]
for host in hosts_data.values()
host["connection_options"]["netmiko"]["extras"]["device_type"] for host in hosts_data.values()
]
logger.info(
"Initializing Nornir: host=%s, device_types=%s, hosts=%d",
@ -608,16 +564,11 @@ class ExecuteMultipleDeviceCommands(BaseTool):
device_result = {
"device_name": device_name,
"status": "failed",
"error": (
f"Device '{device_name}' not found in topology "
"or missing console_port"
),
"error": (f"Device '{device_name}' not found in topology or missing console_port"),
}
# Add blocked commands info if any
if blocked_commands_info:
device_result["blocked_commands"] = list(
blocked_commands_info.keys()
)
device_result["blocked_commands"] = list(blocked_commands_info.keys())
device_result["blocked_info"] = blocked_commands_info
results.append(device_result)
continue
@ -627,15 +578,11 @@ class ExecuteMultipleDeviceCommands(BaseTool):
device_result = {
"device_name": device_name,
"status": "failed",
"error": (
f"Device '{device_name}' not found in task results"
),
"error": (f"Device '{device_name}' not found in task results"),
}
# Add blocked commands info if any
if blocked_commands_info:
device_result["blocked_commands"] = list(
blocked_commands_info.keys()
)
device_result["blocked_commands"] = list(blocked_commands_info.keys())
device_result["blocked_info"] = blocked_commands_info
results.append(device_result)
continue
@ -647,9 +594,7 @@ class ExecuteMultipleDeviceCommands(BaseTool):
if multi_result[0].failed:
# Execution failed
device_result["status"] = "failed"
device_result["error"] = (
f"Diagnostic command failed: {multi_result[0].result}"
)
device_result["error"] = f"Diagnostic command failed: {multi_result[0].result}"
device_result["output"] = multi_result[0].result
else:
# Execution successful
@ -659,9 +604,7 @@ class ExecuteMultipleDeviceCommands(BaseTool):
# Add blocked commands info if any
if blocked_commands_info:
device_result["blocked_commands"] = list(
blocked_commands_info.keys()
)
device_result["blocked_commands"] = list(blocked_commands_info.keys())
device_result["blocked_info"] = blocked_commands_info
# Update status if some commands were blocked but succeeded
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:
logger.error("Invalid links data: must be a non-empty array")
return [
{"error": "Invalid links data: must be a non-empty array"}
]
return [{"error": "Invalid links data: must be a non-empty array"}]
# Build handler context (JWT + server URL from request context)
logger.info("Connecting to GNS3 server...")
@ -142,14 +140,7 @@ class GNS3LinkTool(BaseTool):
if gns3_ctx is None:
logger.error("Failed to create GNS3 connector")
return [
{
"error": (
"Failed to connect to GNS3 server. "
"Please check your configuration."
)
}
]
return [{"error": ("Failed to connect to GNS3 server. Please check your configuration.")}]
# Fetch all nodes once for port resolution
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
@ -172,9 +163,7 @@ class GNS3LinkTool(BaseTool):
# Validate link parameters
if not all([node_id1, port1, node_id2, port2]):
error_msg = (
f"Missing required fields in link definition {i}"
)
error_msg = f"Missing required fields in link definition {i}"
logger.error(error_msg)
created_links.append({"error": error_msg})
continue
@ -218,29 +207,15 @@ class GNS3LinkTool(BaseTool):
"nodes": [
{
"node_id": node_id1,
"adapter_number": port1_info.get(
"adapter_number", 0
),
"port_number": port1_info.get(
"port_number", 0
),
"label": {
"text": port1_info.get("short_name")
or port1
},
"adapter_number": port1_info.get("adapter_number", 0),
"port_number": port1_info.get("port_number", 0),
"label": {"text": port1_info.get("short_name") or port1},
},
{
"node_id": node_id2,
"adapter_number": port2_info.get(
"adapter_number", 0
),
"port_number": port2_info.get(
"port_number", 0
),
"label": {
"text": port2_info.get("short_name")
or port2
},
"adapter_number": port2_info.get("adapter_number", 0),
"port_number": port2_info.get("port_number", 0),
"label": {"text": port2_info.get("short_name") or port2},
},
],
"fields": ["link_id"],
@ -266,9 +241,7 @@ class GNS3LinkTool(BaseTool):
created_links.append({"error": error_msg})
# Log final results
success_count = len(
[link for link in created_links if "error" not in link]
)
success_count = len([link for link in created_links if "error" not in link])
logger.info(
"Link creation completed: %d successful, %d failed",
success_count,

View File

@ -169,9 +169,7 @@ class GNS3CreateNodeTool(BaseTool):
# Validate each node in the array
for i, node_data in enumerate(nodes):
if not isinstance(node_data, dict):
logger.error(
"Invalid input: Node %d must be a dictionary.", i + 1
)
logger.error("Invalid input: Node %d must be a dictionary.", i + 1)
return {"error": f"Node {i + 1} must be a dictionary."}
template_id = node_data.get("template_id")
@ -187,14 +185,10 @@ class GNS3CreateNodeTool(BaseTool):
]
):
logger.error(
"Invalid input: Node %d missing or invalid "
"template_id, x, or y.",
"Invalid input: Node %d missing or invalid template_id, x, or y.",
i + 1,
)
return {
"error": f"Node {i + 1} missing or invalid "
f"template_id, x, or y."
}
return {"error": f"Node {i + 1} missing or invalid template_id, x, or y."}
# Build handler context (JWT + server URL from request context)
logger.info("Connecting to GNS3 server...")
@ -202,15 +196,10 @@ class GNS3CreateNodeTool(BaseTool):
if gns3_ctx is None:
logger.error("Failed to create GNS3 connector")
return {
"error": "Failed to connect to GNS3 server. "
"Please check your configuration."
}
return {"error": "Failed to connect to GNS3 server. Please check your configuration."}
# Create nodes
logger.info(
"Creating %d nodes in project %s...", len(nodes), project_id
)
logger.info("Creating %d nodes in project %s...", len(nodes), project_id)
results: list[dict[str, Any]] = []
for i, node_data in enumerate(nodes):
@ -261,12 +250,8 @@ class GNS3CreateNodeTool(BaseTool):
# Continue with next node even if one fails
# Calculate summary statistics
successful_nodes = len(
[r for r in results if r.get("status") == "success"]
)
failed_nodes = len(
[r for r in results if r.get("status") == "failed"]
)
successful_nodes = len([r for r in results if r.get("status") == "success"])
failed_nodes = len([r for r in results if r.get("status") == "failed"])
# Prepare final result
final_result = {
@ -293,9 +278,7 @@ class GNS3CreateNodeTool(BaseTool):
return {"error": f"Invalid JSON input: {e}"}
except Exception as e:
logger.error("Failed to process node creation request: %s", e)
return {
"error": f"Failed to process node creation request: {str(e)}"
}
return {"error": f"Failed to process node creation request: {str(e)}"}
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)
FILTERED_TEMPLATES = {
"atm_switch", # ATM switch
"cloud", # Cloud
"ethernet_hub", # Ethernet hub
"ethernet_switch", # Ethernet switch (built-in)
"atm_switch", # ATM switch
"cloud", # Cloud
"ethernet_hub", # Ethernet hub
"ethernet_switch", # Ethernet switch (built-in)
"frame_relay_switch", # Frame Relay switch
"nat", # NAT device
"nat", # NAT device
}
@ -131,17 +131,10 @@ class GNS3TemplateTool(BaseTool):
if gns3_server is None:
logger.error("Failed to create GNS3 connector")
return {
"error": (
"Failed to connect to GNS3 server. "
"Please check your configuration."
)
}
return {"error": ("Failed to connect to GNS3 server. Please check your configuration.")}
# Retrieve all available templates
templates = gns3_server.http_call(
"get", f"{gns3_server.base_url}/templates"
).json()
templates = gns3_server.http_call("get", f"{gns3_server.base_url}/templates").json()
# Filter out utility templates and extract relevant info
template_info = []
@ -154,11 +147,13 @@ class GNS3TemplateTool(BaseTool):
continue
# Extract name, template_id, and template_type
template_info.append({
"name": template.get("name", "N/A"),
"template_id": template.get("template_id", "N/A"),
"template_type": template.get("template_type", "N/A"),
})
template_info.append(
{
"name": template.get("name", "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
result = {"templates": template_info}
@ -172,9 +167,7 @@ class GNS3TemplateTool(BaseTool):
return result
except Exception as e:
logger.error(
"Failed to connect to GNS3 server or retrieve templates: %s", e
)
logger.error("Failed to connect to GNS3 server or retrieve templates: %s", 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"]
if action not in valid_actions:
logger.error("Invalid action: %s. Must be one of %s", action, valid_actions)
return {
"error": f"Invalid action: {action}. Must be one of {valid_actions}"
}
return {"error": f"Invalid action: {action}. Must be one of {valid_actions}"}
# Validate filters for "set" action
if action == "set":
filters = input_data.get("filters")
if not filters or not isinstance(filters, dict):
logger.error("Invalid input: 'set' action requires 'filters' dict.")
return {
"error": "'set' action requires 'filters' dict with filter configuration."
}
return {"error": "'set' action requires 'filters' dict with filter configuration."}
# Build handler context (JWT + server URL from request context)
logger.info("Connecting to GNS3 server...")
@ -217,28 +213,19 @@ class GNS3PacketFilterTool(BaseTool):
if gns3_ctx is None:
logger.error("Failed to create GNS3 connector")
return {
"error": "Failed to connect to GNS3 server. "
"Please check your configuration."
}
return {"error": "Failed to connect to GNS3 server. Please check your configuration."}
# Execute action
logger.info(
"Processing packet filter action '%s' for link %s...", action, link_id
)
logger.info("Processing packet filter action '%s' for link %s...", action, link_id)
if action == "get_available":
result = self._get_available_filters(gns3_ctx, project_id, link_id)
elif action == "set":
filters = input_data.get("filters", {})
result = self._set_filters(
gns3_ctx, project_id, link_id, filters, show_filters_icon
)
result = self._set_filters(gns3_ctx, project_id, link_id, filters, show_filters_icon)
elif action == "get":
result = self._get_filters(gns3_ctx, project_id, link_id)
elif action == "clear":
result = self._clear_filters(
gns3_ctx, project_id, link_id, show_filters_icon
)
result = self._clear_filters(gns3_ctx, project_id, link_id, show_filters_icon)
else:
result = {"error": f"Unknown action: {action}"}
@ -252,18 +239,12 @@ class GNS3PacketFilterTool(BaseTool):
return {"error": f"Invalid JSON input: {e}"}
except Exception as e:
logger.error("Failed to process packet filter request: %s", e)
return {
"error": f"Failed to process packet filter request: {str(e)}"
}
return {"error": f"Failed to process packet filter request: {str(e)}"}
def _get_available_filters(
self, gns3_ctx: dict, project_id: str, link_id: str
) -> dict[str, Any]:
def _get_available_filters(self, gns3_ctx: dict, project_id: str, link_id: str) -> dict[str, Any]:
"""Get available filter types for the link."""
try:
filters = available_filters_handler(
{"project_id": project_id, "link_id": link_id}, gns3_ctx
)
filters = available_filters_handler({"project_id": project_id, "link_id": link_id}, gns3_ctx)
if "error" in filters:
raise RuntimeError(filters["error"])
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:
error_lines = []
if "Invalid" in result.stderr:
error_lines.extend(
line for line in result.stderr.split("\n") if "Invalid" in line
)
error_lines.extend(line for line in result.stderr.split("\n") if "Invalid" in line)
if "Invalid" in result.stdout:
error_lines.extend(
line for line in result.stdout.split("\n") if "Invalid" in line
)
error_lines.extend(line for line in result.stdout.split("\n") if "Invalid" in line)
# Strip interface suffix (e.g., "for interface 'lo'") for cleaner error
error_msg_parts = []
@ -336,10 +313,7 @@ class GNS3PacketFilterTool(BaseTool):
except FileNotFoundError:
# tshark not installed - skip validation
logger.warning(
"tshark not found, skipping BPF syntax validation. "
"Install tshark to enable BPF validation."
)
logger.warning("tshark not found, skipping BPF syntax validation. Install tshark to enable BPF validation.")
return {"valid": True, "error": None}
except Exception as e:
@ -414,14 +388,10 @@ class GNS3PacketFilterTool(BaseTool):
"status": "failed",
}
def _get_filters(
self, gns3_ctx: dict, project_id: str, link_id: str
) -> dict[str, Any]:
def _get_filters(self, gns3_ctx: dict, project_id: str, link_id: str) -> dict[str, Any]:
"""Get current filters configured on the link."""
try:
link = get_link_handler(
{"project_id": project_id, "link_id": link_id}, gns3_ctx
)
link = get_link_handler({"project_id": project_id, "link_id": link_id}, gns3_ctx)
if "error" in link:
raise RuntimeError(link["error"])

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -73,14 +73,14 @@ def _load_forbidden_commands() -> list[str]:
# Try to load from skills repository
manager = get_skills_manager()
if manager is not None:
commands = manager.load_forbidden_commands()
if commands:
logger.info(
"Loaded %d forbidden command patterns from skills repository",
len(commands),
)
_forbidden_commands_cache = commands
return _forbidden_commands_cache
commands = manager.load_forbidden_commands()
if commands:
logger.info(
"Loaded %d forbidden command patterns from skills repository",
len(commands),
)
_forbidden_commands_cache = commands
return _forbidden_commands_cache
# Fallback to defaults
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
tool = DisplayToolNornir()
result = tool._run(json.dumps({
"device_names": ["huawei-sw1"],
"commands": ["display version"],
"project_id": "project-uuid"
}))
result = tool._run(
json.dumps({"device_names": ["huawei-sw1"], "commands": ["display version"], "project_id": "project-uuid"})
)
```
## Running Tests
@ -145,11 +143,14 @@ Create `tests/test_cisco.py`:
import unittest
from gns3server.agent.gns3_copilot.utils.custom_netmiko import cisco
class TestCustomCiscoDriver(unittest.TestCase):
def test_device_type_registered(self):
from netmiko.ssh_dispatcher import CLASS_MAPPER
self.assertIn("cisco_custom", CLASS_MAPPER)
if __name__ == "__main__":
unittest.main()
```

View File

@ -209,7 +209,7 @@ class GNS3HuaweiTelnetCE(HuaweiBase):
# Ensure we're in a clean state
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)
else:
# 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
# 'last_read' seconds. This is the proper Netmiko way to handle
# command output
output += self.read_channel_timing(
read_timeout=read_timeout, last_read=2.0
)
output += self.read_channel_timing(read_timeout=read_timeout, last_read=2.0)
# Exit config mode if requested
if exit_config_mode:
@ -318,9 +316,7 @@ class GNS3HuaweiTelnetCE(HuaweiBase):
return output
def exit_config_mode(
self, exit_config: str = "return", pattern: str = r"<\S+>|>\s*$"
) -> str:
def exit_config_mode(self, exit_config: str = "return", pattern: str = r"<\S+>|>\s*$") -> str:
r"""
Exit configuration mode for Huawei devices.
@ -377,6 +373,7 @@ class GNS3HuaweiTelnetCE(HuaweiBase):
# Register the custom device type with Netmiko
_registered = False # Flag to prevent duplicate registration
def register_custom_device_type() -> None:
"""
Register the custom GNS3HuaweiTelnetCE device type with Netmiko.
@ -451,7 +448,4 @@ try:
except Exception as e:
# Log but don't fail on import
logger = logging.getLogger(__name__)
logger.warning(
f"Failed to register custom device type: {e}",
exc_info=True
)
logger.warning(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
# These are commands that commonly require confirmation
INTERACTIVE_PATTERNS = [
re.compile(r'^router-id\s+', re.IGNORECASE), # OSPF router-id
re.compile(r'^erase\s+', re.IGNORECASE), # erase startup-config
re.compile(r'^delete\s+', re.IGNORECASE), # delete files
re.compile(r'^format\s+', re.IGNORECASE), # format filesystem
re.compile(r'^reload\b', re.IGNORECASE), # reload/reboot
re.compile(r'^boot\s+system\s+', re.IGNORECASE), # change boot image
re.compile(r"^router-id\s+", re.IGNORECASE), # OSPF router-id
re.compile(r"^erase\s+", re.IGNORECASE), # erase startup-config
re.compile(r"^delete\s+", re.IGNORECASE), # delete files
re.compile(r"^format\s+", re.IGNORECASE), # format filesystem
re.compile(r"^reload\b", re.IGNORECASE), # reload/reboot
re.compile(r"^boot\s+system\s+", re.IGNORECASE), # change boot image
]
def __init__(
@ -80,9 +80,7 @@ class RuijieTelnetEnhanced(RuijieOSBase):
kwargs["default_enter"] = "\r\n"
super().__init__(*args, **kwargs)
def _preprocess_interactive_commands(
self, config_commands: list[str]
) -> list[str]:
def _preprocess_interactive_commands(self, config_commands: list[str]) -> list[str]:
"""
Preprocess commands to insert 'yes' after interactive commands.
@ -105,8 +103,7 @@ class RuijieTelnetEnhanced(RuijieOSBase):
for pattern in self.INTERACTIVE_PATTERNS:
if pattern.match(cmd.strip()):
logging.info(
"Ruijie device: Detected interactive command '%s', "
"inserting 'yes'",
"Ruijie device: Detected interactive command '%s', inserting 'yes'",
cmd.strip(),
)
# Insert 'yes' after this command
@ -153,25 +150,18 @@ class RuijieTelnetEnhanced(RuijieOSBase):
output += self.config_mode()
# Preprocess: Insert 'yes' after known interactive commands
processed_commands = self._preprocess_interactive_commands(
config_commands
)
processed_commands = self._preprocess_interactive_commands(config_commands)
# Try batch send first (fast path)
try:
output += self._send_config_batch(
processed_commands, read_timeout, delay_factor
)
output += self._send_config_batch(processed_commands, read_timeout, delay_factor)
except Exception as batch_error:
logging.warning(
"Ruijie device: Batch send failed, "
"falling back to one-by-one: %s",
"Ruijie device: Batch send failed, falling back to one-by-one: %s",
batch_error,
)
# Fallback to one-by-one send with real-time detection
output += self._send_config_one_by_one(
config_commands, read_timeout, delay_factor
)
output += self._send_config_one_by_one(config_commands, read_timeout, delay_factor)
# Exit config mode if requested
if exit_config_mode:
@ -179,9 +169,7 @@ class RuijieTelnetEnhanced(RuijieOSBase):
return output
def _send_config_batch(
self, commands: list[str], read_timeout: int, delay_factor: float
) -> str:
def _send_config_batch(self, commands: list[str], read_timeout: int, delay_factor: float) -> str:
"""
Send configuration commands in batch (fast).
@ -197,15 +185,11 @@ class RuijieTelnetEnhanced(RuijieOSBase):
# Read all output at once
# Use same default as Netmiko (2.0 seconds) to ensure complete output
output += self.read_channel_timing(
read_timeout=read_timeout, last_read=2.0
)
output += self.read_channel_timing(read_timeout=read_timeout, last_read=2.0)
return output
def _send_config_one_by_one(
self, commands: list[str], read_timeout: int, delay_factor: float
) -> str:
def _send_config_one_by_one(self, commands: list[str], read_timeout: int, delay_factor: float) -> str:
"""
Send commands one-by-one with real-time prompt detection.
@ -225,26 +209,21 @@ class RuijieTelnetEnhanced(RuijieOSBase):
time.sleep(0.05)
# Read output after this command
new_output = self.read_channel_timing(
read_timeout=10, last_read=0.5
)
new_output = self.read_channel_timing(read_timeout=10, last_read=0.5)
output += new_output
# Check if interactive prompt appeared after this command
for pattern in interactive_patterns:
if re.search(pattern, new_output, re.IGNORECASE):
logging.info(
"Ruijie device: Detected interactive prompt "
"after '%s', sending 'yes'",
"Ruijie device: Detected interactive prompt after '%s', sending 'yes'",
cmd,
)
# Send 'yes' to confirm
self.write_channel(f"yes{self.RETURN}")
time.sleep(0.3)
# Read the confirmation response
output += self.read_channel_timing(
read_timeout=30, last_read=0.5
)
output += self.read_channel_timing(read_timeout=30, last_read=0.5)
break
return output
@ -253,6 +232,7 @@ class RuijieTelnetEnhanced(RuijieOSBase):
# Register the custom device type with Netmiko
_registered = False # Flag to prevent duplicate registration
def register_custom_device_type() -> None:
"""
Register the custom RuijieTelnetEnhanced device type with Netmiko.
@ -295,7 +275,4 @@ try:
register_custom_device_type()
except Exception as e:
logger = logging.getLogger(__name__)
logger.warning(
f"Failed to register Ruijie device type: {e}",
exc_info=True
)
logger.warning(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."""
try:
import netmiko
return netmiko.__version__
except Exception:
return "Unknown"
@ -68,47 +69,39 @@ def register_custom_drivers() -> set:
try:
# Import and register Huawei CE driver
from gns3server.agent.gns3_copilot.utils.custom_netmiko \
import huawei_ce
from gns3server.agent.gns3_copilot.utils.custom_netmiko import huawei_ce
# Store custom device types before registration
custom_devices.add("gns3_huawei_telnet_ce")
# The driver auto-registers on import, but we can call it
# explicitly
if hasattr(huawei_ce, 'register_custom_device_type'):
if hasattr(huawei_ce, "register_custom_device_type"):
huawei_ce.register_custom_device_type()
except ImportError:
# Silently skip if custom drivers are not available
pass
except Exception as e:
# Log but don't fail
print(
f"Warning: Failed to register Huawei CE driver: {e}",
file=sys.stderr
)
print(f"Warning: Failed to register Huawei CE driver: {e}", file=sys.stderr)
try:
# Import and register Ruijie Telnet driver
from gns3server.agent.gns3_copilot.utils.custom_netmiko \
import ruijie_telnet
from gns3server.agent.gns3_copilot.utils.custom_netmiko import ruijie_telnet
# Store custom device types before registration
custom_devices.add("gns3_ruijie_telnet")
# The driver auto-registers on import, but we can call it
# explicitly
if hasattr(ruijie_telnet, 'register_custom_device_type'):
if hasattr(ruijie_telnet, "register_custom_device_type"):
ruijie_telnet.register_custom_device_type()
except ImportError:
# Silently skip if custom drivers are not available
pass
except Exception as e:
# Log but don't fail
print(
f"Warning: Failed to register Ruijie driver: {e}",
file=sys.stderr
)
print(f"Warning: Failed to register Ruijie driver: {e}", file=sys.stderr)
return custom_devices
@ -124,32 +117,32 @@ def extract_brand_name(device_type: str) -> str:
Brand name in a standardized format
"""
# Remove common suffixes
name = device_type.replace('_telnet', '').replace('_ssh', '')
name = name.replace('_serial', '')
name = device_type.replace("_telnet", "").replace("_ssh", "")
name = name.replace("_serial", "")
# Map to standard brand names
brand_mapping = {
'cisco': 'Cisco',
'huawei': 'Huawei',
'juniper': 'Juniper',
'arista': 'Arista',
'hp': 'HP',
'aruba': 'Aruba',
'dell': 'Dell',
'brocade': 'Brocade',
'extreme': 'Extreme',
'ruckus': 'Ruckus',
'ruijie': 'Ruijie (锐捷)',
'zte': 'ZTE (中兴)',
'maipu': 'Maipu (迈普)',
'h3c': 'H3C (华三)',
'nokia': 'Nokia',
'paloalto': 'Palo Alto',
'f5': 'F5',
'checkpoint': 'Check Point',
'generic': 'Generic',
'huaweiyt': 'HuaweiYT',
'vsz': 'Ruckus',
"cisco": "Cisco",
"huawei": "Huawei",
"juniper": "Juniper",
"arista": "Arista",
"hp": "HP",
"aruba": "Aruba",
"dell": "Dell",
"brocade": "Brocade",
"extreme": "Extreme",
"ruckus": "Ruckus",
"ruijie": "Ruijie (锐捷)",
"zte": "ZTE (中兴)",
"maipu": "Maipu (迈普)",
"h3c": "H3C (华三)",
"nokia": "Nokia",
"paloalto": "Palo Alto",
"f5": "F5",
"checkpoint": "Check Point",
"generic": "Generic",
"huaweiyt": "HuaweiYT",
"vsz": "Ruckus",
}
# Check for exact matches first
@ -163,7 +156,7 @@ def extract_brand_name(device_type: str) -> str:
return value
# 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]]:
@ -205,8 +198,8 @@ def get_devices_by_protocol() -> Tuple[List[str], List[str]]:
for device_type in CLASS_MAPPER.keys():
device_lower = device_type.lower()
has_telnet = 'telnet' in device_lower
has_ssh = 'ssh' in device_lower
has_telnet = "telnet" in device_lower
has_ssh = "ssh" in device_lower
if has_telnet and has_ssh:
both_protocols.append(device_type)
@ -230,17 +223,14 @@ def get_total_device_count() -> int:
"""Get total number of device types in Netmiko."""
try:
from netmiko.ssh_dispatcher import CLASS_MAPPER
return len(CLASS_MAPPER)
except ImportError:
return 0
def generate_markdown(
version: str,
ssh_devices: List[str],
telnet_devices: List[str],
total_count: int,
custom_devices: set
version: str, ssh_devices: List[str], telnet_devices: List[str], total_count: int, custom_devices: set
) -> str:
"""
Generate Markdown documentation with table format.
@ -261,7 +251,7 @@ def generate_markdown(
md_lines.append("# Netmiko Supported Devices")
md_lines.append("")
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("")
md_lines.append("---")
@ -313,8 +303,7 @@ def generate_markdown(
# Footer
md_lines.append("---")
md_lines.append("")
md_lines.append("*This document was generated automatically by the "
"Netmiko device list script.*")
md_lines.append("*This document was generated automatically by the Netmiko device list script.*")
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/
# custom_netmiko/scripts to project root
# That's 6 levels up
project_root = os.path.dirname(os.path.dirname(
os.path.dirname(os.path.dirname(
os.path.dirname(os.path.dirname(script_dir))))
)
project_root = os.path.dirname(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(script_dir)))))
)
# 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)
try:
with open(output_path, 'w', encoding='utf-8') as f:
with open(output_path, "w", encoding="utf-8") as f:
f.write(content)
print(f"Markdown document saved to: {output_path}")
except IOError as e:
print(f"Error: Failed to save Markdown file: {e}",
file=sys.stderr)
print(f"Error: Failed to save Markdown file: {e}", file=sys.stderr)
sys.exit(1)
def print_summary(version: str, ssh_count: int, telnet_count: int,
total_count: int, output_file: str) -> None:
def print_summary(version: str, ssh_count: int, telnet_count: int, total_count: int, output_file: str) -> None:
"""Print summary to console."""
print("=" * 80)
print("Netmiko Device List Generator")
@ -402,7 +387,7 @@ def main() -> int:
ssh_devices=ssh_devices,
telnet_devices=telnet_devices,
total_count=total_count,
custom_devices=custom_devices
custom_devices=custom_devices,
)
# Save to file
@ -415,7 +400,7 @@ def main() -> int:
ssh_count=len(ssh_devices),
telnet_count=len(telnet_devices),
total_count=total_count,
output_file=output_file
output_file=output_file,
)
return 0

View File

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

View File

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

View File

@ -27,9 +27,7 @@ import os
# Add project root to path using relative path
test_dir = os.path.dirname(os.path.abspath(__file__))
project_root = os.path.dirname(
os.path.dirname(os.path.dirname(
os.path.dirname(os.path.dirname(test_dir)))))
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(test_dir)))))
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
VPCSTelnet,
)
cls.VPCSTelnet = VPCSTelnet
def test_default_enter_parameter(self):
@ -134,6 +133,7 @@ class TestVPCSTelnetInit(unittest.TestCase):
def test_default_parameters_when_none(self):
"""Test default parameters when not provided."""
# Mock the BaseConnection.__init__ to avoid actual connection
# But also set device_type since VPCSTelnet.__init__ sets it
def mock_init(self, *args, **kwargs):
@ -141,23 +141,20 @@ class TestVPCSTelnetInit(unittest.TestCase):
kwargs.setdefault("device_type", "gns3_vpcs_telnet")
self.device_type = kwargs.get("device_type")
with patch.object(
self.VPCSTelnet, "__init__", mock_init
):
with patch.object(self.VPCSTelnet, "__init__", mock_init):
instance = self.VPCSTelnet(host="127.0.0.1")
self.assertEqual(instance.device_type, "gns3_vpcs_telnet")
def test_device_type_set(self):
"""Test that device_type is set to gns3_vpcs_telnet."""
# Mock the __init__ to set device_type
def mock_init(self, *args, **kwargs):
# Simulate what VPCSTelnet.__init__ does
kwargs.setdefault("device_type", "gns3_vpcs_telnet")
self.device_type = kwargs.get("device_type")
with patch.object(
self.VPCSTelnet, "__init__", mock_init
):
with patch.object(self.VPCSTelnet, "__init__", mock_init):
instance = self.VPCSTelnet(host="127.0.0.1")
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
VPCSTelnet,
)
cls.VPCSTelnet = VPCSTelnet
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
VPCSTelnet,
)
cls.VPCSTelnet = VPCSTelnet
def test_send_command_writes_bytes(self):
@ -243,7 +242,7 @@ class TestVPCSTelnetSendCommand(unittest.TestCase):
# Check that expect_string defaults to r"PC\d+>"
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):
"""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
VPCSTelnet,
)
cls.VPCSTelnet = VPCSTelnet
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 (
vpcs_telnet,
)
cls.vpcs_telnet = vpcs_telnet
def test_register_function_exists(self):
"""Test that register_custom_device_type function exists."""
self.assertTrue(
hasattr(self.vpcs_telnet, "register_custom_device_type")
)
self.assertTrue(hasattr(self.vpcs_telnet, "register_custom_device_type"))
def test_register_function_is_callable(self):
"""Test that register_custom_device_type is callable."""
self.assertTrue(
callable(self.vpcs_telnet.register_custom_device_type)
)
self.assertTrue(callable(self.vpcs_telnet.register_custom_device_type))
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
VPCSTelnet,
)
cls.VPCSTelnet = VPCSTelnet
def test_strip_ansi_codes_method_exists(self):
@ -435,14 +433,10 @@ IP/MASK : 192.168.1.20/24"""
# Mock necessary attributes
instance.remote_conn = MagicMock()
instance.remote_conn.write = MagicMock()
instance.read_until_pattern = MagicMock(
return_value="\x1b[1mNAME\x1b[0m : PC2\nPC2>"
)
instance.read_until_pattern = MagicMock(return_value="\x1b[1mNAME\x1b[0m : PC2\nPC2>")
# Mock _strip_ansi_codes to verify it's called
instance._strip_ansi_codes = Mock(
return_value="NAME : PC2\nPC2>"
)
instance._strip_ansi_codes = Mock(return_value="NAME : PC2\nPC2>")
# Call send_command
instance.send_command("show ip")
@ -476,9 +470,7 @@ def run_tests():
print("\n" + "=" * 100)
print("Test Summary:")
print(f" Run: {result.testsRun}")
success_count = (
result.testsRun - len(result.failures) - len(result.errors)
)
success_count = result.testsRun - len(result.failures) - len(result.errors)
print(f" Success: {success_count}")
print(f" Failed: {len(result.failures)}")
print(f" Errors: {len(result.errors)}")

View File

@ -149,10 +149,7 @@ class VPCSTelnet(BaseConnection):
# Step 3: Wait for VPCS prompt pattern
try:
# Read until we see the prompt
output = self.read_until_pattern(
pattern=pri_prompt_terminator,
read_timeout=10
)
output = self.read_until_pattern(pattern=pri_prompt_terminator, read_timeout=10)
return_msg += output
if re.search(pri_prompt_terminator, return_msg, flags=re.M):
@ -291,9 +288,7 @@ class VPCSTelnet(BaseConnection):
strip_command=strip_command,
)
def check_config_mode(
self, check_string: str = "", pattern: str = "", force_regex: bool = False
) -> bool:
def check_config_mode(self, check_string: str = "", pattern: str = "", force_regex: bool = False) -> bool:
"""
VPCS has no configuration mode.
@ -326,9 +321,7 @@ class VPCSTelnet(BaseConnection):
"""
return ANSI_ESCAPE_PATTERN.sub("", text)
def config_mode(
self, config_command: str = "", pattern: str = "", re_flags: int = 0
) -> str:
def config_mode(self, config_command: str = "", pattern: str = "", re_flags: int = 0) -> str:
"""
VPCS has no configuration mode.
@ -370,6 +363,7 @@ class VPCSTelnet(BaseConnection):
# Register the custom device type with Netmiko
_registered = False # Flag to prevent duplicate registration
def register_custom_device_type() -> None:
"""
Register the custom VPCS Telnet device type with Netmiko.
@ -431,8 +425,4 @@ try:
register_custom_device_type()
except Exception as e:
# Log but don't fail on import
logger.warning(
"Failed to register VPCS device type: %s",
e,
exc_info=True
)
logger.warning("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
if _contains_html(error_str):
logger.warning(
"Detected HTML in error message, likely due to incorrect API "
"configuration or base URL"
)
logger.warning("Detected HTML in error message, likely due to incorrect API configuration or base URL")
return (
"API request failed. The error response indicates an issue with "
"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
"""
# Log received parameters
logger.info(
"Called with device_names=%s, project_id=%s", device_names, project_id
)
logger.info("Called with device_names=%s, project_id=%s", device_names, project_id)
try:
# Lazy import to avoid circular dependency
@ -93,9 +91,7 @@ def get_device_ports_from_topology(
for device_name in device_names:
# Check if device exists in topology
if device_name not in topology.get("nodes", {}):
logger.warning(
"Device '%s' not found in topology", device_name
)
logger.warning("Device '%s' not found in topology", device_name)
continue
node_info = topology["nodes"][device_name]
@ -133,9 +129,7 @@ def get_device_ports_from_topology(
f"Current tags: {tags}"
)
logger.error(error_msg)
hosts_data[device_name] = {
"error": error_msg
}
hosts_data[device_name] = {"error": error_msg}
continue
logger.debug(
@ -170,11 +164,7 @@ def get_device_ports_from_topology(
"platform": platform,
"node_type": node_info.get("type"),
"groups": ["network_devices"], # For inheriting hostname, timeout, etc.
"connection_options": {
"netmiko": {
"extras": {"device_type": device_type}
}
},
"connection_options": {"netmiko": {"extras": {"device_type": device_type}}},
}
# 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_y = node2["y"] + (node2_height / 2)
distance = math.sqrt(
(node2_center_x - node1_center_x) ** 2
+ (node2_center_y - node1_center_y) ** 2
)
distance = math.sqrt((node2_center_x - node1_center_x) ** 2 + (node2_center_y - node1_center_y) ** 2)
angle_rad = math.atan2(
node2_center_y - node1_center_y, node2_center_x - node1_center_x
)
angle_rad = math.atan2(node2_center_y - node1_center_y, node2_center_x - node1_center_x)
angle_deg = round(math.degrees(angle_rad))
angle_rad = math.radians(angle_deg)
@ -162,16 +157,10 @@ def calculate_two_node_shape(
shape_width = rx * 2
shape_height = ry * 2
svg_x = center_x - (
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_x = center_x - (rx * math.cos(angle_rad) - ry * math.sin(angle_rad))
svg_y = center_y - (rx * math.sin(angle_rad) + ry * math.cos(angle_rad))
shape_svg = generate_ellipse_svg(
int(rx), int(ry), color_scheme, int(shape_width), int(shape_height)
)
shape_svg = generate_ellipse_svg(int(rx), int(ry), color_scheme, int(shape_width), int(shape_height))
offset_distance = ry * text_offset_ratio
@ -188,22 +177,12 @@ def calculate_two_node_shape(
else: # rectangle
shape_width = distance
shape_height = max(
node1_width, node1_height, node2_width, node2_height
)
shape_height = max(node1_width, node1_height, node2_width, node2_height)
svg_x = center_x - (
(shape_width / 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)
)
svg_x = center_x - ((shape_width / 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(
int(shape_width), int(shape_height), color_scheme
)
shape_svg = generate_rectangle_svg(int(shape_width), int(shape_height), color_scheme)
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"]
# 3. Logical Isolation
if (
"VRF" in label
or "VLAN" in label
or "MSTP" in label
or "VXLAN" in label
or "MPLS" in label
):
if "VRF" in label or "VLAN" in label or "MSTP" in label or "VXLAN" in label or "MPLS" in label:
return COLOR_SCHEMES["ISOLATION"]
# 4. High Availability
if (
"VRRP" in label
or "HSRP" in label
or "HA" in label
or "STACK" in label
or "M-LAG" in label
):
if "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"]
# 5. External/Internet
if (
"INET" in label
or "OUT" in label
or "EXTERNAL" in label
or "INTERNET" in label
or "DMZ" in label
):
if "INET" in label or "OUT" in label or "EXTERNAL" in label or "INTERNET" in label or "DMZ" in label:
return COLOR_SCHEMES["EXTERNAL"]
# 6. Management
@ -392,12 +353,7 @@ def _get_color_scheme(area_name: str) -> dict[str, Any]:
return COLOR_SCHEMES["SECURITY_TRUSTED"]
# 8. Cloud/Tunnel
if (
"TUNNEL" in label
or "CLOUD" in label
or "GRE" in label
or "IPSEC" in label
):
if "TUNNEL" in label or "CLOUD" in label or "GRE" in label or "IPSEC" in label:
return COLOR_SCHEMES["CLOUD_TUNNEL"]
# 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".
"""
result = calculate_two_node_shape(
node1, node2, area_name, "ellipse", text_offset_ratio
)
result = calculate_two_node_shape(node1, node2, area_name, "ellipse", text_offset_ratio)
return {
"ellipse": result["shape"],
"text": result["text"],
@ -457,9 +411,7 @@ def calculate_two_node_rectangle(
Wrapper around calculate_two_node_shape with shape_type="rectangle".
"""
result = calculate_two_node_shape(
node1, node2, area_name, "rectangle", text_offset_ratio
)
result = calculate_two_node_shape(node1, node2, area_name, "rectangle", text_offset_ratio)
metadata = result["metadata"]
return {
"rectangle": result["shape"],

View File

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

View File

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

View File

@ -98,9 +98,7 @@ class ToolCallStreamAccumulator:
"id": tc_id,
"type": "function",
"function": {
"name": self._current_tool_call[
"name"
],
"name": self._current_tool_call["name"],
"arguments": "",
},
},
@ -122,9 +120,7 @@ class ToolCallStreamAccumulator:
# Core: string concatenation
if isinstance(args_chunk, str):
tool_data[
"args_string"
] += args_chunk
tool_data["args_string"] += args_chunk
# Send updated tool_call event with accumulated args
chunks.append(
@ -135,9 +131,7 @@ class ToolCallStreamAccumulator:
"type": "function",
"function": {
"name": tool_data["name"],
"arguments": tool_data[
"args_string"
],
"arguments": tool_data["args_string"],
},
},
}
@ -147,15 +141,9 @@ class ToolCallStreamAccumulator:
# complete ==========
# Check finish_reason == "tool_calls" or "STOP"
response_metadata = getattr(chunk, "response_metadata", {})
finish_reason = (
response_metadata.get("finish_reason")
if isinstance(response_metadata, dict)
else None
)
finish_reason = response_metadata.get("finish_reason") if isinstance(response_metadata, dict) else None
if (finish_reason == "tool_calls") or (
finish_reason == "stop" and self._current_tool_call is not None
):
if (finish_reason == "tool_calls") or (finish_reason == "stop" and self._current_tool_call is not None):
if 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 ─────────────────────────────────────────────────────────────────
def _get_connector(gns3_ctx: dict[str, Any]):
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
return Gns3Connector(
url=gns3_ctx["server_url"],
jwt_token=gns3_ctx["jwt_token"],
@ -41,12 +43,32 @@ def _get_connector(gns3_ctx: dict[str, Any]):
# ── Tool handlers ──────────────────────────────────────────────────────────
VALID_APPLIANCE_FIELDS = {
"appliance_id", "name", "category", "description", "vendor_name",
"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",
"appliance_id",
"name",
"category",
"description",
"vendor_name",
"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")
if fields:
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]
if invalid:
return {

View File

@ -27,6 +27,7 @@ log = logging.getLogger(__name__)
def _get_connector(gns3_ctx: dict[str, Any]):
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
return Gns3Connector(
url=gns3_ctx["server_url"],
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 ──────────────────────────────────────────────────────────
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."""
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
tool = ExecuteMultipleDeviceConfigCommands()
input_data = json.dumps({
"project_id": project_id,
"device_configs": device_configs,
})
input_data = json.dumps(
{
"project_id": project_id,
"device_configs": device_configs,
}
)
return tool._run(
input_data,
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")
template = params.get("template")
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:
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
tool = ExecuteMultipleDeviceCommands()
input_data = json.dumps({
"project_id": project_id,
"device_configs": device_configs,
})
input_data = json.dumps(
{
"project_id": project_id,
"device_configs": device_configs,
}
)
return tool._run(
input_data,
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
tool = VPCSCommands()
input_data = json.dumps({
"project_id": project_id,
"device_configs": device_configs,
})
input_data = json.dumps(
{
"project_id": project_id,
"device_configs": device_configs,
}
)
return tool._run(
input_data,
jwt_token=gns3_ctx["jwt_token"],

View File

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

View File

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

View File

@ -31,9 +31,11 @@ log = logging.getLogger(__name__)
# ── Helper ─────────────────────────────────────────────────────────────────
def _get_connector(gns3_ctx: dict[str, Any]):
"""Create a Gns3Connector from the GNS3 context dict."""
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
return Gns3Connector(
url=gns3_ctx["server_url"],
jwt_token=gns3_ctx["jwt_token"],
@ -44,6 +46,7 @@ def _get_connector(gns3_ctx: dict[str, Any]):
# ── Tool handlers ──────────────────────────────────────────────────────────
def list_projects_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
conn = _get_connector(gns3_ctx)
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}
except Exception as 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
@ -286,7 +294,10 @@ PROJECT_TOOLS = [
"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"},
"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"],
},

View File

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

View File

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

View File

@ -30,8 +30,10 @@ log = logging.getLogger(__name__)
# ── Helper ─────────────────────────────────────────────────────────────────
def _get_connector(gns3_ctx: dict[str, Any]):
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
return Gns3Connector(
url=gns3_ctx["server_url"],
jwt_token=gns3_ctx["jwt_token"],
@ -42,6 +44,7 @@ def _get_connector(gns3_ctx: dict[str, Any]):
# ── Tool handlers ──────────────────────────────────────────────────────────
def get_symbols_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
conn = _get_connector(gns3_ctx)
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")
# short-lived ticket bound to this exact path — LLM clients retyping curl
# commands corrupted the long Bearer JWT this used to embed
ticket = access_ticket_service.mint(
username, token_version=gns3_ctx.get("jwt_token_version", 0), path=path
) if username else None
ticket = (
access_ticket_service.mint(username, token_version=gns3_ctx.get("jwt_token_version", 0), path=path)
if username
else None
)
if ticket:
download_url += f"?token={ticket}"
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.",
}
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["note"] += " The download URL includes a 10-minute ticket."
return result

View File

@ -31,8 +31,10 @@ log = logging.getLogger(__name__)
# ── Helper ─────────────────────────────────────────────────────────────────
def _get_connector(gns3_ctx: dict[str, Any]):
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
return Gns3Connector(
url=gns3_ctx["server_url"],
jwt_token=gns3_ctx["jwt_token"],
@ -44,9 +46,19 @@ def _get_connector(gns3_ctx: dict[str, Any]):
# ── Tool handlers ──────────────────────────────────────────────────────────
VALID_TEMPLATE_FIELDS = {
"template_id", "name", "version", "category", "default_name_format",
"symbol", "template_type", "compute_id", "usage", "tags", "builtin",
"created_at", "updated_at",
"template_id",
"name",
"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"]
@ -67,7 +79,7 @@ def list_templates_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) ->
fields = params.get("fields")
if fields:
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]
if invalid:
return {

View File

@ -83,7 +83,7 @@ class DockerHTTPClient:
self._connected = True
# Parse API version
api_version = parse_version(docker_info['ApiVersion'])
api_version = parse_version(docker_info["ApiVersion"])
docker_version = docker_info["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}")
else:
# 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)")
except (aiohttp.ClientError, FileNotFoundError) as e:
@ -124,7 +124,7 @@ class DockerHTTPClient:
Response JSON data
"""
# 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:
await self._check_connection()
@ -147,22 +147,21 @@ class DockerHTTPClient:
except RuntimeError as e:
# Retry with lower API version if Docker daemon doesn't support current version
error_msg = str(e)
if ("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}")
if (
"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
return await self._request(method, endpoint, check_connection=False, **kwargs)
raise
async def create_network(self, name: str, driver: str = "bridge", subnet: str = None):
"""Create Docker network."""
data = {
"Name": name,
"Driver": driver
}
data = {"Name": name, "Driver": driver}
if subnet:
data["IPAM"] = {
"Config": [{"Subnet": subnet}]
}
data["IPAM"] = {"Config": [{"Subnet": subnet}]}
await self._request("POST", "networks/create", json=data)
async def get_network(self, name: str):
@ -176,19 +175,12 @@ class DockerHTTPClient:
async def create_container(self, name: str, image: str, **kwargs):
"""Create container."""
data = {
"Image": image,
"name": name,
"HostConfig": {},
"NetworkingConfig": {}
}
data = {"Image": image, "name": name, "HostConfig": {}, "NetworkingConfig": {}}
# Handle network config
if "network" in kwargs:
network_name = kwargs.pop("network")
data["NetworkingConfig"]["EndpointsConfig"] = {
network_name: {}
}
data["NetworkingConfig"]["EndpointsConfig"] = {network_name: {}}
# Handle environment variables
if "environment" in kwargs:

View File

@ -92,10 +92,7 @@ logger = logging.getLogger(__name__)
def setup_logging(verbose: bool = False):
"""Setup logging configuration."""
level = logging.DEBUG if verbose else logging.INFO
logging.basicConfig(
level=level,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logging.basicConfig(level=level, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
async def cmd_start(args) -> int:
@ -119,7 +116,7 @@ async def cmd_start(args) -> int:
image=args.image,
memory=args.memory,
cpus=args.cpus,
pids_limit=args.pids_limit
pids_limit=args.pids_limit,
)
print(json.dumps(result, indent=2))
@ -143,10 +140,7 @@ async def cmd_stop(args) -> int:
"""
manager = WebWiresharkManager()
try:
await manager.stop_wireshark_session(
project_id=args.project_id,
link_id=args.link_id
)
await manager.stop_wireshark_session(project_id=args.project_id, link_id=args.link_id)
print(json.dumps({"status": "stopped"}))
return 0
except Exception as e:
@ -172,7 +166,7 @@ async def cmd_restart(args) -> int:
project_id=args.project_id,
link_id=args.link_id,
jwt_token=args.jwt_token,
capture_stream_url=args.capture_url
capture_stream_url=args.capture_url,
)
print(json.dumps(result, indent=2))
return 0
@ -274,14 +268,8 @@ async def cmd_delete(args) -> int:
def create_parser() -> argparse.ArgumentParser:
"""Create command line argument parser."""
parser = argparse.ArgumentParser(
description="Web Wireshark container management"
)
parser.add_argument(
"--verbose", "-v",
action="store_true",
help="Enable verbose logging"
)
parser = argparse.ArgumentParser(description="Web Wireshark container management")
parser.add_argument("--verbose", "-v", action="store_true", help="Enable verbose logging")
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("--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("--capture-url", help="Capture stream URL (auto-detected if not provided)")
start_parser.add_argument(
"--capture-url",
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)"
"--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)")
# Stop command
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("--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(
"--capture-url",
help="Capture stream URL (auto-detected if not provided)"
)
restart_parser.add_argument("--capture-url", help="Capture stream URL (auto-detected if not provided)")
# Stop all command
stop_all_parser = subparsers.add_parser("stop-all", help="Stop all sessions")

View File

@ -63,15 +63,16 @@ class WebWiresharkManager:
"""
try:
proc = await asyncio.create_subprocess_exec(
"docker", "exec", container_id,
"bash", "-c", "echo 'ping'",
"docker",
"exec",
container_id,
"bash",
"-c",
"echo 'ping'",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, _ = await asyncio.wait_for(
proc.communicate(),
timeout=self.CONTAINER_EXEC_TIMEOUT
stderr=asyncio.subprocess.PIPE,
)
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=self.CONTAINER_EXEC_TIMEOUT)
return proc.returncode == 0 and b"ping" in stdout
except asyncio.TimeoutError:
logger.warning(f"Container {container_id[:12]} health check timeout")
@ -99,16 +100,17 @@ class WebWiresharkManager:
try:
proc = await asyncio.create_subprocess_exec(
"docker", "exec", container_id,
"bash", "-c", command,
"docker",
"exec",
container_id,
"bash",
"-c",
command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
stderr=asyncio.subprocess.PIPE,
)
try:
stdout, stderr = await asyncio.wait_for(
proc.communicate(),
timeout=timeout
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
return (proc.returncode, stdout.decode().strip(), stderr.decode().strip())
except asyncio.TimeoutError:
logger.error(f"Command timeout after {timeout}s: {command}")
@ -142,7 +144,7 @@ class WebWiresharkManager:
logger.debug(f"No processes found matching pattern '{pattern}'")
return
pids = pids_str.replace('\n', ' ')
pids = pids_str.replace("\n", " ")
logger.info(f"Found matching processes for '{pattern}': PIDs={pids}")
# Kill the processes using their container-local PIDs
@ -170,10 +172,13 @@ class WebWiresharkManager:
try:
# Get container init PID from host perspective
proc = await asyncio.create_subprocess_exec(
"docker", "inspect", container_id,
"--format", "{{.State.Pid}}",
"docker",
"inspect",
container_id,
"--format",
"{{.State.Pid}}",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
stderr=asyncio.subprocess.PIPE,
)
stdout, _ = await proc.communicate()
container_init_pid = stdout.decode().strip()
@ -187,15 +192,13 @@ class WebWiresharkManager:
# Check for residual processes from host perspective
proc = await asyncio.create_subprocess_exec(
"ps", "-eo", "pid,ppid,args",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
"ps", "-eo", "pid,ppid,args", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
stdout, _ = await proc.communicate()
# Build parent->children mapping
children_map = {}
for line in stdout.decode().strip().split('\n'):
for line in stdout.decode().strip().split("\n"):
parts = line.split(None, 2)
if len(parts) < 3:
continue
@ -218,14 +221,15 @@ class WebWiresharkManager:
# Check if any descendant processes match display patterns
import re
patterns = [
f'xpra.*:{display}',
f'Xvfb.*:{display}',
f'wireshark.*:{display}',
f'pulseaudio.*display=:{display}'
f"xpra.*:{display}",
f"Xvfb.*:{display}",
f"wireshark.*:{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)
if len(parts) < 3:
continue
@ -248,13 +252,11 @@ class WebWiresharkManager:
# /proc/<pid>/root points to container filesystem
lock_paths = [
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:
proc = await asyncio.create_subprocess_exec(
"test", "-e", lock_path,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
"test", "-e", lock_path, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
await proc.communicate()
if proc.returncode == 0:
@ -264,9 +266,7 @@ class WebWiresharkManager:
# Check for xpra socket files
socket_path = f"/proc/{container_init_pid}/root/run/user/1000/xpra/{display}/socket"
proc = await asyncio.create_subprocess_exec(
"test", "-e", socket_path,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
"test", "-e", socket_path, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
await proc.communicate()
if proc.returncode == 0:
@ -298,10 +298,13 @@ class WebWiresharkManager:
try:
# Get container init PID from host perspective
proc = await asyncio.create_subprocess_exec(
"docker", "inspect", container_id,
"--format", "{{.State.Pid}}",
"docker",
"inspect",
container_id,
"--format",
"{{.State.Pid}}",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
stderr=asyncio.subprocess.PIPE,
)
stdout, _ = await proc.communicate()
container_init_pid = stdout.decode().strip()
@ -314,9 +317,7 @@ class WebWiresharkManager:
# 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
proc = await asyncio.create_subprocess_exec(
"ps", "-eo", "pid,ppid,args",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
"ps", "-eo", "pid,ppid,args", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
stdout, _ = await proc.communicate()
@ -328,7 +329,7 @@ class WebWiresharkManager:
children_map = {} # ppid -> [pid]
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)
if len(parts) < 3:
continue
@ -371,9 +372,7 @@ class WebWiresharkManager:
logger.info(f"Killing {len(matching_pids)} processes: {matching_pids}")
# Kill all matching PIDs from host perspective
proc = await asyncio.create_subprocess_exec(
"kill", "-9", *matching_pids,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
"kill", "-9", *matching_pids, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
await proc.communicate()
logger.debug("Kill completed from host perspective")
@ -396,16 +395,12 @@ class WebWiresharkManager:
"""
try:
# 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
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(
container_id,
pgrep_cmd,
timeout=5
)
returncode, stdout, stderr = await self._exec_in_container(container_id, pgrep_cmd, timeout=5)
if "Found processes:" in stdout:
logger.info(stdout.strip())
@ -422,10 +417,12 @@ class WebWiresharkManager:
"""
# 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
cmd = (f"rm -f /tmp/.X{display}-lock /tmp/.X11-unix/X{display} "
f"/run/user/1000/xpra/{display}/socket "
f"/run/user/1000/xpra/*-{display} "
f"/home/gns3/.xpra/*-{display} 2>/dev/null || true")
cmd = (
f"rm -f /tmp/.X{display}-lock /tmp/.X11-unix/X{display} "
f"/run/user/1000/xpra/{display}/socket "
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)
logger.debug(f"Cleanup X locks and xpra sockets: returncode={returncode}")
@ -436,9 +433,11 @@ class WebWiresharkManager:
container_id: Container ID
display: Display number (e.g., 10210)
"""
cmd = (f"rm -f /run/user/1000/xpra/{display}/socket "
f"/run/user/1000/xpra/*-{display} "
f"/home/gns3/.xpra/*-{display} 2>/dev/null || true")
cmd = (
f"rm -f /run/user/1000/xpra/{display}/socket "
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)
logger.debug(f"Cleanup socket files: returncode={returncode}")
@ -457,20 +456,15 @@ class WebWiresharkManager:
return 0
# 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:
raise ValueError(f"Invalid memory format: {memory_str}")
value = float(match.group(1))
unit = match.group(2) or 'b'
unit = match.group(2) or "b"
# Convert to bytes
multipliers = {
'b': 1,
'k': 1024,
'm': 1024 * 1024,
'g': 1024 * 1024 * 1024
}
multipliers = {"b": 1, "k": 1024, "m": 1024 * 1024, "g": 1024 * 1024 * 1024}
unit = unit[0] # Take first character
return int(value * multipliers.get(unit, 1))
@ -511,8 +505,7 @@ class WebWiresharkManager:
if container_id:
try:
returncode, stdout, stderr = await self._exec_in_container(
container_id,
"cat /proc/net/route | grep -E '^eth0\\s+00000000' | awk '{print $3}' | head -1"
container_id, "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}'")
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
# This works on most Linux systems and is more portable than 'ip' command
returncode, stdout, stderr = await self._exec_in_container(
container_id,
"hostname -I 2>/dev/null | awk '{print $1}'"
container_id, "hostname -I 2>/dev/null | awk '{print $1}'"
)
if returncode == 0 and stdout.strip():
container_ip = stdout.strip()
@ -585,7 +577,7 @@ class WebWiresharkManager:
"""
try:
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)
if gateway:
fixed_url = f"{parsed.scheme}://{gateway}:{parsed.port or 3080}{parsed.path}"
@ -627,11 +619,7 @@ class WebWiresharkManager:
config = Config.instance()
subnet = getattr(config.settings.WebWireshark, "network_subnet", "172.31.0.0/22")
logger.info(f"Creating network {self.network_name} with subnet {subnet}")
await self.docker.create_network(
self.network_name,
driver="bridge",
subnet=subnet
)
await self.docker.create_network(self.network_name, driver="bridge", subnet=subnet)
async def get_or_create_container(
self,
@ -640,7 +628,7 @@ class WebWiresharkManager:
memory: str = "2g",
memory_swap: str = None,
cpus: float = 1.0,
pids_limit: int = 1000
pids_limit: int = 1000,
) -> str:
"""Get or create project's Web Wireshark container.
@ -716,21 +704,15 @@ class WebWiresharkManager:
"PidsLimit": pids_limit,
"RestartPolicy": {"Name": "unless-stopped"},
"Init": True, # Use init system (tini) as PID 1 to reap zombie processes
"LogConfig": {
"Type": "json-file",
"Config": {
"max-size": "10m",
"max-file": "3"
}
}
"LogConfig": {"Type": "json-file", "Config": {"max-size": "10m", "max-file": "3"}},
}
# Health check configuration
health_config = {
"Test": ["CMD-SHELL", "xpra list"],
"Interval": 30000000000, # 30 seconds (nanoseconds)
"Timeout": 10000000000, # 10 seconds
"Retries": 3
"Timeout": 10000000000, # 10 seconds
"Retries": 3,
}
try:
@ -740,11 +722,7 @@ class WebWiresharkManager:
network=self.network_name,
host_config=host_config,
health_config=health_config,
environment={
"XDG_RUNTIME_DIR": "/run/user/1000",
"LANG": "C.UTF-8",
"LC_ALL": "C.UTF-8"
}
environment={"XDG_RUNTIME_DIR": "/run/user/1000", "LANG": "C.UTF-8", "LC_ALL": "C.UTF-8"},
)
except RuntimeError as e:
error_msg = str(e)
@ -773,7 +751,7 @@ class WebWiresharkManager:
memory: str = "2g",
memory_swap: Optional[str] = None,
cpus: float = 1.0,
pids_limit: int = 1000
pids_limit: int = 1000,
) -> dict:
"""Start Web Wireshark session.
@ -799,20 +777,10 @@ class WebWiresharkManager:
# Auto-detect capture_stream_url if not provided
if not capture_stream_url:
gns3_url = self.detect_gns3_url()
capture_stream_url = (
f"{gns3_url}/v3/projects/{project_id}/links/"
f"{link_id}/capture/stream"
)
capture_stream_url = f"{gns3_url}/v3/projects/{project_id}/links/{link_id}/capture/stream"
logger.info(f"Auto-detected capture stream URL: {capture_stream_url}")
container_id = await self.get_or_create_container(
project_id,
image,
memory,
memory_swap,
cpus,
pids_limit
)
container_id = await self.get_or_create_container(project_id, image, memory, memory_swap, cpus, pids_limit)
container_name = f"gns3-wireshark-{project_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)
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:
patterns = [
f'xpra.*:{display}',
f'Xvfb.*:{display}',
f'wireshark.*:{display}',
f'pulseaudio.*display=:{display}'
f"xpra.*:{display}",
f"Xvfb.*:{display}",
f"wireshark.*:{display}",
f"pulseaudio.*display=:{display}",
]
await self._kill_process_tree_batch(container_id, patterns)
if has_socket_residuals:
@ -848,29 +818,24 @@ class WebWiresharkManager:
xpra_cmd = [
"XPRA_CLIENT_CAN_SHUTDOWN=false",
"xpra", "start", f":{display}",
"xpra",
"start",
f":{display}",
'--xvfb="Xvfb -screen 0 1920x1080x24 +extension RANDR"',
"--html=off",
f"--bind-ws=0.0.0.0:{port}",
f"--session-name={session_name}",
"--daemon=yes",
"--dbus-launch=no",
"--resize-display=yes"
"--resize-display=yes",
]
# Parallel execution: get container info + start xpra
container_info_task = asyncio.create_task(
self.docker.get_container(container_name)
)
xpra_start_task = asyncio.create_task(
self._exec_in_container(container_id, " ".join(xpra_cmd))
)
container_info_task = asyncio.create_task(self.docker.get_container(container_name))
xpra_start_task = asyncio.create_task(self._exec_in_container(container_id, " ".join(xpra_cmd)))
# Wait for both tasks to complete
container, (returncode, stdout, stderr) = await asyncio.gather(
container_info_task,
xpra_start_task
)
container, (returncode, stdout, stderr) = await asyncio.gather(container_info_task, xpra_start_task)
# Check xpra start result
if returncode != 0:
@ -901,10 +866,14 @@ class WebWiresharkManager:
# Execute Wireshark command without waiting for completion
await asyncio.create_subprocess_exec(
"docker", "exec", container_id,
"bash", "-c", wireshark_cmd,
"docker",
"exec",
container_id,
"bash",
"-c",
wireshark_cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
stderr=asyncio.subprocess.PIPE,
)
# Don't wait - Wireshark runs in background
@ -918,7 +887,7 @@ class WebWiresharkManager:
"container_name": container_name,
"container_id": container_id,
"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']}")
@ -947,11 +916,11 @@ class WebWiresharkManager:
# This ensures child processes are properly terminated, not left as zombies
logger.info(f"Stopping all processes on display :{display}")
patterns = [
f'xpra.*:{display}',
f'Xvfb.*:{display}',
f'Xvfb-for-Xpra-{display}',
f'wireshark.*:{display}',
f'pulseaudio.*display=:{display}'
f"xpra.*:{display}",
f"Xvfb.*:{display}",
f"Xvfb-for-Xpra-{display}",
f"wireshark.*:{display}",
f"pulseaudio.*display=:{display}",
]
await self._kill_process_tree_batch(container["Id"], patterns)
await self._cleanup_x_lock(container["Id"], display)
@ -971,7 +940,7 @@ class WebWiresharkManager:
memory: str = "2g",
memory_swap: str = None,
cpus: float = 1.0,
pids_limit: int = 1000
pids_limit: int = 1000,
):
"""Restart Web Wireshark session.
@ -1001,7 +970,7 @@ class WebWiresharkManager:
memory=memory,
memory_swap=memory_swap,
cpus=cpus,
pids_limit=pids_limit
pids_limit=pids_limit,
)
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
# This ensures clean removal of all session processes
patterns = [
"xpra.*--session-name=link-",
"Xvfb-for-Xpra-",
"wireshark.*display :",
"pulseaudio.*display :"
]
patterns = ["xpra.*--session-name=link-", "Xvfb-for-Xpra-", "wireshark.*display :", "pulseaudio.*display :"]
await self._kill_process_tree_batch(container["Id"], patterns)
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
try:
result = subprocess.run(
["docker", "info"],
capture_output=True,
text=True
)
result = subprocess.run(["docker", "info"], capture_output=True, text=True)
if result.returncode != 0:
print("Error: Docker daemon is not running", 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."""
try:
result = subprocess.run(
["docker", "image", "inspect", DOCKER_IMAGE],
capture_output=True,
text=True
)
result = subprocess.run(["docker", "image", "inspect", DOCKER_IMAGE], capture_output=True, text=True)
return result.returncode == 0
except Exception:
return False
@ -115,11 +107,7 @@ def pull_image():
print(f"Pulling Docker image: {DOCKER_IMAGE}")
print("-" * 60)
result = subprocess.run(
["docker", "pull", DOCKER_IMAGE],
capture_output=True,
text=True
)
result = subprocess.run(["docker", "pull", DOCKER_IMAGE], capture_output=True, text=True)
# Store output for network error detection
pull_output = result.stdout + result.stderr
@ -140,33 +128,17 @@ def build_image(dockerfile_path):
print("-" * 60)
result = subprocess.run(
["docker", "build", "-t", DOCKER_IMAGE, "-f", dockerfile_path, "."],
cwd=dockerfile_dir,
pass_fds=(1, 2)
["docker", "build", "-t", DOCKER_IMAGE, "-f", dockerfile_path, "."], cwd=dockerfile_dir, pass_fds=(1, 2)
)
return result.returncode == 0
def main():
parser = argparse.ArgumentParser(
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(
"--pull-only",
action="store_true",
help="Only pull from registry, skip build"
)
parser = argparse.ArgumentParser(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("--pull-only", action="store_true", help="Only pull from registry, skip build")
args = parser.parse_args()
@ -183,7 +155,7 @@ def main():
if image_exists() and not args.force:
print(f"Image {DOCKER_IMAGE} already exists.")
response = input("Do you want to rebuild it? [y/N]: ").strip().lower()
if response != 'y':
if response != "y":
print("Setup cancelled.")
sys.exit(0)
print()

View File

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

View File

@ -155,113 +155,71 @@ async def http_exception_handler(request: Request, exc: StarletteHTTPException):
)
compute_api.include_router(
capabilities.router,
dependencies=[Depends(compute_authentication)],
tags=["Capabilities"]
)
compute_api.include_router(capabilities.router, dependencies=[Depends(compute_authentication)], tags=["Capabilities"])
compute_api.include_router(
compute.router,
dependencies=[Depends(compute_authentication)],
tags=["Compute"]
)
compute_api.include_router(compute.router, dependencies=[Depends(compute_authentication)], tags=["Compute"])
compute_api.include_router(
notifications.router,
tags=["Notifications"]
)
compute_api.include_router(notifications.router, tags=["Notifications"])
compute_api.include_router(
projects.router,
dependencies=[Depends(compute_authentication)],
tags=["Projects"]
)
compute_api.include_router(projects.router, dependencies=[Depends(compute_authentication)], tags=["Projects"])
compute_api.include_router(
images.router,
dependencies=[Depends(compute_authentication)],
tags=["Images"]
)
compute_api.include_router(images.router, dependencies=[Depends(compute_authentication)], tags=["Images"])
compute_api.include_router(
atm_switch_nodes.router,
dependencies=[Depends(compute_authentication)],
prefix="/projects/{project_id}/atm_switch/nodes",
tags=["ATM switch"]
tags=["ATM switch"],
)
compute_api.include_router(
cloud_nodes.router,
dependencies=[Depends(compute_authentication)],
prefix="/projects/{project_id}/cloud/nodes",
tags=["Cloud nodes"]
tags=["Cloud nodes"],
)
compute_api.include_router(
docker_nodes.router,
prefix="/projects/{project_id}/docker/nodes",
tags=["Docker nodes"]
)
compute_api.include_router(docker_nodes.router, prefix="/projects/{project_id}/docker/nodes", tags=["Docker nodes"])
compute_api.include_router(
dynamips_nodes.router,
prefix="/projects/{project_id}/dynamips/nodes",
tags=["Dynamips nodes"]
dynamips_nodes.router, prefix="/projects/{project_id}/dynamips/nodes", tags=["Dynamips nodes"]
)
compute_api.include_router(
ethernet_hub_nodes.router,
dependencies=[Depends(compute_authentication)],
prefix="/projects/{project_id}/ethernet_hub/nodes",
tags=["Ethernet hub nodes"]
tags=["Ethernet hub nodes"],
)
compute_api.include_router(
ethernet_switch_nodes.router,
dependencies=[Depends(compute_authentication)],
prefix="/projects/{project_id}/ethernet_switch/nodes",
tags=["Ethernet switch nodes"]
tags=["Ethernet switch nodes"],
)
compute_api.include_router(
frame_relay_switch_nodes.router,
dependencies=[Depends(compute_authentication)],
prefix="/projects/{project_id}/frame_relay_switch/nodes",
tags=["Frame Relay switch nodes"]
tags=["Frame Relay switch nodes"],
)
compute_api.include_router(
iou_nodes.router,
prefix="/projects/{project_id}/iou/nodes",
tags=["IOU nodes"])
compute_api.include_router(iou_nodes.router, prefix="/projects/{project_id}/iou/nodes", tags=["IOU nodes"])
compute_api.include_router(
nat_nodes.router,
dependencies=[Depends(compute_authentication)],
prefix="/projects/{project_id}/nat/nodes",
tags=["NAT nodes"]
tags=["NAT nodes"],
)
compute_api.include_router(
qemu_nodes.router,
prefix="/projects/{project_id}/qemu/nodes",
tags=["Qemu nodes"]
)
compute_api.include_router(qemu_nodes.router, prefix="/projects/{project_id}/qemu/nodes", tags=["Qemu nodes"])
compute_api.include_router(
virtualbox_nodes.router,
prefix="/projects/{project_id}/virtualbox/nodes",
tags=["VirtualBox nodes"]
virtualbox_nodes.router, prefix="/projects/{project_id}/virtualbox/nodes", tags=["VirtualBox nodes"]
)
compute_api.include_router(
vmware_nodes.router,
prefix="/projects/{project_id}/vmware/nodes",
tags=["VMware nodes"]
)
compute_api.include_router(vmware_nodes.router, prefix="/projects/{project_id}/vmware/nodes", tags=["VMware nodes"])
compute_api.include_router(
vpcs_nodes.router,
prefix="/projects/{project_id}/vpcs/nodes",
tags=["VPCS nodes"]
)
compute_api.include_router(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)
async def duplicate_atm_switch(
destination_node_id: UUID = Body(..., embed=True),
node: ATMSwitch = Depends(dep_node)
destination_node_id: UUID = Body(..., embed=True), node: ATMSwitch = Depends(dep_node)
) -> schemas.ATMSwitch:
"""
Duplicate an ATM switch node.
@ -92,8 +91,7 @@ async def duplicate_atm_switch(
@router.put("/{node_id}", response_model=schemas.ATMSwitch)
async def update_atm_switch(
node_data: schemas.ATMSwitchUpdate,
node: ATMSwitch = Depends(dep_node)
node_data: schemas.ATMSwitchUpdate, node: ATMSwitch = Depends(dep_node)
) -> schemas.ATMSwitch:
"""
Update an ATM switch node.
@ -124,8 +122,7 @@ def start_atm_switch(node: ATMSwitch = Depends(dep_node)) -> None:
"""
raise HTTPException(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
detail="Start is not supported for ATM switches"
status_code=status.HTTP_405_METHOD_NOT_ALLOWED, 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.
"""
raise HTTPException(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
detail="Stop is not supported for ATM switches"
)
raise HTTPException(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)
@ -149,8 +143,7 @@ def suspend_atm_switch(node: ATMSwitch = Depends(dep_node)) -> None:
"""
raise HTTPException(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
detail="Suspend is not supported for ATM switches"
status_code=status.HTTP_405_METHOD_NOT_ALLOWED, 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,
)
async def create_nio(
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
nio_data: schemas.UDPNIO,
node: ATMSwitch = Depends(dep_node)
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
nio_data: schemas.UDPNIO,
node: ATMSwitch = Depends(dep_node),
) -> schemas.UDPNIO:
"""
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")
async def start_capture(
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
node_capture_data: schemas.NodeCapture,
node: ATMSwitch = Depends(dep_node)
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
node_capture_data: schemas.NodeCapture,
node: ATMSwitch = Depends(dep_node),
) -> dict:
"""
Start a packet capture on the node.
@ -210,10 +203,7 @@ async def start_capture(
status_code=status.HTTP_204_NO_CONTENT,
)
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:
"""
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")
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:
"""
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.
"""
raise HTTPException(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
detail="Stop is not supported for cloud nodes"
)
raise HTTPException(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)
@ -136,8 +133,7 @@ async def suspend_cloud(node: Cloud = Depends(dep_node)) -> None:
"""
raise HTTPException(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
detail="Suspend is not supported for cloud nodes"
status_code=status.HTTP_405_METHOD_NOT_ALLOWED, 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],
)
async def create_cloud_nio(
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
nio_data: Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO],
node: Cloud = Depends(dep_node),
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
nio_data: Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO],
node: Cloud = Depends(dep_node),
) -> Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO]:
"""
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],
)
async def update_cloud_nio(
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
nio_data: Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO],
node: Cloud = Depends(dep_node),
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
nio_data: Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO],
node: Cloud = Depends(dep_node),
) -> Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO]:
"""
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)
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:
"""
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")
async def start_cloud_capture(
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
node_capture_data: schemas.NodeCapture,
node: Cloud = Depends(dep_node)
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
node_capture_data: schemas.NodeCapture,
node: Cloud = Depends(dep_node),
) -> dict:
"""
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
)
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:
"""
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")
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:
"""
Stream the pcap capture file.
@ -257,13 +244,9 @@ async def stream_pcap_file(
return StreamingResponse(stream, media_type="application/vnd.tcpdump.pcap")
@router.put(
"/{node_id}/markers/{marker_name}"
)
@router.put("/{node_id}/markers/{marker_name}")
async def toggle_cloud_marker(
marker_name: str,
toggle_data: schemas.MarkerToggle,
node: Cloud = Depends(dep_node)
marker_name: str, toggle_data: schemas.MarkerToggle, node: Cloud = Depends(dep_node)
) -> dict:
"""
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}
@router.post(
"/{node_id}/markers/pause",
status_code=status.HTTP_204_NO_CONTENT
)
@router.post("/{node_id}/markers/pause", status_code=status.HTTP_204_NO_CONTENT)
async def pause_cloud_markers(node: Cloud = Depends(dep_node)) -> None:
await node._ubridge_marker_pause()
@router.post(
"/{node_id}/markers/resume",
status_code=status.HTTP_204_NO_CONTENT
)
@router.post("/{node_id}/markers/resume", status_code=status.HTTP_204_NO_CONTENT)
async def resume_cloud_markers(node: Cloud = Depends(dep_node)) -> None:
await node._ubridge_marker_resume()
@ -298,7 +275,7 @@ async def resume_cloud_markers(node: Cloud = Depends(dep_node)) -> None:
@router.delete(
"/{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(
*,
@ -306,7 +283,7 @@ async def delete_cloud_marker_capture(
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
link_id: str = "",
node: Cloud = Depends(dep_node)
node: Cloud = Depends(dep_node),
) -> None:
"""
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")
async def rebuild_cloud_marker(
marker_name: str,
rebuild_data: schemas.MarkerRebuild,
node: Cloud = Depends(dep_node)
marker_name: str, rebuild_data: schemas.MarkerRebuild, node: Cloud = Depends(dep_node)
) -> dict:
"""
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(
marker_name, rebuild_data.link_id, rebuild_data.bpf,
rebuild_data.tag, rebuild_data.direction, rebuild_data.enabled,
marker_name,
rebuild_data.link_id,
rebuild_data.bpf,
rebuild_data.tag,
rebuild_data.direction,
rebuild_data.enabled,
)
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:
"""
Authenticate compute requests.
Returns None if authentication is disabled or if authentication succeeds
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"},
)
async def ws_compute_authentication(websocket: WebSocket) -> Union[None, WebSocket]:
"""
"""
""" """
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,
status_code=status.HTTP_201_CREATED,
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:
"""
@ -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
# re-trigger console/aux port setter logging.
for key in (
"console", "console_type", "console_resolution", "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",
"console",
"console_type",
"console_resolution",
"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)
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()
@router.get(
"/{node_id}",
response_model=schemas.Docker,
dependencies=[Depends(compute_authentication)]
)
@router.get("/{node_id}", response_model=schemas.Docker, dependencies=[Depends(compute_authentication)])
def get_docker_node(node: DockerVM = Depends(dep_node)) -> schemas.Docker:
"""
Return a Docker node.
@ -114,11 +123,7 @@ def get_docker_node(node: DockerVM = Depends(dep_node)) -> schemas.Docker:
return node.asdict()
@router.put(
"/{node_id}",
response_model=schemas.Docker,
dependencies=[Depends(compute_authentication)]
)
@router.put("/{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:
"""
Update a Docker node.
@ -160,11 +165,7 @@ async def update_docker_node(node_data: schemas.DockerUpdate, node: DockerVM = D
return node.asdict()
@router.post(
"/{node_id}/start",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
)
@router.post("/{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:
"""
Start a Docker node.
@ -173,11 +174,7 @@ async def start_docker_node(node: DockerVM = Depends(dep_node)) -> None:
await node.start()
@router.post(
"/{node_id}/stop",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
)
@router.post("/{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:
"""
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(
"/{node_id}/suspend",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
"/{node_id}/suspend", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
)
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(
"/{node_id}/reload",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
"/{node_id}/reload", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
)
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()
@router.post(
"/{node_id}/pause",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
)
@router.post("/{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:
"""
Pause a Docker node.
@ -228,9 +217,7 @@ async def pause_docker_node(node: DockerVM = Depends(dep_node)) -> None:
@router.post(
"/{node_id}/unpause",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
"/{node_id}/unpause", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
)
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()
@router.delete(
"/{node_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
)
@router.delete("/{node_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
async def delete_docker_node(node: DockerVM = Depends(dep_node)) -> None:
"""
Delete a Docker node.
@ -258,11 +241,10 @@ async def delete_docker_node(node: DockerVM = Depends(dep_node)) -> None:
"/{node_id}/duplicate",
response_model=schemas.Docker,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def duplicate_docker_node(
destination_node_id: UUID = Body(..., embed=True),
node: DockerVM = Depends(dep_node)
destination_node_id: UUID = Body(..., embed=True), node: DockerVM = Depends(dep_node)
) -> schemas.Docker:
"""
Duplicate a Docker node.
@ -276,7 +258,7 @@ async def duplicate_docker_node(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
status_code=status.HTTP_201_CREATED,
response_model=schemas.UDPNIO,
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def create_docker_node_nio(
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",
status_code=status.HTTP_201_CREATED,
response_model=schemas.UDPNIO,
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def update_docker_node_nio(
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(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def delete_docker_node_nio(
adapter_number: int,
port_number: int,
node: DockerVM = Depends(dep_node)
) -> None:
async def delete_docker_node_nio(adapter_number: int, port_number: int, node: DockerVM = Depends(dep_node)) -> None:
"""
Delete a NIO (Network Input/Output) from the node.
The port number on the Docker node is always 0.
@ -335,13 +313,10 @@ async def delete_docker_node_nio(
@router.post(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start",
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def start_docker_node_capture(
adapter_number: int,
port_number: int,
node_capture_data: schemas.NodeCapture,
node: DockerVM = Depends(dep_node)
adapter_number: int, port_number: int, node_capture_data: schemas.NodeCapture, node: DockerVM = Depends(dep_node)
) -> dict:
"""
Start a packet capture on the node.
@ -356,13 +331,9 @@ async def start_docker_node_capture(
@router.post(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def stop_docker_node_capture(
adapter_number: int,
port_number: int,
node: DockerVM = Depends(dep_node)
) -> None:
async def stop_docker_node_capture(adapter_number: int, port_number: int, node: DockerVM = Depends(dep_node)) -> None:
"""
Stop a packet capture on the node.
The port number on the Docker node is always 0.
@ -373,12 +344,10 @@ async def stop_docker_node_capture(
@router.get(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream",
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def stream_pcap_file(
adapter_number: int,
port_number: int,
node: DockerVM = Depends(dep_node)
adapter_number: int, port_number: int, node: DockerVM = Depends(dep_node)
) -> StreamingResponse:
"""
Stream the pcap capture file.
@ -392,8 +361,7 @@ async def stream_pcap_file(
@router.websocket("/{node_id}/console/ws")
async def console_ws(
websocket: Union[None, WebSocket] = Depends(ws_compute_authentication),
node: DockerVM = Depends(dep_node)
websocket: Union[None, WebSocket] = Depends(ws_compute_authentication), node: DockerVM = Depends(dep_node)
) -> None:
"""
Console WebSocket.
@ -403,12 +371,9 @@ async def console_ws(
await node.start_websocket_console(websocket)
@router.websocket(
"/{node_id}/console/vnc"
)
@router.websocket("/{node_id}/console/vnc")
async def vnc_console_ws(
websocket: Union[None, WebSocket] = Depends(ws_compute_authentication),
node: DockerVM = Depends(dep_node)
websocket: Union[None, WebSocket] = Depends(ws_compute_authentication), node: DockerVM = Depends(dep_node)
) -> None:
"""
VNC Console WebSocket.
@ -419,23 +384,16 @@ async def vnc_console_ws(
@router.post(
"/{node_id}/console/reset",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
"/{node_id}/console/reset", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
)
async def reset_console(node: DockerVM = Depends(dep_node)) -> None:
await node.reset_console()
@router.put(
"/{node_id}/markers/{marker_name}",
dependencies=[Depends(compute_authentication)]
)
@router.put("/{node_id}/markers/{marker_name}", dependencies=[Depends(compute_authentication)])
async def toggle_docker_marker(
marker_name: str,
toggle_data: schemas.MarkerToggle,
node: DockerVM = Depends(dep_node)
marker_name: str, toggle_data: schemas.MarkerToggle, node: DockerVM = Depends(dep_node)
) -> dict:
"""
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(
"/{node_id}/markers/pause",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
"/{node_id}/markers/pause", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
)
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(
"/{node_id}/markers/resume",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
"/{node_id}/markers/resume", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
)
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(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/{marker_name}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def delete_docker_marker_capture(
marker_name: str,
adapter_number: int,
port_number: int,
link_id: str = "",
node: DockerVM = Depends(dep_node)
marker_name: str, adapter_number: int, port_number: int, link_id: str = "", node: DockerVM = Depends(dep_node)
) -> None:
"""
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)
@router.put(
"/{node_id}/markers/{marker_name}/rebuild",
dependencies=[Depends(compute_authentication)]
)
@router.put("/{node_id}/markers/{marker_name}/rebuild", dependencies=[Depends(compute_authentication)])
async def rebuild_docker_marker(
marker_name: str,
rebuild_data: schemas.MarkerRebuild,
node: DockerVM = Depends(dep_node)
marker_name: str, rebuild_data: schemas.MarkerRebuild, node: DockerVM = Depends(dep_node)
) -> dict:
"""
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(
marker_name, rebuild_data.link_id, rebuild_data.bpf,
rebuild_data.tag, rebuild_data.direction, rebuild_data.enabled,
marker_name,
rebuild_data.link_id,
rebuild_data.bpf,
rebuild_data.tag,
rebuild_data.direction,
rebuild_data.enabled,
)
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,
status_code=status.HTTP_201_CREATED,
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:
"""
@ -86,11 +86,7 @@ async def create_router(project_id: UUID, node_data: schemas.DynamipsCreate) ->
return vm.asdict()
@router.get(
"/{node_id}",
response_model=schemas.Dynamips,
dependencies=[Depends(compute_authentication)]
)
@router.get("/{node_id}", response_model=schemas.Dynamips, dependencies=[Depends(compute_authentication)])
def get_router(node: Router = Depends(dep_node)) -> schemas.Dynamips:
"""
Return Dynamips router.
@ -99,11 +95,7 @@ def get_router(node: Router = Depends(dep_node)) -> schemas.Dynamips:
return node.asdict()
@router.put(
"/{node_id}",
response_model=schemas.Dynamips,
dependencies=[Depends(compute_authentication)]
)
@router.put("/{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:
"""
Update a Dynamips router.
@ -114,11 +106,7 @@ async def update_router(node_data: schemas.DynamipsUpdate, node: Router = Depend
return node.asdict()
@router.delete(
"/{node_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
)
@router.delete("/{node_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
async def delete_router(node: Router = Depends(dep_node)) -> None:
"""
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)
@router.post(
"/{node_id}/start",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
)
@router.post("/{node_id}/start", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
async def start_router(node: Router = Depends(dep_node)) -> None:
"""
Start a Dynamips router.
@ -144,11 +128,7 @@ async def start_router(node: Router = Depends(dep_node)) -> None:
await node.start()
@router.post(
"/{node_id}/stop",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
)
@router.post("/{node_id}/stop", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
async def stop_router(node: Router = Depends(dep_node)) -> None:
"""
Stop a Dynamips router.
@ -158,9 +138,7 @@ async def stop_router(node: Router = Depends(dep_node)) -> None:
@router.post(
"/{node_id}/suspend",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
"/{node_id}/suspend", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
)
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(
"/{node_id}/resume",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
"/{node_id}/resume", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
)
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(
"/{node_id}/reload",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
"/{node_id}/reload", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
)
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",
status_code=status.HTTP_201_CREATED,
response_model=schemas.UDPNIO,
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def create_nio(
adapter_number: int,
port_number: int,
nio_data: schemas.UDPNIO,
node: Router = Depends(dep_node)
adapter_number: int, port_number: int, nio_data: schemas.UDPNIO, node: Router = Depends(dep_node)
) -> schemas.UDPNIO:
"""
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",
status_code=status.HTTP_201_CREATED,
response_model=schemas.UDPNIO,
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def update_nio(
adapter_number: int,
port_number: int,
nio_data: schemas.UDPNIO,
node: Router = Depends(dep_node)
adapter_number: int, port_number: int, nio_data: schemas.UDPNIO, node: Router = Depends(dep_node)
) -> schemas.UDPNIO:
"""
Update a NIO (Network Input/Output) on the node.
@ -242,7 +210,7 @@ async def update_nio(
@router.delete(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
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:
"""
@ -255,13 +223,10 @@ async def delete_nio(adapter_number: int, port_number: int, node: Router = Depen
@router.post(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start",
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def start_capture(
adapter_number: int,
port_number: int,
node_capture_data: schemas.NodeCapture,
node: Router = Depends(dep_node)
adapter_number: int, port_number: int, node_capture_data: schemas.NodeCapture, node: Router = Depends(dep_node)
) -> dict:
"""
Start a packet capture on the node.
@ -275,7 +240,7 @@ async def start_capture(
@router.post(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop",
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:
"""
@ -287,12 +252,10 @@ async def stop_capture(adapter_number: int, port_number: int, node: Router = Dep
@router.get(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream",
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def stream_pcap_file(
adapter_number: int,
port_number: int,
node: Router = Depends(dep_node)
adapter_number: int, port_number: int, node: Router = Depends(dep_node)
) -> StreamingResponse:
"""
Stream the pcap capture file.
@ -303,10 +266,7 @@ async def stream_pcap_file(
return StreamingResponse(stream, media_type="application/vnd.tcpdump.pcap")
@router.get(
"/{node_id}/idlepc_proposals",
dependencies=[Depends(compute_authentication)]
)
@router.get("/{node_id}/idlepc_proposals", dependencies=[Depends(compute_authentication)])
async def get_idlepcs(node: Router = Depends(dep_node)) -> List[str]:
"""
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()
@router.get(
"/{node_id}/auto_idlepc",
dependencies=[Depends(compute_authentication)]
)
@router.get("/{node_id}/auto_idlepc", dependencies=[Depends(compute_authentication)])
async def get_auto_idlepc(node: Router = Depends(dep_node)) -> dict:
"""
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",
response_model=schemas.Dynamips,
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.
"""
@ -346,9 +305,7 @@ async def duplicate_router(destination_node_id: UUID = Body(..., embed=True), no
@router.websocket("/{node_id}/console/ws")
async def console_ws(
websocket: Union[None, WebSocket] = Depends(ws_compute_authentication),
node: Router = Depends(dep_node)
websocket: Union[None, WebSocket] = Depends(ws_compute_authentication), node: Router = Depends(dep_node)
) -> None:
"""
Console WebSocket.
@ -359,23 +316,16 @@ async def console_ws(
@router.post(
"/{node_id}/console/reset",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
"/{node_id}/console/reset", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
)
async def reset_console(node: Router = Depends(dep_node)) -> None:
await node.reset_console()
@router.put(
"/{node_id}/markers/{marker_name}",
dependencies=[Depends(compute_authentication)]
)
@router.put("/{node_id}/markers/{marker_name}", dependencies=[Depends(compute_authentication)])
async def toggle_dynamips_marker(
marker_name: str,
toggle_data: schemas.MarkerToggle,
node: Router = Depends(dep_node)
marker_name: str, toggle_data: schemas.MarkerToggle, node: Router = Depends(dep_node)
) -> dict:
"""
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(
"/{node_id}/markers/pause",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
"/{node_id}/markers/pause", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
)
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(
"/{node_id}/markers/resume",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
"/{node_id}/markers/resume", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
)
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(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/{marker_name}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def delete_dynamips_marker_capture(
marker_name: str,
adapter_number: int,
port_number: int,
link_id: str = "",
node: Router = Depends(dep_node)
marker_name: str, adapter_number: int, port_number: int, link_id: str = "", node: Router = Depends(dep_node)
) -> None:
"""
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)
@router.put(
"/{node_id}/markers/{marker_name}/rebuild",
dependencies=[Depends(compute_authentication)]
)
@router.put("/{node_id}/markers/{marker_name}/rebuild", dependencies=[Depends(compute_authentication)])
async def rebuild_dynamips_marker(
marker_name: str,
rebuild_data: schemas.MarkerRebuild,
node: Router = Depends(dep_node)
marker_name: str, rebuild_data: schemas.MarkerRebuild, node: Router = Depends(dep_node)
) -> dict:
"""
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(
marker_name, rebuild_data.link_id, rebuild_data.bpf,
rebuild_data.tag, rebuild_data.direction, rebuild_data.enabled,
marker_name,
rebuild_data.link_id,
rebuild_data.bpf,
rebuild_data.tag,
rebuild_data.direction,
rebuild_data.enabled,
)
return {"marker_name": marker_name}

View File

@ -91,8 +91,7 @@ async def duplicate_ethernet_hub(
@router.put("/{node_id}", response_model=schemas.EthernetHub)
async def update_ethernet_hub(
node_data: schemas.EthernetHubUpdate,
node: EthernetHub = Depends(dep_node)
node_data: schemas.EthernetHubUpdate, node: EthernetHub = Depends(dep_node)
) -> schemas.EthernetHub:
"""
Update an Ethernet hub.
@ -124,8 +123,7 @@ def start_ethernet_hub(node: EthernetHub = Depends(dep_node)) -> None:
"""
raise HTTPException(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
detail="Start is not supported for Ethernet hubs"
status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Start is not supported for Ethernet hubs"
)
@ -136,8 +134,7 @@ def stop_ethernet_hub(node: EthernetHub = Depends(dep_node)) -> None:
"""
raise HTTPException(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
detail="Stop is not supported for Ethernet hubs"
status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Stop is not supported for Ethernet hubs"
)
@ -148,8 +145,7 @@ def suspend_ethernet_hub(node: EthernetHub = Depends(dep_node)) -> None:
"""
raise HTTPException(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
detail="Suspend is not supported for Ethernet hubs"
status_code=status.HTTP_405_METHOD_NOT_ALLOWED, 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,
)
async def create_nio(
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
nio_data: schemas.UDPNIO,
node: EthernetHub = Depends(dep_node)
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
nio_data: schemas.UDPNIO,
node: EthernetHub = Depends(dep_node),
) -> schemas.UDPNIO:
"""
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)
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:
"""
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")
async def start_capture(
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
node_capture_data: schemas.NodeCapture,
node: EthernetHub = Depends(dep_node)
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
node_capture_data: schemas.NodeCapture,
node: EthernetHub = Depends(dep_node),
) -> dict:
"""
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
)
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:
"""
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")
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:
"""
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)
async def duplicate_ethernet_switch(
destination_node_id: UUID = Body(..., embed=True),
node: EthernetSwitch = Depends(dep_node)
destination_node_id: UUID = Body(..., embed=True), node: EthernetSwitch = Depends(dep_node)
) -> schemas.EthernetSwitch:
"""
Duplicate an Ethernet switch.
@ -95,8 +94,7 @@ async def duplicate_ethernet_switch(
@router.put("/{node_id}", response_model=schemas.EthernetSwitch)
async def update_ethernet_switch(
node_data: schemas.EthernetSwitchUpdate,
node: EthernetSwitch = Depends(dep_node)
node_data: schemas.EthernetSwitchUpdate, node: EthernetSwitch = Depends(dep_node)
) -> schemas.EthernetSwitch:
"""
Update an Ethernet switch.
@ -132,8 +130,7 @@ def start_ethernet_switch(node: EthernetSwitch = Depends(dep_node)) -> None:
"""
raise HTTPException(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
detail="Start is not supported for Ethernet switches"
status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Start is not supported for Ethernet switches"
)
@ -144,8 +141,7 @@ def stop_ethernet_switch(node: EthernetSwitch = Depends(dep_node)) -> None:
"""
raise HTTPException(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
detail="Stop is not supported for Ethernet switches"
status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Stop is not supported for Ethernet switches"
)
@ -156,8 +152,7 @@ def suspend_ethernet_switch(node: EthernetSwitch = Depends(dep_node)) -> None:
"""
raise HTTPException(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
detail="Suspend is not supported for Ethernet switches"
status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Suspend is not supported for Ethernet switches"
)
@ -169,8 +164,7 @@ def reload_ethernet_switch(node: EthernetSwitch = Depends(dep_node)) -> None:
"""
raise HTTPException(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
detail="Reload is not supported for Ethernet switches"
status_code=status.HTTP_405_METHOD_NOT_ALLOWED, 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,
)
async def create_ethernet_switch_nio(
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
nio_data: schemas.UDPNIO,
node: EthernetSwitch = Depends(dep_node)
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
nio_data: schemas.UDPNIO,
node: EthernetSwitch = Depends(dep_node),
) -> schemas.UDPNIO:
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,
)
async def update_ethernet_switch_nio(
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
nio_data: schemas.UDPNIO,
node: EthernetSwitch = Depends(dep_node)
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
nio_data: schemas.UDPNIO,
node: EthernetSwitch = Depends(dep_node),
) -> schemas.UDPNIO:
"""
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)
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:
"""
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")
async def start_ethernet_switch_capture(
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
node_capture_data: schemas.NodeCapture,
node: EthernetSwitch = Depends(dep_node),
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
node_capture_data: schemas.NodeCapture,
node: EthernetSwitch = Depends(dep_node),
) -> dict:
"""
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
)
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:
"""
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")
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:
"""
Stream the pcap capture file.
@ -288,9 +273,7 @@ async def stream_pcap_file(
@router.put("/{node_id}/markers/{marker_name}")
async def toggle_ethernet_switch_marker(
marker_name: str,
toggle_data: schemas.MarkerToggle,
node: EthernetSwitch = Depends(dep_node)
marker_name: str, toggle_data: schemas.MarkerToggle, node: EthernetSwitch = Depends(dep_node)
) -> dict:
"""
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,
)
async def delete_ethernet_switch_marker_capture(
*,
marker_name: str,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
link_id: str = "",
node: EthernetSwitch = Depends(dep_node)
*,
marker_name: str,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
link_id: str = "",
node: EthernetSwitch = Depends(dep_node),
) -> None:
"""
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")
async def rebuild_ethernet_switch_marker(
marker_name: str,
rebuild_data: schemas.MarkerRebuild,
node: EthernetSwitch = Depends(dep_node)
marker_name: str, rebuild_data: schemas.MarkerRebuild, node: EthernetSwitch = Depends(dep_node)
) -> dict:
"""
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(
marker_name, rebuild_data.link_id, rebuild_data.bpf,
rebuild_data.tag, rebuild_data.direction, rebuild_data.enabled,
marker_name,
rebuild_data.link_id,
rebuild_data.bpf,
rebuild_data.tag,
rebuild_data.direction,
rebuild_data.enabled,
)
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"}},
)
async def create_frame_relay_switch(
project_id: UUID,
node_data: schemas.FrameRelaySwitchCreate
project_id: UUID, node_data: schemas.FrameRelaySwitchCreate
) -> schemas.FrameRelaySwitch:
"""
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)
async def duplicate_frame_relay_switch(
destination_node_id: UUID = Body(..., embed=True),
node: FrameRelaySwitch = Depends(dep_node)
destination_node_id: UUID = Body(..., embed=True), node: FrameRelaySwitch = Depends(dep_node)
) -> schemas.FrameRelaySwitch:
"""
Duplicate a Frame Relay switch node.
@ -95,8 +93,7 @@ async def duplicate_frame_relay_switch(
@router.put("/{node_id}", response_model=schemas.FrameRelaySwitch)
async def update_frame_relay_switch(
node_data: schemas.FrameRelaySwitchUpdate,
node: FrameRelaySwitch = Depends(dep_node)
node_data: schemas.FrameRelaySwitchUpdate, node: FrameRelaySwitch = Depends(dep_node)
) -> schemas.FrameRelaySwitch:
"""
Update an Frame Relay switch node.
@ -127,8 +124,7 @@ def start_frame_relay_switch(node: FrameRelaySwitch = Depends(dep_node)) -> None
"""
raise HTTPException(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
detail="Start is not supported for Frame Relay switches"
status_code=status.HTTP_405_METHOD_NOT_ALLOWED, 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(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
detail="Stop is not supported for Frame Relay switches"
status_code=status.HTTP_405_METHOD_NOT_ALLOWED, 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(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
detail="Suspend is not supported for Frame Relay switches"
status_code=status.HTTP_405_METHOD_NOT_ALLOWED, 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,
)
async def create_nio(
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
nio_data: schemas.UDPNIO,
node: FrameRelaySwitch = Depends(dep_node)
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
nio_data: schemas.UDPNIO,
node: FrameRelaySwitch = Depends(dep_node),
) -> schemas.UDPNIO:
"""
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)
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:
"""
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")
async def start_capture(
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
node_capture_data: schemas.NodeCapture,
node: FrameRelaySwitch = Depends(dep_node),
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
node_capture_data: schemas.NodeCapture,
node: FrameRelaySwitch = Depends(dep_node),
) -> dict:
"""
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
)
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:
"""
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")
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:
"""
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,
status_code=status.HTTP_201_CREATED,
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:
"""
@ -85,11 +85,7 @@ async def create_iou_node(project_id: UUID, node_data: schemas.IOUCreate) -> sch
return vm.asdict()
@router.get(
"/{node_id}",
response_model=schemas.IOU,
dependencies=[Depends(compute_authentication)]
)
@router.get("/{node_id}", response_model=schemas.IOU, dependencies=[Depends(compute_authentication)])
def get_iou_node(node: IOUVM = Depends(dep_node)) -> schemas.IOU:
"""
Return an IOU node.
@ -98,11 +94,7 @@ def get_iou_node(node: IOUVM = Depends(dep_node)) -> schemas.IOU:
return node.asdict()
@router.put(
"/{node_id}",
response_model=schemas.IOU,
dependencies=[Depends(compute_authentication)]
)
@router.put("/{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:
"""
Update an IOU node.
@ -123,11 +115,7 @@ async def update_iou_node(node_data: schemas.IOUUpdate, node: IOUVM = Depends(de
return node.asdict()
@router.delete(
"/{node_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
)
@router.delete("/{node_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
async def delete_iou_node(node: IOUVM = Depends(dep_node)) -> None:
"""
Delete an IOU node.
@ -140,11 +128,10 @@ async def delete_iou_node(node: IOUVM = Depends(dep_node)) -> None:
"/{node_id}/duplicate",
response_model=schemas.IOU,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def duplicate_iou_node(
destination_node_id: UUID = Body(..., embed=True),
node: IOUVM = Depends(dep_node)
destination_node_id: UUID = Body(..., embed=True), node: IOUVM = Depends(dep_node)
) -> schemas.IOU:
"""
Duplicate an IOU node.
@ -154,11 +141,7 @@ async def duplicate_iou_node(
return new_node.asdict()
@router.post(
"/{node_id}/start",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
)
@router.post("/{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:
"""
Start an IOU node.
@ -172,11 +155,7 @@ async def start_iou_node(start_data: schemas.IOUStart, node: IOUVM = Depends(dep
await node.start()
@router.post(
"/{node_id}/stop",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
)
@router.post("/{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:
"""
Stop an IOU node.
@ -185,25 +164,17 @@ async def stop_iou_node(node: IOUVM = Depends(dep_node)) -> None:
await node.stop()
@router.post(
"/{node_id}/suspend",
dependencies=[Depends(compute_authentication)]
)
@router.post("/{node_id}/suspend", dependencies=[Depends(compute_authentication)])
def suspend_iou_node(node: IOUVM = Depends(dep_node)) -> None:
"""
Suspend an IOU node.
"""
raise HTTPException(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
detail="Suspend is not supported for IOU nodes"
)
raise HTTPException(status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Suspend is not supported for IOU nodes")
@router.post(
"/{node_id}/reload",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
"/{node_id}/reload", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
)
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",
status_code=status.HTTP_201_CREATED,
response_model=Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO],
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def create_iou_node_nio(
adapter_number: int,
@ -238,7 +209,7 @@ async def create_iou_node_nio(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
status_code=status.HTTP_201_CREATED,
response_model=Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO],
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def update_iou_node_nio(
adapter_number: int,
@ -263,7 +234,7 @@ async def update_iou_node_nio(
@router.delete(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
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:
"""
@ -275,13 +246,10 @@ async def delete_iou_node_nio(adapter_number: int, port_number: int, node: IOUVM
@router.post(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start",
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def start_iou_node_capture(
adapter_number: int,
port_number: int,
node_capture_data: schemas.NodeCapture,
node: IOUVM = Depends(dep_node)
adapter_number: int, port_number: int, node_capture_data: schemas.NodeCapture, node: IOUVM = Depends(dep_node)
) -> dict:
"""
Start a packet capture on the node.
@ -295,7 +263,7 @@ async def start_iou_node_capture(
@router.post(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop",
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:
"""
@ -307,13 +275,9 @@ async def stop_iou_node_capture(adapter_number: int, port_number: int, node: IOU
@router.get(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream",
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def stream_pcap_file(
adapter_number: int,
port_number: int,
node: IOUVM = Depends(dep_node)
) -> StreamingResponse:
async def stream_pcap_file(adapter_number: int, port_number: int, node: IOUVM = Depends(dep_node)) -> StreamingResponse:
"""
Stream the pcap capture file.
"""
@ -327,8 +291,7 @@ async def stream_pcap_file(
"/{node_id}/console/ws",
)
async def console_ws(
websocket: Union[None, WebSocket] = Depends(ws_compute_authentication),
node: IOUVM = Depends(dep_node)
websocket: Union[None, WebSocket] = Depends(ws_compute_authentication), node: IOUVM = Depends(dep_node)
) -> None:
"""
Console WebSocket.
@ -339,23 +302,16 @@ async def console_ws(
@router.post(
"/{node_id}/console/reset",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
"/{node_id}/console/reset", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
)
async def reset_console(node: IOUVM = Depends(dep_node)) -> None:
await node.reset_console()
@router.put(
"/{node_id}/markers/{marker_name}",
dependencies=[Depends(compute_authentication)]
)
@router.put("/{node_id}/markers/{marker_name}", dependencies=[Depends(compute_authentication)])
async def toggle_iou_marker(
marker_name: str,
toggle_data: schemas.MarkerToggle,
node: IOUVM = Depends(dep_node)
marker_name: str, toggle_data: schemas.MarkerToggle, node: IOUVM = Depends(dep_node)
) -> dict:
"""
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(
"/{node_id}/markers/pause",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
"/{node_id}/markers/pause", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
)
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(
"/{node_id}/markers/resume",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
"/{node_id}/markers/resume", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
)
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(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/{marker_name}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def delete_iou_marker_capture(
marker_name: str,
adapter_number: int,
port_number: int,
link_id: str = "",
node: IOUVM = Depends(dep_node)
marker_name: str, adapter_number: int, port_number: int, link_id: str = "", node: IOUVM = Depends(dep_node)
) -> None:
"""
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)
@router.put(
"/{node_id}/markers/{marker_name}/rebuild",
dependencies=[Depends(compute_authentication)]
)
@router.put("/{node_id}/markers/{marker_name}/rebuild", dependencies=[Depends(compute_authentication)])
async def rebuild_iou_marker(
marker_name: str,
rebuild_data: schemas.MarkerRebuild,
node: IOUVM = Depends(dep_node)
marker_name: str, rebuild_data: schemas.MarkerRebuild, node: IOUVM = Depends(dep_node)
) -> dict:
"""
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(
marker_name, rebuild_data.link_id, rebuild_data.bpf,
rebuild_data.tag, rebuild_data.direction, rebuild_data.enabled,
marker_name,
rebuild_data.link_id,
rebuild_data.bpf,
rebuild_data.tag,
rebuild_data.direction,
rebuild_data.enabled,
)
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.
"""
raise HTTPException(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
detail="Stop is not supported for NAT nodes"
)
raise HTTPException(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)
@ -129,10 +126,7 @@ async def suspend_nat_node(node: Nat = Depends(dep_node)) -> None:
Suspend a NAT node.
"""
raise HTTPException(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
detail="Suspend is not supported for NAT nodes"
)
raise HTTPException(status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Suspend is not supported for NAT nodes")
@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],
)
async def create_nat_node_nio(
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
nio_data: Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO],
node: Nat = Depends(dep_node),
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
nio_data: Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO],
node: Nat = Depends(dep_node),
) -> Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO]:
"""
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],
)
async def update_nat_node_nio(
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
nio_data: Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO],
node: Nat = Depends(dep_node),
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
nio_data: Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO],
node: Nat = Depends(dep_node),
) -> Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO]:
"""
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)
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:
"""
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")
async def start_nat_node_capture(
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
node_capture_data: schemas.NodeCapture,
node: Nat = Depends(dep_node)
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
node_capture_data: schemas.NodeCapture,
node: Nat = Depends(dep_node),
) -> dict:
"""
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
)
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:
"""
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")
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:
"""
Stream the pcap capture file.

View File

@ -18,7 +18,6 @@
API routes for compute notifications.
"""
from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect
from typing import Union
from websockets.exceptions import ConnectionClosed, WebSocketException
@ -53,7 +52,6 @@ async def project_ws_notifications(websocket: Union[None, WebSocket] = Depends(w
if __name__ == "__main__":
import uvicorn
from fastapi import FastAPI
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)
async def update_compute_project(
project_data: schemas.ProjectUpdate,
project: Project = Depends(dep_project)
project_data: schemas.ProjectUpdate, project: Project = Depends(dep_project)
) -> schemas.Project:
"""
Update project on the compute.
@ -195,6 +194,7 @@ def _get_existing_nio(node, adapter_number, port_number):
# via get_nio(port).
if hasattr(node, "get_nio"):
import inspect as _inspect
if len(_inspect.signature(node.get_nio).parameters) >= 2:
return node.get_nio(adapter_number, 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,
)
async def create_batch_nios(
project_id: UUID,
batch: schemas.BatchNIOCreate,
project: Project = Depends(dep_project),
project_id: UUID,
batch: schemas.BatchNIOCreate,
project: Project = Depends(dep_project),
) -> dict:
"""
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)
await _add_nio_binding(node, entry.adapter_number, entry.port_number, nio)
await asyncio.gather(
*[_create_one_node(nid, ents) for nid, ents in per_node.items()]
)
await asyncio.gather(*[_create_one_node(nid, ents) for nid, ents in per_node.items()])
return {"added": len(batch.nios)}
@ -292,9 +290,9 @@ async def create_batch_nios(
status_code=status.HTTP_200_OK,
)
async def update_batch_nios(
project_id: UUID,
batch: schemas.BatchNIOCreate,
project: Project = Depends(dep_project),
project_id: UUID,
batch: schemas.BatchNIOCreate,
project: Project = Depends(dep_project),
) -> dict:
"""
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 {}
await _update_nio_binding(node, e.adapter_number, e.port_number, nio)
await asyncio.gather(
*[_update_one_node(nid, ents) for nid, ents in per_node.items()]
)
await asyncio.gather(*[_update_one_node(nid, ents) for nid, ents in per_node.items()])
return {"updated": len(batch.nios)}
@ -343,7 +339,7 @@ async def get_compute_node_files(
node_id: str,
project: Project = Depends(dep_project),
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]:
"""
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)
async def write_compute_project_file(
file_path: str,
request: Request,
project: Project = Depends(dep_project)
) -> None:
async def write_compute_project_file(file_path: str, request: Request, project: Project = Depends(dep_project)) -> None:
file_path = urllib.parse.unquote(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)
async def delete_compute_project_file(
file_path: str,
project: Project = Depends(dep_project)
) -> None:
async def delete_compute_project_file(file_path: str, project: Project = Depends(dep_project)) -> None:
file_path = urllib.parse.unquote(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
import logging
log = logging.getLogger(__name__)
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,
status_code=status.HTTP_201_CREATED,
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:
"""
@ -117,11 +118,7 @@ async def create_qemu_node(project_id: UUID, node_data: schemas.QemuCreate) -> s
return vm.asdict()
@router.get(
"/{node_id}",
response_model=schemas.Qemu,
dependencies=[Depends(compute_authentication)]
)
@router.get("/{node_id}", response_model=schemas.Qemu, dependencies=[Depends(compute_authentication)])
def get_qemu_node(node: QemuVM = Depends(dep_node)) -> schemas.Qemu:
"""
Return a Qemu node.
@ -130,11 +127,7 @@ def get_qemu_node(node: QemuVM = Depends(dep_node)) -> schemas.Qemu:
return node.asdict()
@router.put(
"/{node_id}",
response_model=schemas.Qemu,
dependencies=[Depends(compute_authentication)]
)
@router.put("/{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:
"""
Update a Qemu node.
@ -150,11 +143,7 @@ async def update_qemu_node(node_data: schemas.QemuUpdate, node: QemuVM = Depends
return node.asdict()
@router.delete(
"/{node_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
)
@router.delete("/{node_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
async def delete_qemu_node(node: QemuVM = Depends(dep_node)) -> None:
"""
Delete a Qemu node.
@ -167,11 +156,10 @@ async def delete_qemu_node(node: QemuVM = Depends(dep_node)) -> None:
"/{node_id}/duplicate",
response_model=schemas.Qemu,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def duplicate_qemu_node(
destination_node_id: UUID = Body(..., embed=True),
node: QemuVM = Depends(dep_node)
destination_node_id: UUID = Body(..., embed=True), node: QemuVM = Depends(dep_node)
) -> schemas.Qemu:
"""
Duplicate a Qemu node.
@ -184,12 +172,10 @@ async def duplicate_qemu_node(
@router.post(
"/{node_id}/disk_image/{disk_name}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def create_qemu_disk_image(
disk_name: str,
disk_data: schemas.QemuDiskImageCreate,
node: QemuVM = Depends(dep_node)
disk_name: str, disk_data: schemas.QemuDiskImageCreate, node: QemuVM = Depends(dep_node)
) -> None:
"""
Create a Qemu disk image.
@ -202,12 +188,10 @@ async def create_qemu_disk_image(
@router.put(
"/{node_id}/disk_image/{disk_name}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def update_qemu_disk_image(
disk_name: str,
disk_data: schemas.QemuDiskImageUpdate,
node: QemuVM = Depends(dep_node)
disk_name: str, disk_data: schemas.QemuDiskImageUpdate, node: QemuVM = Depends(dep_node)
) -> None:
"""
Update a Qemu disk image.
@ -220,12 +204,9 @@ async def update_qemu_disk_image(
@router.delete(
"/{node_id}/disk_image/{disk_name}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def delete_qemu_disk_image(
disk_name: str,
node: QemuVM = Depends(dep_node)
) -> None:
async def delete_qemu_disk_image(disk_name: str, node: QemuVM = Depends(dep_node)) -> None:
"""
Delete a Qemu disk image.
"""
@ -233,11 +214,7 @@ async def delete_qemu_disk_image(
node.delete_disk_image(disk_name)
@router.post(
"/{node_id}/start",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
)
@router.post("/{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:
"""
Start a Qemu node.
@ -246,11 +223,7 @@ async def start_qemu_node(node: QemuVM = Depends(dep_node)) -> None:
await node.start()
@router.post(
"/{node_id}/stop",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
)
@router.post("/{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:
"""
Stop a Qemu node.
@ -260,9 +233,7 @@ async def stop_qemu_node(node: QemuVM = Depends(dep_node)) -> None:
@router.post(
"/{node_id}/reload",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
"/{node_id}/reload", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
)
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(
"/{node_id}/suspend",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
"/{node_id}/suspend", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
)
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(
"/{node_id}/resume",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
"/{node_id}/resume", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
)
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",
status_code=status.HTTP_201_CREATED,
response_model=schemas.UDPNIO,
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def create_qemu_node_nio(
*,
adapter_number: int,
port_number: int = Path(..., ge=0, le=0),
nio_data: schemas.UDPNIO,
node: QemuVM = Depends(dep_node)
*,
adapter_number: int,
port_number: int = Path(..., ge=0, le=0),
nio_data: schemas.UDPNIO,
node: QemuVM = Depends(dep_node),
) -> schemas.UDPNIO:
"""
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",
status_code=status.HTTP_201_CREATED,
response_model=schemas.UDPNIO,
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def update_qemu_node_nio(
*,
adapter_number: int,
port_number: int = Path(..., ge=0, le=0),
nio_data: schemas.UDPNIO,
node: QemuVM = Depends(dep_node)
*,
adapter_number: int,
port_number: int = Path(..., ge=0, le=0),
nio_data: schemas.UDPNIO,
node: QemuVM = Depends(dep_node),
) -> schemas.UDPNIO:
"""
Update a NIO (Network Input/Output) on the node.
@ -352,12 +319,10 @@ async def update_qemu_node_nio(
@router.delete(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def delete_qemu_node_nio(
adapter_number: int,
port_number: int = Path(..., ge=0, le=0),
node: QemuVM = Depends(dep_node)
adapter_number: int, port_number: int = Path(..., ge=0, le=0), node: QemuVM = Depends(dep_node)
) -> None:
"""
Delete a NIO (Network Input/Output) from the node.
@ -369,14 +334,14 @@ async def delete_qemu_node_nio(
@router.post(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start",
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def start_qemu_node_capture(
*,
adapter_number: int,
port_number: int = Path(..., ge=0, le=0),
node_capture_data: schemas.NodeCapture,
node: QemuVM = Depends(dep_node)
*,
adapter_number: int,
port_number: int = Path(..., ge=0, le=0),
node_capture_data: schemas.NodeCapture,
node: QemuVM = Depends(dep_node),
) -> dict:
"""
Start a packet capture on the node.
@ -391,12 +356,10 @@ async def start_qemu_node_capture(
@router.post(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def stop_qemu_node_capture(
adapter_number: int,
port_number: int = Path(..., ge=0, le=0),
node: QemuVM = Depends(dep_node)
adapter_number: int, port_number: int = Path(..., ge=0, le=0), node: QemuVM = Depends(dep_node)
) -> None:
"""
Stop a packet capture on the node.
@ -408,12 +371,11 @@ async def stop_qemu_node_capture(
@router.get(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream",
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def stream_pcap_file(
adapter_number: int,
port_number: int = Path(..., ge=0, le=0),
node: QemuVM = Depends(dep_node)) -> StreamingResponse:
adapter_number: int, port_number: int = Path(..., ge=0, le=0), node: QemuVM = Depends(dep_node)
) -> StreamingResponse:
"""
Stream the pcap capture file.
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")
@router.websocket(
"/{node_id}/console/ws"
)
@router.websocket("/{node_id}/console/ws")
async def console_ws(
websocket: Union[None, WebSocket] = Depends(ws_compute_authentication),
node: QemuVM = Depends(dep_node)
websocket: Union[None, WebSocket] = Depends(ws_compute_authentication), node: QemuVM = Depends(dep_node)
) -> None:
"""
Console WebSocket.
@ -439,12 +398,9 @@ async def console_ws(
await node.start_websocket_console(websocket)
@router.websocket(
"/{node_id}/console/vnc"
)
@router.websocket("/{node_id}/console/vnc")
async def vnc_console_ws(
websocket: Union[None, WebSocket] = Depends(ws_compute_authentication),
node: QemuVM = Depends(dep_node)
websocket: Union[None, WebSocket] = Depends(ws_compute_authentication), node: QemuVM = Depends(dep_node)
) -> None:
"""
VNC Console WebSocket.
@ -455,23 +411,16 @@ async def vnc_console_ws(
@router.post(
"/{node_id}/console/reset",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
"/{node_id}/console/reset", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
)
async def reset_console(node: QemuVM = Depends(dep_node)) -> None:
await node.reset_console()
@router.put(
"/{node_id}/markers/{marker_name}",
dependencies=[Depends(compute_authentication)]
)
@router.put("/{node_id}/markers/{marker_name}", dependencies=[Depends(compute_authentication)])
async def toggle_qemu_marker(
marker_name: str,
toggle_data: schemas.MarkerToggle,
node: QemuVM = Depends(dep_node)
marker_name: str, toggle_data: schemas.MarkerToggle, node: QemuVM = Depends(dep_node)
) -> dict:
"""
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(
"/{node_id}/markers/pause",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
"/{node_id}/markers/pause", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
)
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(
"/{node_id}/markers/resume",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
"/{node_id}/markers/resume", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
)
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(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/{marker_name}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def delete_qemu_marker_capture(
marker_name: str,
adapter_number: int,
port_number: int = Path(..., ge=0, le=0),
link_id: str = "",
node: QemuVM = Depends(dep_node)
node: QemuVM = Depends(dep_node),
) -> None:
"""
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)
@router.put(
"/{node_id}/markers/{marker_name}/rebuild",
dependencies=[Depends(compute_authentication)]
)
@router.put("/{node_id}/markers/{marker_name}/rebuild", dependencies=[Depends(compute_authentication)])
async def rebuild_qemu_marker(
marker_name: str,
rebuild_data: schemas.MarkerRebuild,
node: QemuVM = Depends(dep_node)
marker_name: str, rebuild_data: schemas.MarkerRebuild, node: QemuVM = Depends(dep_node)
) -> dict:
"""
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(
marker_name, rebuild_data.link_id, rebuild_data.bpf,
rebuild_data.tag, rebuild_data.direction, rebuild_data.enabled,
marker_name,
rebuild_data.link_id,
rebuild_data.bpf,
rebuild_data.tag,
rebuild_data.direction,
rebuild_data.enabled,
)
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,
status_code=status.HTTP_201_CREATED,
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:
"""
@ -86,11 +86,7 @@ async def create_virtualbox_node(project_id: UUID, node_data: schemas.VirtualBox
return vm.asdict()
@router.get(
"/{node_id}",
response_model=schemas.VirtualBox,
dependencies=[Depends(compute_authentication)]
)
@router.get("/{node_id}", response_model=schemas.VirtualBox, dependencies=[Depends(compute_authentication)])
def get_virtualbox_node(node: VirtualBoxVM = Depends(dep_node)) -> schemas.VirtualBox:
"""
Return a VirtualBox node.
@ -99,14 +95,9 @@ def get_virtualbox_node(node: VirtualBoxVM = Depends(dep_node)) -> schemas.Virtu
return node.asdict()
@router.put(
"/{node_id}",
response_model=schemas.VirtualBox,
dependencies=[Depends(compute_authentication)]
)
@router.put("/{node_id}", response_model=schemas.VirtualBox, dependencies=[Depends(compute_authentication)])
async def update_virtualbox_node(
node_data: schemas.VirtualBoxUpdate,
node: VirtualBoxVM = Depends(dep_node)
node_data: schemas.VirtualBoxUpdate, node: VirtualBoxVM = Depends(dep_node)
) -> schemas.VirtualBox:
"""
Update a VirtualBox node.
@ -148,11 +139,7 @@ async def update_virtualbox_node(
return node.asdict()
@router.delete(
"/{node_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
)
@router.delete("/{node_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
async def delete_virtualbox_node(node: VirtualBoxVM = Depends(dep_node)) -> None:
"""
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)
@router.post(
"/{node_id}/start",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
)
@router.post("/{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:
"""
Start a VirtualBox node.
@ -174,11 +157,7 @@ async def start_virtualbox_node(node: VirtualBoxVM = Depends(dep_node)) -> None:
await node.start()
@router.post(
"/{node_id}/stop",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
)
@router.post("/{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:
"""
Stop a VirtualBox node.
@ -188,9 +167,7 @@ async def stop_virtualbox_node(node: VirtualBoxVM = Depends(dep_node)) -> None:
@router.post(
"/{node_id}/suspend",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
"/{node_id}/suspend", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
)
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(
"/{node_id}/resume",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
"/{node_id}/resume", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
)
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(
"/{node_id}/reload",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
"/{node_id}/reload", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
)
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",
status_code=status.HTTP_201_CREATED,
response_model=schemas.UDPNIO,
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def create_virtualbox_node_nio(
*,
adapter_number: int,
port_number: int = Path(..., ge=0, le=0),
nio_data: schemas.UDPNIO,
node: VirtualBoxVM = Depends(dep_node)
*,
adapter_number: int,
port_number: int = Path(..., ge=0, le=0),
nio_data: schemas.UDPNIO,
node: VirtualBoxVM = Depends(dep_node),
) -> schemas.UDPNIO:
"""
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",
status_code=status.HTTP_201_CREATED,
response_model=schemas.UDPNIO,
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def update_virtualbox_node_nio(
*,
adapter_number: int,
port_number: int = Path(..., ge=0, le=0),
nio_data: schemas.UDPNIO,
node: VirtualBoxVM = Depends(dep_node)
*,
adapter_number: int,
port_number: int = Path(..., ge=0, le=0),
nio_data: schemas.UDPNIO,
node: VirtualBoxVM = Depends(dep_node),
) -> schemas.UDPNIO:
"""
Update a NIO (Network Input/Output) on the node.
@ -279,12 +252,10 @@ async def update_virtualbox_node_nio(
@router.delete(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def delete_virtualbox_node_nio(
adapter_number: int,
port_number: int = Path(..., ge=0, le=0),
node: VirtualBoxVM = Depends(dep_node)
adapter_number: int, port_number: int = Path(..., ge=0, le=0), node: VirtualBoxVM = Depends(dep_node)
) -> None:
"""
Delete a NIO (Network Input/Output) from the node.
@ -296,14 +267,14 @@ async def delete_virtualbox_node_nio(
@router.post(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start",
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def start_virtualbox_node_capture(
*,
adapter_number: int,
port_number: int = Path(..., ge=0, le=0),
node_capture_data: schemas.NodeCapture,
node: VirtualBoxVM = Depends(dep_node),
*,
adapter_number: int,
port_number: int = Path(..., ge=0, le=0),
node_capture_data: schemas.NodeCapture,
node: VirtualBoxVM = Depends(dep_node),
) -> dict:
"""
Start a packet capture on the node.
@ -318,12 +289,10 @@ async def start_virtualbox_node_capture(
@router.post(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def stop_virtualbox_node_capture(
adapter_number: int,
port_number: int = Path(..., ge=0, le=0),
node: VirtualBoxVM = Depends(dep_node)
adapter_number: int, port_number: int = Path(..., ge=0, le=0), node: VirtualBoxVM = Depends(dep_node)
) -> None:
"""
Stop a packet capture on the node.
@ -335,12 +304,10 @@ async def stop_virtualbox_node_capture(
@router.get(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream",
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def stream_pcap_file(
adapter_number: int,
port_number: int = Path(..., ge=0, le=0),
node: VirtualBoxVM = Depends(dep_node)
adapter_number: int, port_number: int = Path(..., ge=0, le=0), node: VirtualBoxVM = Depends(dep_node)
) -> StreamingResponse:
"""
Stream the pcap capture file.
@ -352,12 +319,9 @@ async def stream_pcap_file(
return StreamingResponse(stream, media_type="application/vnd.tcpdump.pcap")
@router.websocket(
"/{node_id}/console/ws"
)
@router.websocket("/{node_id}/console/ws")
async def console_ws(
websocket: Union[None, WebSocket] = Depends(ws_compute_authentication),
node: VirtualBoxVM = Depends(dep_node)
websocket: Union[None, WebSocket] = Depends(ws_compute_authentication), node: VirtualBoxVM = Depends(dep_node)
) -> None:
"""
Console WebSocket.
@ -367,11 +331,8 @@ async def console_ws(
@router.post(
"/{node_id}/console/reset",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
"/{node_id}/console/reset", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
)
async def reset_console(node: VirtualBoxVM = Depends(dep_node)) -> None:
await node.reset_console()

View File

@ -52,7 +52,7 @@ def dep_node(project_id: UUID, node_id: UUID) -> VMwareVM:
response_model=schemas.VMware,
status_code=status.HTTP_201_CREATED,
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:
"""
@ -79,11 +79,7 @@ async def create_vmware_node(project_id: UUID, node_data: schemas.VMwareCreate)
return vm.asdict()
@router.get(
"/{node_id}",
response_model=schemas.VMware,
dependencies=[Depends(compute_authentication)]
)
@router.get("/{node_id}", response_model=schemas.VMware, dependencies=[Depends(compute_authentication)])
def get_vmware_node(node: VMwareVM = Depends(dep_node)) -> schemas.VMware:
"""
Return a VMware node.
@ -92,11 +88,7 @@ def get_vmware_node(node: VMwareVM = Depends(dep_node)) -> schemas.VMware:
return node.asdict()
@router.put(
"/{node_id}",
response_model=schemas.VMware,
dependencies=[Depends(compute_authentication)]
)
@router.put("/{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:
"""
Update a VMware node.
@ -113,11 +105,7 @@ async def update_vmware_node(node_data: schemas.VMwareUpdate, node: VMwareVM = D
return node.asdict()
@router.delete(
"/{node_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
)
@router.delete("/{node_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
async def delete_vmware_node(node: VMwareVM = Depends(dep_node)) -> None:
"""
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)
@router.post(
"/{node_id}/start",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
)
@router.post("/{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:
"""
Start a VMware node.
@ -139,11 +123,7 @@ async def start_vmware_node(node: VMwareVM = Depends(dep_node)) -> None:
await node.start()
@router.post(
"/{node_id}/stop",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
)
@router.post("/{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:
"""
Stop a VMware node.
@ -153,9 +133,7 @@ async def stop_vmware_node(node: VMwareVM = Depends(dep_node)) -> None:
@router.post(
"/{node_id}/suspend",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
"/{node_id}/suspend", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
)
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(
"/{node_id}/resume",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
"/{node_id}/resume", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
)
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(
"/{node_id}/reload",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
"/{node_id}/reload", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
)
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",
status_code=status.HTTP_201_CREATED,
response_model=schemas.UDPNIO,
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def create_vmware_node_nio(
*,
adapter_number: int,
port_number: int = Path(..., ge=0, le=0),
nio_data: schemas.UDPNIO,
node: VMwareVM = Depends(dep_node)
*,
adapter_number: int,
port_number: int = Path(..., ge=0, le=0),
nio_data: schemas.UDPNIO,
node: VMwareVM = Depends(dep_node),
) -> schemas.UDPNIO:
"""
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",
status_code=status.HTTP_201_CREATED,
response_model=schemas.UDPNIO,
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def update_vmware_node_nio(
*,
adapter_number: int,
port_number: int = Path(..., ge=0, le=0),
nio_data: schemas.UDPNIO,
node: VMwareVM = Depends(dep_node)
*,
adapter_number: int,
port_number: int = Path(..., ge=0, le=0),
nio_data: schemas.UDPNIO,
node: VMwareVM = Depends(dep_node),
) -> schemas.UDPNIO:
"""
Update a NIO (Network Input/Output) on the node.
@ -243,12 +217,10 @@ async def update_vmware_node_nio(
@router.delete(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def delete_vmware_node_nio(
adapter_number: int,
port_number: int = Path(..., ge=0, le=0),
node: VMwareVM = Depends(dep_node)
adapter_number: int, port_number: int = Path(..., ge=0, le=0), node: VMwareVM = Depends(dep_node)
) -> None:
"""
Delete a NIO (Network Input/Output) from the node.
@ -260,14 +232,14 @@ async def delete_vmware_node_nio(
@router.post(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start",
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def start_vmware_node_capture(
*,
adapter_number: int,
port_number: int = Path(..., ge=0, le=0),
node_capture_data: schemas.NodeCapture,
node: VMwareVM = Depends(dep_node)
*,
adapter_number: int,
port_number: int = Path(..., ge=0, le=0),
node_capture_data: schemas.NodeCapture,
node: VMwareVM = Depends(dep_node),
) -> dict:
"""
Start a packet capture on the node.
@ -282,12 +254,10 @@ async def start_vmware_node_capture(
@router.post(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def stop_vmware_node_capture(
adapter_number: int,
port_number: int = Path(..., ge=0, le=0),
node: VMwareVM = Depends(dep_node)
adapter_number: int, port_number: int = Path(..., ge=0, le=0), node: VMwareVM = Depends(dep_node)
) -> None:
"""
Stop a packet capture on the node.
@ -299,12 +269,10 @@ async def stop_vmware_node_capture(
@router.get(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream",
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def stream_pcap_file(
adapter_number: int,
port_number: int = Path(..., ge=0, le=0),
node: VMwareVM = Depends(dep_node)
adapter_number: int, port_number: int = Path(..., ge=0, le=0), node: VMwareVM = Depends(dep_node)
) -> StreamingResponse:
"""
Stream the pcap capture file.
@ -317,9 +285,7 @@ async def stream_pcap_file(
@router.post(
"/{node_id}/interfaces/vmnet",
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(compute_authentication)]
"/{node_id}/interfaces/vmnet", status_code=status.HTTP_201_CREATED, dependencies=[Depends(compute_authentication)]
)
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")
async def console_ws(
websocket: Union[None, WebSocket] = Depends(ws_compute_authentication),
node: VMwareVM = Depends(dep_node)
websocket: Union[None, WebSocket] = Depends(ws_compute_authentication), node: VMwareVM = Depends(dep_node)
) -> None:
"""
Console WebSocket.
@ -347,9 +312,7 @@ async def console_ws(
@router.post(
"/{node_id}/console/reset",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
"/{node_id}/console/reset", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
)
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,
status_code=status.HTTP_201_CREATED,
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:
"""
@ -72,11 +72,7 @@ async def create_vpcs_node(project_id: UUID, node_data: schemas.VPCSCreate) -> s
return vm.asdict()
@router.get(
"/{node_id}",
response_model=schemas.VPCS,
dependencies=[Depends(compute_authentication)]
)
@router.get("/{node_id}", response_model=schemas.VPCS, dependencies=[Depends(compute_authentication)])
def get_vpcs_node(node: VPCSVM = Depends(dep_node)) -> schemas.VPCS:
"""
Return a VPCS node.
@ -85,11 +81,7 @@ def get_vpcs_node(node: VPCSVM = Depends(dep_node)) -> schemas.VPCS:
return node.asdict()
@router.put(
"/{node_id}",
response_model=schemas.VPCS,
dependencies=[Depends(compute_authentication)]
)
@router.put("/{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:
"""
Update a VPCS node.
@ -109,11 +101,7 @@ async def update_vpcs_node(node_data: schemas.VPCSUpdate, node: VPCSVM = Depends
return node.asdict()
@router.delete(
"/{node_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
)
@router.delete("/{node_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
async def delete_vpcs_node(node: VPCSVM = Depends(dep_node)) -> None:
"""
Delete a VPCS node.
@ -126,11 +114,11 @@ async def delete_vpcs_node(node: VPCSVM = Depends(dep_node)) -> None:
"/{node_id}/duplicate",
response_model=schemas.VPCS,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def duplicate_vpcs_node(
destination_node_id: UUID = Body(..., embed=True),
node: VPCSVM = Depends(dep_node)) -> None:
destination_node_id: UUID = Body(..., embed=True), node: VPCSVM = Depends(dep_node)
) -> None:
"""
Duplicate a VPCS node.
"""
@ -139,11 +127,7 @@ async def duplicate_vpcs_node(
return new_node.asdict()
@router.post(
"/{node_id}/start",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
)
@router.post("/{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:
"""
Start a VPCS node.
@ -152,11 +136,7 @@ async def start_vpcs_node(node: VPCSVM = Depends(dep_node)) -> None:
await node.start()
@router.post(
"/{node_id}/stop",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
)
@router.post("/{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:
"""
Stop a VPCS node.
@ -166,9 +146,7 @@ async def stop_vpcs_node(node: VPCSVM = Depends(dep_node)) -> None:
@router.post(
"/{node_id}/suspend",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
"/{node_id}/suspend", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
)
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(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
detail="Suspend is not supported for VPCS nodes"
status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Suspend is not supported for VPCS nodes"
)
@router.post(
"/{node_id}/reload",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
"/{node_id}/reload", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
)
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",
status_code=status.HTTP_201_CREATED,
response_model=schemas.UDPNIO,
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def create_vpcs_node_nio(
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
nio_data: schemas.UDPNIO,
node: VPCSVM = Depends(dep_node)
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
nio_data: schemas.UDPNIO,
node: VPCSVM = Depends(dep_node),
) -> schemas.UDPNIO:
"""
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",
status_code=status.HTTP_201_CREATED,
response_model=schemas.UDPNIO,
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def update_vpcs_node_nio(
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
nio_data: schemas.UDPNIO,
node: VPCSVM = Depends(dep_node)
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
nio_data: schemas.UDPNIO,
node: VPCSVM = Depends(dep_node),
) -> schemas.UDPNIO:
"""
Update a NIO (Network Input/Output) on the node.
@ -247,13 +222,10 @@ async def update_vpcs_node_nio(
@router.delete(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
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:
"""
Delete a NIO (Network Input/Output) from the node.
@ -265,14 +237,14 @@ async def delete_vpcs_node_nio(
@router.post(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start",
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def start_vpcs_node_capture(
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
node_capture_data: schemas.NodeCapture,
node: VPCSVM = Depends(dep_node)
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
node_capture_data: schemas.NodeCapture,
node: VPCSVM = Depends(dep_node),
) -> dict:
"""
Start a packet capture on the node.
@ -287,13 +259,10 @@ async def start_vpcs_node_capture(
@router.post(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
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:
"""
Stop a packet capture on the node.
@ -303,16 +272,12 @@ async def stop_vpcs_node_capture(
await node.stop_capture(port_number)
@router.get(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream",
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
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:
"""
Stream the pcap capture file.
@ -324,12 +289,10 @@ async def stream_pcap_file(
return StreamingResponse(stream, media_type="application/vnd.tcpdump.pcap")
@router.websocket(
"/{node_id}/console/ws"
)
@router.websocket("/{node_id}/console/ws")
async def console_ws(
websocket: Union[None, WebSocket] = Depends(ws_compute_authentication),
node: VPCSVM = Depends(dep_node)) -> None:
websocket: Union[None, WebSocket] = Depends(ws_compute_authentication), node: VPCSVM = Depends(dep_node)
) -> None:
"""
Console WebSocket.
"""
@ -338,23 +301,16 @@ async def console_ws(
@router.post(
"/{node_id}/console/reset",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
"/{node_id}/console/reset", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
)
async def reset_console(node: VPCSVM = Depends(dep_node)) -> None:
await node.reset_console()
@router.put(
"/{node_id}/markers/{marker_name}",
dependencies=[Depends(compute_authentication)]
)
@router.put("/{node_id}/markers/{marker_name}", dependencies=[Depends(compute_authentication)])
async def toggle_vpcs_marker(
marker_name: str,
toggle_data: schemas.MarkerToggle,
node: VPCSVM = Depends(dep_node)
marker_name: str, toggle_data: schemas.MarkerToggle, node: VPCSVM = Depends(dep_node)
) -> dict:
"""
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(
"/{node_id}/markers/pause",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
"/{node_id}/markers/pause", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
)
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(
"/{node_id}/markers/resume",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
"/{node_id}/markers/resume", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
)
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(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/{marker_name}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def delete_vpcs_marker_capture(
*,
@ -400,7 +352,7 @@ async def delete_vpcs_marker_capture(
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
link_id: str = "",
node: VPCSVM = Depends(dep_node)
node: VPCSVM = Depends(dep_node),
) -> None:
"""
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)
@router.put(
"/{node_id}/markers/{marker_name}/rebuild",
dependencies=[Depends(compute_authentication)]
)
@router.put("/{node_id}/markers/{marker_name}/rebuild", dependencies=[Depends(compute_authentication)])
async def rebuild_vpcs_marker(
marker_name: str,
rebuild_data: schemas.MarkerRebuild,
node: VPCSVM = Depends(dep_node)
marker_name: str, rebuild_data: schemas.MarkerRebuild, node: VPCSVM = Depends(dep_node)
) -> dict:
"""
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(
marker_name, rebuild_data.link_id, rebuild_data.bpf,
rebuild_data.tag, rebuild_data.direction, rebuild_data.enabled,
marker_name,
rebuild_data.link_id,
rebuild_data.bpf,
rebuild_data.tag,
rebuild_data.direction,
rebuild_data.enabled,
)
return {"marker_name": marker_name}

View File

@ -24,6 +24,7 @@ if AI_COPILOT_AVAILABLE:
from . import chat
from . import copilot
from . import llm_model_configs
_chat_router = chat.router
_copilot_router = copilot.router
_llm_router = llm_model_configs.router
@ -39,9 +40,10 @@ else:
async def ai_not_available(path: str = ""):
raise HTTPException(
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 appliances
from . import computes
@ -68,148 +70,65 @@ from .dependencies.authentication import get_current_active_user
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(
controller.router,
tags=["Controller"]
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(
settings.router,
prefix="/settings",
tags=["Server settings"]
_llm_router, prefix="/access", dependencies=[Depends(get_current_active_user)], tags=["LLM Model Configurations"]
)
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(
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"]
_copilot_router, prefix="/copilot", dependencies=[Depends(get_current_active_user)], tags=["GNS3 Copilot"]
)
router.include_router(
_chat_router,
prefix="/copilot/projects/{project_id}/chat",
dependencies=[Depends(get_current_active_user)],
tags=["GNS3 Copilot"]
tags=["GNS3 Copilot"],
)
router.include_router(
api_keys.router,
dependencies=[Depends(get_current_active_user)],
tags=["API Keys"]
)
router.include_router(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.controller.controller_error import (
ControllerBadRequestError,
ControllerNotFoundError
)
from gns3server.controller.controller_error import ControllerBadRequestError, ControllerNotFoundError
from gns3server.controller import Controller
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:
if isinstance(route, _IncludedRouter):
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
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)
@router.get(
"/endpoints",
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(has_privilege("ACE.Audit"))]
)
@router.get("/endpoints", status_code=status.HTTP_201_CREATED, dependencies=[Depends(has_privilege("ACE.Audit"))])
async def endpoints(
users_repo: UsersRepository = Depends(get_repository(UsersRepository)),
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)),
images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)),
templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository))
users_repo: UsersRepository = Depends(get_repository(UsersRepository)),
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)),
images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)),
templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)),
) -> List[dict]:
"""
List all endpoints to be used in ACL entries.
@ -123,7 +118,7 @@ async def endpoints(
add_to_endpoints(
f"/projects/{project.id}/nodes/{node['node_id']}",
f'Node "{node["name"]}" in project "{project.name}"',
endpoint_type="node"
endpoint_type="node",
)
# links
@ -140,7 +135,7 @@ async def endpoints(
add_to_endpoints(
f"/projects/{project.id}/links/{link['link_id']}",
f'Link from "{node_name_1}" to "{node_name_2}" in project "{project.name}"',
endpoint_type="link"
endpoint_type="link",
)
# users
@ -179,14 +174,8 @@ async def endpoints(
return endpoints
@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]:
@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]:
"""
Get all ACL entries.
@ -200,12 +189,10 @@ async def get_aces(
"",
response_model=schemas.ACE,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(has_privilege("ACE.Allocate"))]
dependencies=[Depends(has_privilege("ACE.Allocate"))],
)
async def create_ace(
request: Request,
ace_create: schemas.ACECreate,
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
request: Request, ace_create: schemas.ACECreate, rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
) -> schemas.ACE:
"""
Create a new ACL entry.
@ -217,11 +204,12 @@ async def create_ace(
print(route_path)
for route_path in _iter_route_paths(request.app.routes, include_mounted_routes=True):
# remove the prefix (e.g. "/v3") from the route path
normalized_path = re.sub(r"^/v[0-9]", "", route_path)
# 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
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")
@router.get(
"/{ace_id}",
response_model=schemas.ACE,
dependencies=[Depends(has_privilege("ACE.Audit"))]
)
@router.get("/{ace_id}", response_model=schemas.ACE, dependencies=[Depends(has_privilege("ACE.Audit"))])
async def get_ace(
ace_id: UUID,
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)),
ace_id: UUID,
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)),
) -> schemas.ACE:
"""
Get an ACL entry.
@ -253,15 +237,9 @@ async def get_ace(
return ace
@router.put(
"/{ace_id}",
response_model=schemas.ACE,
dependencies=[Depends(has_privilege("ACE.Modify"))]
)
@router.put("/{ace_id}", response_model=schemas.ACE, dependencies=[Depends(has_privilege("ACE.Modify"))])
async def update_ace(
ace_id: UUID,
ace_update: schemas.ACEUpdate,
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
ace_id: UUID, ace_update: schemas.ACEUpdate, rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
) -> schemas.ACE:
"""
Update an ACL entry.
@ -277,9 +255,7 @@ async def update_ace(
@router.delete(
"/{ace_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(has_privilege("ACE.Allocate"))]
"/{ace_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(has_privilege("ACE.Allocate"))]
)
async def delete_ace(
ace_id: UUID,

View File

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

View File

@ -69,7 +69,7 @@ def dep_project(project_id: UUID) -> Project:
"/stream",
response_model=None,
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(
request: schemas.ChatRequest,
@ -96,11 +96,11 @@ async def stream_chat(
"Chat rejected: project not opened. user_id=%s, project_id=%s, status=%s",
user_id,
project.id,
project.status
project.status,
)
raise HTTPException(
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(
@ -124,7 +124,7 @@ async def stream_chat(
log.warning("LLM config not found for user: %s", user_id)
raise HTTPException(
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(
@ -161,7 +161,7 @@ async def stream_chat(
user_id=user_id,
jwt_token=jwt_token,
mode=request.mode,
llm_config=llm_config
llm_config=llm_config,
):
try:
# Validate and serialize chunk
@ -187,7 +187,7 @@ async def stream_chat(
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
"Connection": "keep-alive",
}
},
)
@ -195,7 +195,7 @@ async def stream_chat(
"/sessions",
response_model=List[schemas.ChatSession],
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(
project: Project = Depends(dep_project),
@ -213,7 +213,7 @@ async def list_sessions(
if project.status != "opened":
raise HTTPException(
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
@ -221,10 +221,7 @@ async def list_sessions(
agent_service = await agent_manager.get_agent(str(project.id), project.path)
# List sessions with optional copilot_mode filter
sessions = await agent_service.list_sessions(
user_id=str(current_user.user_id),
copilot_mode=copilot_mode
)
sessions = await agent_service.list_sessions(user_id=str(current_user.user_id), copilot_mode=copilot_mode)
# Convert to schemas
return [schemas.ChatSession(**s) for s in sessions]
@ -234,7 +231,7 @@ async def list_sessions(
"/sessions/{session_id}/history",
response_model=schemas.ConversationHistory,
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(
session_id: str,
@ -250,7 +247,7 @@ async def get_history(
if project.status != "opened":
raise HTTPException(
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
@ -267,7 +264,7 @@ async def get_history(
"/sessions/{session_id}",
status_code=status.HTTP_204_NO_CONTENT,
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(
session_id: str,
@ -282,7 +279,7 @@ async def delete_session(
if project.status != "opened":
raise HTTPException(
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
@ -293,17 +290,14 @@ async def delete_session(
deleted = await agent_service.delete_session(session_id)
if not deleted:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Session '{session_id}' not found"
)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Session '{session_id}' not found")
@router.post(
"/sessions/{session_id}/abort",
status_code=status.HTTP_200_OK,
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(
session_id: str,
@ -321,7 +315,7 @@ async def abort_session(
if project.status != "opened":
raise HTTPException(
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
@ -338,7 +332,7 @@ async def abort_session(
"/sessions/{session_id}",
response_model=schemas.ChatSession,
summary="Rename a chat session",
description="Rename a specific chat session."
description="Rename a specific chat session.",
)
async def rename_session(
session_id: str,
@ -354,7 +348,7 @@ async def rename_session(
if project.status != "opened":
raise HTTPException(
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
@ -365,10 +359,7 @@ async def rename_session(
session = await agent_service.rename_session(session_id, request.title)
if not session:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Session '{session_id}' not found"
)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Session '{session_id}' not found")
return schemas.ChatSession(**session)
@ -377,7 +368,7 @@ async def rename_session(
"/sessions/{session_id}/pin",
response_model=schemas.ChatSession,
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(
session_id: str,
@ -392,7 +383,7 @@ async def pin_session(
if project.status != "opened":
raise HTTPException(
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
@ -403,10 +394,7 @@ async def pin_session(
session = await agent_service.pin_session(session_id, pinned=True)
if not session:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Session '{session_id}' not found"
)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Session '{session_id}' not found")
return schemas.ChatSession(**session)
@ -415,7 +403,7 @@ async def pin_session(
"/sessions/{session_id}/pin",
response_model=schemas.ChatSession,
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(
session_id: str,
@ -430,7 +418,7 @@ async def unpin_session(
if project.status != "opened":
raise HTTPException(
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
@ -441,10 +429,7 @@ async def unpin_session(
session = await agent_service.pin_session(session_id, pinned=False)
if not session:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Session '{session_id}' not found"
)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Session '{session_id}' not found")
return schemas.ChatSession(**session)
@ -453,7 +438,7 @@ async def unpin_session(
"/inject",
response_model=None,
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(
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",
user_id,
project.id,
project.status
project.status,
)
raise HTTPException(
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(
@ -510,7 +495,7 @@ async def inject_issue(
log.warning("LLM config not found for user: %s", user_id)
raise HTTPException(
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
@ -546,7 +531,7 @@ async def inject_issue(
user_id=user_id,
jwt_token=jwt_token,
mode=request.mode,
llm_config=llm_config
llm_config=llm_config,
):
try:
# Validate and serialize chunk
@ -572,5 +557,5 @@ async def inject_issue(
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
"Connection": "keep-alive",
}
},
)

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