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