mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-10-27 10:42:28 +02:00
Merge pull request #2880 from markparonyan/harden-ci
ci: mypy, ruff, pytest
This commit is contained in:
commit
d99b89f1e9
@ -33,7 +33,7 @@ from gns3server.services import auth_service
|
|||||||
from gns3server.services.authentication import DEFAULT_JWT_SECRET_KEY
|
from gns3server.services.authentication import DEFAULT_JWT_SECRET_KEY
|
||||||
|
|
||||||
token = auth_service.create_access_token(test_user.username, secret_key=DEFAULT_JWT_SECRET_KEY)
|
token = auth_service.create_access_token(test_user.username, secret_key=DEFAULT_JWT_SECRET_KEY)
|
||||||
response = await client.get(url, headers={"Authorization": f"Bearer {token}"}) # specific user
|
response = await client.get(url, headers={"Authorization": f"Bearer {token}"}) # specific user
|
||||||
response = await client.get(url, headers={"Authorization": "Bearer invalid_token"}) # 401
|
response = await client.get(url, headers={"Authorization": "Bearer invalid_token"}) # 401
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@ -1,2 +1,9 @@
|
|||||||
|
[run]
|
||||||
|
source = gns3server
|
||||||
|
branch = True
|
||||||
|
|
||||||
|
[report]
|
||||||
|
fail_under = 55
|
||||||
|
|
||||||
[paths]
|
[paths]
|
||||||
source = gns3server
|
source = gns3server
|
||||||
|
|||||||
13
.github/workflows/testing.yml
vendored
13
.github/workflows/testing.yml
vendored
@ -21,26 +21,23 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
cache: pip
|
cache: pip
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
python -m pip install .[ai-features,dev]
|
python -m pip install .[ai-features,dev]
|
||||||
|
|
||||||
- name: Install sharkd (marker replay engine)
|
- name: Install sharkd (marker replay engine)
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y wireshark-common
|
sudo apt-get install -y wireshark-common
|
||||||
|
|
||||||
- name: Lint with flake8
|
- name: Run all checks
|
||||||
run: |
|
run: |
|
||||||
# stop the build if there are Python syntax errors or undefined names
|
just test
|
||||||
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
|
|
||||||
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
|
|
||||||
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
|
|
||||||
- name: Test with pytest
|
|
||||||
run: |
|
|
||||||
python -m pytest -vv
|
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@ -37,6 +37,10 @@ pip-log.txt
|
|||||||
.tox
|
.tox
|
||||||
.cache
|
.cache
|
||||||
nosetests.xml
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
htmlcov/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
# Translations
|
# Translations
|
||||||
*.mo
|
*.mo
|
||||||
|
|||||||
@ -126,34 +126,37 @@ flake8 gns3server/utils/
|
|||||||
import os # Never used
|
import os # Never used
|
||||||
# Good: Delete this line
|
# Good: Delete this line
|
||||||
|
|
||||||
|
|
||||||
# F841: Mark as intentionally unused
|
# F841: Mark as intentionally unused
|
||||||
# Bad
|
# Bad
|
||||||
def foo():
|
def foo():
|
||||||
x = 1 # Never used
|
x = 1 # Never used
|
||||||
|
|
||||||
|
|
||||||
# Good: Use or mark as intentionally unused
|
# Good: Use or mark as intentionally unused
|
||||||
def foo():
|
def foo():
|
||||||
x = 1
|
x = 1
|
||||||
_ = x # Mark as intentionally unused
|
_ = x # Mark as intentionally unused
|
||||||
|
|
||||||
|
|
||||||
# F824: Remove unnecessary global declaration
|
# F824: Remove unnecessary global declaration
|
||||||
# Bad
|
# Bad
|
||||||
def foo():
|
def foo():
|
||||||
global _fernet # Never assigned in this scope
|
global _fernet # Never assigned in this scope
|
||||||
|
|
||||||
|
|
||||||
# Good: Remove global statement (if only reading)
|
# Good: Remove global statement (if only reading)
|
||||||
def foo():
|
def foo():
|
||||||
# Just read the global variable, no global declaration needed
|
# Just read the global variable, no global declaration needed
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
# E501: Break long lines
|
# E501: Break long lines
|
||||||
# Bad
|
# Bad
|
||||||
raise RuntimeError("Encryption not initialized. Call init_encryption() first.")
|
raise RuntimeError("Encryption not initialized. Call init_encryption() first.")
|
||||||
|
|
||||||
# Good
|
# Good
|
||||||
raise RuntimeError(
|
raise RuntimeError("Encryption not initialized. Call init_encryption() first.")
|
||||||
"Encryption not initialized. Call init_encryption() first."
|
|
||||||
)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Ruff (Alternative)
|
#### Ruff (Alternative)
|
||||||
@ -177,6 +180,7 @@ ruff format gns3server/ # Format code
|
|||||||
def process_data(input_data: dict[str, Any]) -> list[dict[str, Any]]:
|
def process_data(input_data: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
# 2. Error handling
|
# 2. Error handling
|
||||||
try:
|
try:
|
||||||
result = tool.invoke(args)
|
result = tool.invoke(args)
|
||||||
|
|||||||
@ -1,7 +1,10 @@
|
|||||||
pytest==9.1.1
|
pytest==9.1.1
|
||||||
flake8==7.3.0
|
|
||||||
pytest-timeout==2.4.0
|
pytest-timeout==2.4.0
|
||||||
pytest-asyncio==1.4.0
|
pytest-asyncio==1.4.0
|
||||||
httpx==0.28.1
|
httpx==0.28.1
|
||||||
httpx_ws==0.7.2 # upgrading leads to failures in tests
|
httpx_ws==0.7.2 # upgrading leads to failures in tests
|
||||||
pytest-random-order==1.2.0 # opt-in: --random-order --random-order-bucket=global (legacy suites are not shuffle-clean yet, see .claude/skills/gns3-api-test-writing)
|
pytest-random-order==1.2.0 # opt-in: --random-order --random-order-bucket=global (legacy suites are not shuffle-clean yet, see .claude/skills/gns3-api-test-writing)
|
||||||
|
pytest-cov==7.1.0
|
||||||
|
ruff==0.16.9
|
||||||
|
mypy==2.3.1
|
||||||
|
rust-just==1.58.0 # provides the `just` command runner, see justfile
|
||||||
|
|||||||
@ -279,7 +279,7 @@ Fix the WebSocket error handler:
|
|||||||
```python
|
```python
|
||||||
@app.exception_handler(ControllerNotFoundError)
|
@app.exception_handler(ControllerNotFoundError)
|
||||||
async def controller_not_found_error_handler(request: Request, exc: ControllerNotFoundError):
|
async def controller_not_found_error_handler(request: Request, exc: ControllerNotFoundError):
|
||||||
method = getattr(request, 'method', 'WebSocket')
|
method = getattr(request, "method", "WebSocket")
|
||||||
log.error(f"Controller not found error in {request.url.path} ({method}): {exc}")
|
log.error(f"Controller not found error in {request.url.path} ({method}): {exc}")
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
|||||||
@ -238,25 +238,32 @@ Both `device_config_send` and `device_show_run` support an optional `template` p
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
# Direct commands (single/batch)
|
# Direct commands (single/batch)
|
||||||
device_config_send(project_id, device_configs=[
|
device_config_send(
|
||||||
{"device_name": "R1", "config_commands": ["int lo0", "ip add 1.1.1.1 255.255.255.255"]},
|
project_id,
|
||||||
])
|
device_configs=[
|
||||||
|
{"device_name": "R1", "config_commands": ["int lo0", "ip add 1.1.1.1 255.255.255.255"]},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
# Jinja2 template (reduces token usage for batch)
|
# Jinja2 template (reduces token usage for batch)
|
||||||
device_config_send(project_id,
|
device_config_send(
|
||||||
|
project_id,
|
||||||
template="interface lo{{ n }}\nip address {{ ip }} 255.255.255.255",
|
template="interface lo{{ n }}\nip address {{ ip }} 255.255.255.255",
|
||||||
device_configs=[
|
device_configs=[
|
||||||
{"device_name": "R1", "vars": {"n": 0, "ip": "1.1.1.1"}},
|
{"device_name": "R1", "vars": {"n": 0, "ip": "1.1.1.1"}},
|
||||||
{"device_name": "R2", "vars": {"n": 0, "ip": "2.2.2.2"}},
|
{"device_name": "R2", "vars": {"n": 0, "ip": "2.2.2.2"}},
|
||||||
])
|
],
|
||||||
|
)
|
||||||
|
|
||||||
# Show commands with template
|
# Show commands with template
|
||||||
device_show_run(project_id,
|
device_show_run(
|
||||||
|
project_id,
|
||||||
template="show ip route {{ protocol }}",
|
template="show ip route {{ protocol }}",
|
||||||
device_configs=[
|
device_configs=[
|
||||||
{"device_name": "R1", "vars": {"protocol": "ospf"}},
|
{"device_name": "R1", "vars": {"protocol": "ospf"}},
|
||||||
{"device_name": "R2", "vars": {"protocol": "bgp"}},
|
{"device_name": "R2", "vars": {"protocol": "bgp"}},
|
||||||
])
|
],
|
||||||
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Best Practices
|
### Best Practices
|
||||||
@ -273,9 +280,12 @@ device_show_run(project_id,
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
# Save config on device
|
# Save config on device
|
||||||
device_show_run(project_id, device_configs=[
|
device_show_run(
|
||||||
{"device_name": "R1", "commands": ["write memory"]},
|
project_id,
|
||||||
])
|
device_configs=[
|
||||||
|
{"device_name": "R1", "commands": ["write memory"]},
|
||||||
|
],
|
||||||
|
)
|
||||||
# Backup
|
# Backup
|
||||||
config = node_file_get(project_id, node_id, "startup-config.cfg")
|
config = node_file_get(project_id, node_id, "startup-config.cfg")
|
||||||
# Restore if config breaks
|
# Restore if config breaks
|
||||||
|
|||||||
@ -51,8 +51,8 @@ Each IOU VM creation spawned `ld-linux --verify` and `iou-image -h` subprocesses
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
# Class-level caches shared across all instances
|
# Class-level caches shared across all instances
|
||||||
IOUVM._loader_cache = {} # image path → loader command
|
IOUVM._loader_cache = {} # image path → loader command
|
||||||
IOUVM._default_values_cache = {} # image path → (ram, nvram)
|
IOUVM._default_values_cache = {} # image path → (ram, nvram)
|
||||||
```
|
```
|
||||||
|
|
||||||
Only the first node with a given image runs the subprocesses; subsequent nodes reuse cached values.
|
Only the first node with a given image runs the subprocesses; subsequent nodes reuse cached values.
|
||||||
|
|||||||
@ -798,7 +798,7 @@ OpenAI-compatible message model.
|
|||||||
content=observation, # Always JSON string format
|
content=observation, # Always JSON string format
|
||||||
tool_call_id=tool_call["id"],
|
tool_call_id=tool_call["id"],
|
||||||
name=tool_call["name"],
|
name=tool_call["name"],
|
||||||
metadata={"created_at": datetime.utcnow().isoformat()}
|
metadata={"created_at": datetime.utcnow().isoformat()},
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -924,11 +924,7 @@ ToolMessage(content=JSON_string)
|
|||||||
|
|
||||||
**Route Registration**:
|
**Route Registration**:
|
||||||
```python
|
```python
|
||||||
router.include_router(
|
router.include_router(chat.router, prefix="/{project_id}/chat", tags=["Chat"])
|
||||||
chat.router,
|
|
||||||
prefix="/{project_id}/chat",
|
|
||||||
tags=["Chat"]
|
|
||||||
)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Main Endpoint Implementation**:
|
**Main Endpoint Implementation**:
|
||||||
|
|||||||
@ -79,9 +79,11 @@ PC1> ping 10.10.0.254
|
|||||||
def check_config_mode(self) -> bool:
|
def check_config_mode(self) -> bool:
|
||||||
return False # VPCS has no config mode
|
return False # VPCS has no config mode
|
||||||
|
|
||||||
|
|
||||||
def config_mode(self) -> str:
|
def config_mode(self) -> str:
|
||||||
return "" # No config mode to enter
|
return "" # No config mode to enter
|
||||||
|
|
||||||
|
|
||||||
def exit_config_mode(self) -> str:
|
def exit_config_mode(self) -> str:
|
||||||
return "" # No config mode to exit
|
return "" # No config mode to exit
|
||||||
```
|
```
|
||||||
@ -354,6 +356,7 @@ The custom driver must be registered with Netmiko's global mappings:
|
|||||||
```python
|
```python
|
||||||
def register_custom_device_type() -> None:
|
def register_custom_device_type() -> None:
|
||||||
import importlib
|
import importlib
|
||||||
|
|
||||||
sd = importlib.import_module("netmiko.ssh_dispatcher")
|
sd = importlib.import_module("netmiko.ssh_dispatcher")
|
||||||
|
|
||||||
# Register in CLASS_MAPPER (for ConnectHandler)
|
# Register in CLASS_MAPPER (for ConnectHandler)
|
||||||
@ -430,12 +433,12 @@ gns3server/agent/gns3_copilot/utils/custom_netmiko/ruijie_telnet.py
|
|||||||
**1. Preprocessing - Known Interactive Commands**
|
**1. Preprocessing - Known Interactive Commands**
|
||||||
```python
|
```python
|
||||||
INTERACTIVE_PATTERNS = [
|
INTERACTIVE_PATTERNS = [
|
||||||
re.compile(r'^router-id\s+', re.IGNORECASE), # OSPF/EIGRP/BGP router-id
|
re.compile(r"^router-id\s+", re.IGNORECASE), # OSPF/EIGRP/BGP router-id
|
||||||
re.compile(r'^erase\s+', re.IGNORECASE), # erase startup-config
|
re.compile(r"^erase\s+", re.IGNORECASE), # erase startup-config
|
||||||
re.compile(r'^delete\s+', re.IGNORECASE), # delete files
|
re.compile(r"^delete\s+", re.IGNORECASE), # delete files
|
||||||
re.compile(r'^format\s+', re.IGNORECASE), # format filesystem
|
re.compile(r"^format\s+", re.IGNORECASE), # format filesystem
|
||||||
re.compile(r'^reload\b', re.IGNORECASE), # reload/reboot
|
re.compile(r"^reload\b", re.IGNORECASE), # reload/reboot
|
||||||
re.compile(r'^boot\s+system\s+', re.IGNORECASE), # change boot image
|
re.compile(r"^boot\s+system\s+", re.IGNORECASE), # change boot image
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@ -520,9 +520,14 @@ The tools call the shared REST handler layer (`gns3_copilot.gns3_client.api_hand
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import (
|
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import (
|
||||||
build_gns3_ctx, create_node_handler, create_link_handler,
|
build_gns3_ctx,
|
||||||
get_nodes_handler, start_node_handler, stop_node_handler,
|
create_node_handler,
|
||||||
suspend_node_handler, update_node_handler,
|
create_link_handler,
|
||||||
|
get_nodes_handler,
|
||||||
|
start_node_handler,
|
||||||
|
stop_node_handler,
|
||||||
|
suspend_node_handler,
|
||||||
|
update_node_handler,
|
||||||
)
|
)
|
||||||
|
|
||||||
gns3_ctx = build_gns3_ctx() # JWT + server URL from the request context
|
gns3_ctx = build_gns3_ctx() # JWT + server URL from the request context
|
||||||
@ -650,10 +655,7 @@ Note: These special node types are filtered out by GNS3TemplateTool and won't ap
|
|||||||
from gns3server.agent.gns3_copilot.tools_v2 import GNS3StartNodeTool
|
from gns3server.agent.gns3_copilot.tools_v2 import GNS3StartNodeTool
|
||||||
|
|
||||||
tool = GNS3StartNodeTool()
|
tool = GNS3StartNodeTool()
|
||||||
result = tool._run(json.dumps({
|
result = tool._run(json.dumps({"project_id": "abc-123-def", "node_ids": ["node-1", "node-2", "node-3"]}))
|
||||||
"project_id": "abc-123-def",
|
|
||||||
"node_ids": ["node-1", "node-2", "node-3"]
|
|
||||||
}))
|
|
||||||
|
|
||||||
# Output includes progress bar and final status
|
# Output includes progress bar and final status
|
||||||
```
|
```
|
||||||
@ -664,10 +666,7 @@ result = tool._run(json.dumps({
|
|||||||
from gns3server.agent.gns3_copilot.tools_v2 import GNS3StopNodeTool
|
from gns3server.agent.gns3_copilot.tools_v2 import GNS3StopNodeTool
|
||||||
|
|
||||||
tool = GNS3StopNodeTool()
|
tool = GNS3StopNodeTool()
|
||||||
result = tool._run(json.dumps({
|
result = tool._run(json.dumps({"project_id": "abc-123-def", "node_ids": ["node-1", "node-2"]}))
|
||||||
"project_id": "abc-123-def",
|
|
||||||
"node_ids": ["node-1", "node-2"]
|
|
||||||
}))
|
|
||||||
|
|
||||||
# Immediate return with stop status
|
# Immediate return with stop status
|
||||||
```
|
```
|
||||||
@ -677,19 +676,13 @@ result = tool._run(json.dumps({
|
|||||||
```python
|
```python
|
||||||
# Lab deployment
|
# Lab deployment
|
||||||
start_tool = GNS3StartNodeTool()
|
start_tool = GNS3StartNodeTool()
|
||||||
start_result = start_tool._run(json.dumps({
|
start_result = start_tool._run(json.dumps({"project_id": project_id, "node_ids": all_node_ids}))
|
||||||
"project_id": project_id,
|
|
||||||
"node_ids": all_node_ids
|
|
||||||
}))
|
|
||||||
|
|
||||||
# ... Run tests ...
|
# ... Run tests ...
|
||||||
|
|
||||||
# Lab shutdown
|
# Lab shutdown
|
||||||
stop_tool = GNS3StopNodeTool()
|
stop_tool = GNS3StopNodeTool()
|
||||||
stop_result = stop_tool._run(json.dumps({
|
stop_result = stop_tool._run(json.dumps({"project_id": project_id, "node_ids": all_node_ids}))
|
||||||
"project_id": project_id,
|
|
||||||
"node_ids": all_node_ids
|
|
||||||
}))
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Example 4: Lab Pause and Resume
|
### Example 4: Lab Pause and Resume
|
||||||
@ -699,56 +692,45 @@ from gns3server.agent.gns3_copilot.tools_v2 import GNS3SuspendNodeTool
|
|||||||
|
|
||||||
# Start lab
|
# Start lab
|
||||||
start_tool = GNS3StartNodeTool()
|
start_tool = GNS3StartNodeTool()
|
||||||
start_result = start_tool._run(json.dumps({
|
start_result = start_tool._run(json.dumps({"project_id": project_id, "node_ids": all_node_ids}))
|
||||||
"project_id": project_id,
|
|
||||||
"node_ids": all_node_ids
|
|
||||||
}))
|
|
||||||
|
|
||||||
# ... Configure devices ...
|
# ... Configure devices ...
|
||||||
# ... Run some tests ...
|
# ... Run some tests ...
|
||||||
|
|
||||||
# Suspend lab (preserves all state)
|
# Suspend lab (preserves all state)
|
||||||
suspend_tool = GNS3SuspendNodeTool()
|
suspend_tool = GNS3SuspendNodeTool()
|
||||||
suspend_result = suspend_tool._run(json.dumps({
|
suspend_result = suspend_tool._run(json.dumps({"project_id": project_id, "node_ids": all_node_ids}))
|
||||||
"project_id": project_id,
|
|
||||||
"node_ids": all_node_ids
|
|
||||||
}))
|
|
||||||
# Nodes suspended - state preserved in memory
|
# Nodes suspended - state preserved in memory
|
||||||
|
|
||||||
# ... Take a break, work on something else ...
|
# ... Take a break, work on something else ...
|
||||||
|
|
||||||
# Resume lab (quick recovery)
|
# Resume lab (quick recovery)
|
||||||
start_result = start_tool._run(json.dumps({
|
start_result = start_tool._run(json.dumps({"project_id": project_id, "node_ids": all_node_ids}))
|
||||||
"project_id": project_id,
|
|
||||||
"node_ids": all_node_ids
|
|
||||||
}))
|
|
||||||
# Back to previous state in seconds!
|
# Back to previous state in seconds!
|
||||||
```
|
```
|
||||||
|
|
||||||
### Example 5: Suspend While Renaming Nodes
|
### Example 5: Suspend While Renaming Nodes
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from gns3server.agent.gns3_copilot.tools_v2 import (
|
from gns3server.agent.gns3_copilot.tools_v2 import GNS3SuspendNodeTool, GNS3UpdateNodeNameTool
|
||||||
GNS3SuspendNodeTool,
|
|
||||||
GNS3UpdateNodeNameTool
|
|
||||||
)
|
|
||||||
|
|
||||||
# Suspend nodes first (allows renaming)
|
# Suspend nodes first (allows renaming)
|
||||||
suspend_tool = GNS3SuspendNodeTool()
|
suspend_tool = GNS3SuspendNodeTool()
|
||||||
suspend_result = suspend_tool._run(json.dumps({
|
suspend_result = suspend_tool._run(json.dumps({"project_id": project_id, "node_ids": node_ids}))
|
||||||
"project_id": project_id,
|
|
||||||
"node_ids": node_ids
|
|
||||||
}))
|
|
||||||
|
|
||||||
# Now rename nodes (possible while suspended!)
|
# Now rename nodes (possible while suspended!)
|
||||||
rename_tool = GNS3UpdateNodeNameTool()
|
rename_tool = GNS3UpdateNodeNameTool()
|
||||||
rename_result = rename_tool._run(json.dumps({
|
rename_result = rename_tool._run(
|
||||||
"project_id": project_id,
|
json.dumps(
|
||||||
"nodes": [
|
{
|
||||||
{"node_id": "node-1", "new_name": "Router-Primary"},
|
"project_id": project_id,
|
||||||
{"node_id": "node-2", "new_name": "Router-Backup"}
|
"nodes": [
|
||||||
]
|
{"node_id": "node-1", "new_name": "Router-Primary"},
|
||||||
}))
|
{"node_id": "node-2", "new_name": "Router-Backup"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# Resume when ready
|
# Resume when ready
|
||||||
# Note: Cannot rename while started, but CAN rename while suspended!
|
# Note: Cannot rename while started, but CAN rename while suspended!
|
||||||
@ -777,21 +759,17 @@ result = tool._run("")
|
|||||||
from gns3server.agent.gns3_copilot.tools_v2 import GNS3CreateNodeTool
|
from gns3server.agent.gns3_copilot.tools_v2 import GNS3CreateNodeTool
|
||||||
|
|
||||||
tool = GNS3CreateNodeTool()
|
tool = GNS3CreateNodeTool()
|
||||||
result = tool._run(json.dumps({
|
result = tool._run(
|
||||||
"project_id": "abc-123-def",
|
json.dumps(
|
||||||
"nodes": [
|
|
||||||
{
|
{
|
||||||
"template_id": "uuid-of-router-template",
|
"project_id": "abc-123-def",
|
||||||
"x": 100,
|
"nodes": [
|
||||||
"y": -200
|
{"template_id": "uuid-of-router-template", "x": 100, "y": -200},
|
||||||
},
|
{"template_id": "uuid-of-switch-template", "x": -200, "y": 300},
|
||||||
{
|
],
|
||||||
"template_id": "uuid-of-switch-template",
|
|
||||||
"x": -200,
|
|
||||||
"y": 300
|
|
||||||
}
|
}
|
||||||
]
|
)
|
||||||
}))
|
)
|
||||||
|
|
||||||
# Creates two nodes with specified templates and positions
|
# Creates two nodes with specified templates and positions
|
||||||
```
|
```
|
||||||
@ -802,23 +780,27 @@ result = tool._run(json.dumps({
|
|||||||
from gns3server.agent.gns3_copilot.tools_v2 import GNS3LinkTool
|
from gns3server.agent.gns3_copilot.tools_v2 import GNS3LinkTool
|
||||||
|
|
||||||
tool = GNS3LinkTool()
|
tool = GNS3LinkTool()
|
||||||
result = tool._run(json.dumps({
|
result = tool._run(
|
||||||
"project_id": "abc-123-def",
|
json.dumps(
|
||||||
"links": [
|
|
||||||
{
|
{
|
||||||
"node_id1": "uuid-of-node1",
|
"project_id": "abc-123-def",
|
||||||
"port1": "Ethernet0/0",
|
"links": [
|
||||||
"node_id2": "uuid-of-node2",
|
{
|
||||||
"port2": "Ethernet0/0"
|
"node_id1": "uuid-of-node1",
|
||||||
},
|
"port1": "Ethernet0/0",
|
||||||
{
|
"node_id2": "uuid-of-node2",
|
||||||
"node_id1": "uuid-of-node1",
|
"port2": "Ethernet0/0",
|
||||||
"port1": "Ethernet0/1",
|
},
|
||||||
"node_id2": "uuid-of-node3",
|
{
|
||||||
"port2": "Ethernet0/0"
|
"node_id1": "uuid-of-node1",
|
||||||
|
"port1": "Ethernet0/1",
|
||||||
|
"node_id2": "uuid-of-node3",
|
||||||
|
"port2": "Ethernet0/0",
|
||||||
|
},
|
||||||
|
],
|
||||||
}
|
}
|
||||||
]
|
)
|
||||||
}))
|
)
|
||||||
|
|
||||||
# Creates two links connecting the nodes
|
# Creates two links connecting the nodes
|
||||||
```
|
```
|
||||||
@ -829,15 +811,19 @@ result = tool._run(json.dumps({
|
|||||||
from gns3server.agent.gns3_copilot.tools_v2 import GNS3UpdateNodeNameTool
|
from gns3server.agent.gns3_copilot.tools_v2 import GNS3UpdateNodeNameTool
|
||||||
|
|
||||||
tool = GNS3UpdateNodeNameTool()
|
tool = GNS3UpdateNodeNameTool()
|
||||||
result = tool._run(json.dumps({
|
result = tool._run(
|
||||||
"project_id": "abc-123-def",
|
json.dumps(
|
||||||
"nodes": [
|
{
|
||||||
{"node_id": "node-1", "new_name": "R1-Core"},
|
"project_id": "abc-123-def",
|
||||||
{"node_id": "node-2", "new_name": "R2-Core"},
|
"nodes": [
|
||||||
{"node_id": "node-3", "new_name": "S1-Access"},
|
{"node_id": "node-1", "new_name": "R1-Core"},
|
||||||
{"node_id": "node-4", "new_name": "S2-Access"}
|
{"node_id": "node-2", "new_name": "R2-Core"},
|
||||||
]
|
{"node_id": "node-3", "new_name": "S1-Access"},
|
||||||
}))
|
{"node_id": "node-4", "new_name": "S2-Access"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# Applies consistent naming to all nodes
|
# Applies consistent naming to all nodes
|
||||||
```
|
```
|
||||||
@ -850,7 +836,7 @@ from gns3server.agent.gns3_copilot.tools_v2 import (
|
|||||||
GNS3CreateNodeTool,
|
GNS3CreateNodeTool,
|
||||||
GNS3LinkTool,
|
GNS3LinkTool,
|
||||||
GNS3UpdateNodeNameTool,
|
GNS3UpdateNodeNameTool,
|
||||||
GNS3StartNodeTool
|
GNS3StartNodeTool,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Step 1: Get available templates
|
# Step 1: Get available templates
|
||||||
@ -860,44 +846,63 @@ templates = template_tool._run("")
|
|||||||
|
|
||||||
# Step 2: Create nodes
|
# Step 2: Create nodes
|
||||||
create_tool = GNS3CreateNodeTool()
|
create_tool = GNS3CreateNodeTool()
|
||||||
nodes = create_tool._run(json.dumps({
|
nodes = create_tool._run(
|
||||||
"project_id": project_id,
|
json.dumps(
|
||||||
"nodes": [
|
{
|
||||||
{"template_id": router_template_id, "x": 0, "y": -200},
|
"project_id": project_id,
|
||||||
{"template_id": router_template_id, "x": 200, "y": -200},
|
"nodes": [
|
||||||
{"template_id": switch_template_id, "x": 100, "y": 0}
|
{"template_id": router_template_id, "x": 0, "y": -200},
|
||||||
]
|
{"template_id": router_template_id, "x": 200, "y": -200},
|
||||||
}))
|
{"template_id": switch_template_id, "x": 100, "y": 0},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# Step 3: Connect nodes
|
# Step 3: Connect nodes
|
||||||
link_tool = GNS3LinkTool()
|
link_tool = GNS3LinkTool()
|
||||||
links = link_tool._run(json.dumps({
|
links = link_tool._run(
|
||||||
"project_id": project_id,
|
json.dumps(
|
||||||
"links": [
|
{
|
||||||
{"node_id1": nodes["created_nodes"][0]["node_id"], "port1": "Ethernet0/0",
|
"project_id": project_id,
|
||||||
"node_id2": nodes["created_nodes"][2]["node_id"], "port2": "Ethernet0/0"},
|
"links": [
|
||||||
{"node_id1": nodes["created_nodes"][1]["node_id"], "port1": "Ethernet0/0",
|
{
|
||||||
"node_id2": nodes["created_nodes"][2]["node_id"], "port2": "Ethernet0/1"}
|
"node_id1": nodes["created_nodes"][0]["node_id"],
|
||||||
]
|
"port1": "Ethernet0/0",
|
||||||
}))
|
"node_id2": nodes["created_nodes"][2]["node_id"],
|
||||||
|
"port2": "Ethernet0/0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node_id1": nodes["created_nodes"][1]["node_id"],
|
||||||
|
"port1": "Ethernet0/0",
|
||||||
|
"node_id2": nodes["created_nodes"][2]["node_id"],
|
||||||
|
"port2": "Ethernet0/1",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# Step 4: Apply naming
|
# Step 4: Apply naming
|
||||||
name_tool = GNS3UpdateNodeNameTool()
|
name_tool = GNS3UpdateNodeNameTool()
|
||||||
names = name_tool._run(json.dumps({
|
names = name_tool._run(
|
||||||
"project_id": project_id,
|
json.dumps(
|
||||||
"nodes": [
|
{
|
||||||
{"node_id": nodes["created_nodes"][0]["node_id"], "new_name": "R1"},
|
"project_id": project_id,
|
||||||
{"node_id": nodes["created_nodes"][1]["node_id"], "new_name": "R2"},
|
"nodes": [
|
||||||
{"node_id": nodes["created_nodes"][2]["node_id"], "new_name": "SW1"}
|
{"node_id": nodes["created_nodes"][0]["node_id"], "new_name": "R1"},
|
||||||
]
|
{"node_id": nodes["created_nodes"][1]["node_id"], "new_name": "R2"},
|
||||||
}))
|
{"node_id": nodes["created_nodes"][2]["node_id"], "new_name": "SW1"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# Step 5: Start nodes
|
# Step 5: Start nodes
|
||||||
start_tool = GNS3StartNodeTool()
|
start_tool = GNS3StartNodeTool()
|
||||||
start_result = start_tool._run(json.dumps({
|
start_result = start_tool._run(
|
||||||
"project_id": project_id,
|
json.dumps({"project_id": project_id, "node_ids": [n["node_id"] for n in nodes["created_nodes"]]})
|
||||||
"node_ids": [n["node_id"] for n in nodes["created_nodes"]]
|
)
|
||||||
}))
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Error Handling
|
## Error Handling
|
||||||
|
|||||||
@ -150,9 +150,9 @@ Git operations use per-command environment variables to prevent hanging:
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
_GIT_TIMEOUT_ENV = {
|
_GIT_TIMEOUT_ENV = {
|
||||||
'GIT_HTTP_TIMEOUT': '10', # Connection timeout (default: 120s)
|
"GIT_HTTP_TIMEOUT": "10", # Connection timeout (default: 120s)
|
||||||
'GIT_HTTP_LOW_SPEED_TIME': '5', # Slow speed threshold window
|
"GIT_HTTP_LOW_SPEED_TIME": "5", # Slow speed threshold window
|
||||||
'GIT_HTTP_LOW_SPEED_LIMIT': '1000', # < 1 KB/s = slow → abort
|
"GIT_HTTP_LOW_SPEED_LIMIT": "1000", # < 1 KB/s = slow → abort
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@ -389,18 +389,13 @@ router ospf {{ process_id }}
|
|||||||
@interrupt
|
@interrupt
|
||||||
def template_review_checkpoint(state):
|
def template_review_checkpoint(state):
|
||||||
"""Pause and wait for user confirmation."""
|
"""Pause and wait for user confirmation."""
|
||||||
return {
|
return {"type": "template_review", "data": state["generated_template"]}
|
||||||
"type": "template_review",
|
|
||||||
"data": state["generated_template"]
|
|
||||||
}
|
|
||||||
|
|
||||||
@interrupt
|
@interrupt
|
||||||
def params_review_checkpoint(state):
|
def params_review_checkpoint(state):
|
||||||
"""Pause and wait for user confirmation."""
|
"""Pause and wait for user confirmation."""
|
||||||
return {
|
return {"type": "params_review", "data": state["generated_params"]}
|
||||||
"type": "params_review",
|
|
||||||
"data": state["generated_params"]
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**State Management:**
|
**State Management:**
|
||||||
@ -939,24 +934,9 @@ For 1000+ devices, some failures are inevitable. The system provides:
|
|||||||
```python
|
```python
|
||||||
{
|
{
|
||||||
"total_devices": 1000,
|
"total_devices": 1000,
|
||||||
"summary": {
|
"summary": {"success": 987, "failed": 13, "skipped": 0},
|
||||||
"success": 987,
|
"failed_devices": [{"device_name": "R456", "error": "Connection timeout", "retry_available": true}, ...],
|
||||||
"failed": 13,
|
"retry_suggestions": {"auto_retry": True, "retry_batch_size": 10, "exponential_backoff": True},
|
||||||
"skipped": 0
|
|
||||||
},
|
|
||||||
"failed_devices": [
|
|
||||||
{
|
|
||||||
"device_name": "R456",
|
|
||||||
"error": "Connection timeout",
|
|
||||||
"retry_available": true
|
|
||||||
},
|
|
||||||
...
|
|
||||||
],
|
|
||||||
"retry_suggestions": {
|
|
||||||
"auto_retry": True,
|
|
||||||
"retry_batch_size": 10,
|
|
||||||
"exponential_backoff": True
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -1277,54 +1257,38 @@ All servers connect to access switches in pairs.
|
|||||||
"node_type": "cisco_iosv",
|
"node_type": "cisco_iosv",
|
||||||
"count": 4,
|
"count": 4,
|
||||||
"name_pattern": "Spine-R{{ id }}",
|
"name_pattern": "Spine-R{{ id }}",
|
||||||
"properties": {
|
"properties": {"ram": 4096, "cpus": 2, "adapters": 8},
|
||||||
"ram": 4096,
|
"position": {"strategy": "hierarchical", "y": 100, "x_spacing": 600},
|
||||||
"cpus": 2,
|
|
||||||
"adapters": 8
|
|
||||||
},
|
|
||||||
"position": {
|
|
||||||
"strategy": "hierarchical",
|
|
||||||
"y": 100,
|
|
||||||
"x_spacing": 600
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "leaf",
|
"name": "leaf",
|
||||||
"node_type": "cisco_iosv_l2",
|
"node_type": "cisco_iosv_l2",
|
||||||
"count": 20,
|
"count": 20,
|
||||||
"name_pattern": "Leaf-SW{{ id }}",
|
"name_pattern": "Leaf-SW{{ id }}",
|
||||||
"properties": {
|
"properties": {"ram": 2048, "cpus": 1, "adapters": 16},
|
||||||
"ram": 2048,
|
|
||||||
"cpus": 1,
|
|
||||||
"adapters": 16
|
|
||||||
},
|
|
||||||
"position": {
|
"position": {
|
||||||
"strategy": "grid",
|
"strategy": "grid",
|
||||||
"grid_rows": 4,
|
"grid_rows": 4,
|
||||||
"grid_cols": 5,
|
"grid_cols": 5,
|
||||||
"y": 400,
|
"y": 400,
|
||||||
"x_spacing": 300,
|
"x_spacing": 300,
|
||||||
"y_spacing": 200
|
"y_spacing": 200,
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "access",
|
"name": "access",
|
||||||
"node_type": "cisco_iosv_l2",
|
"node_type": "cisco_iosv_l2",
|
||||||
"count": 200,
|
"count": 200,
|
||||||
"name_pattern": "Acc-SW{{ id }}",
|
"name_pattern": "Acc-SW{{ id }}",
|
||||||
"properties": {
|
"properties": {"ram": 1024, "cpus": 1, "adapters": 4},
|
||||||
"ram": 1024,
|
|
||||||
"cpus": 1,
|
|
||||||
"adapters": 4
|
|
||||||
},
|
|
||||||
"position": {
|
"position": {
|
||||||
"strategy": "grid",
|
"strategy": "grid",
|
||||||
"grid_rows": 10,
|
"grid_rows": 10,
|
||||||
"grid_cols": 20,
|
"grid_cols": 20,
|
||||||
"y": 800,
|
"y": 800,
|
||||||
"x_spacing": 120,
|
"x_spacing": 120,
|
||||||
"y_spacing": 100
|
"y_spacing": 100,
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "server",
|
"name": "server",
|
||||||
@ -1338,36 +1302,19 @@ All servers connect to access switches in pairs.
|
|||||||
"grid_cols": 25,
|
"grid_cols": 25,
|
||||||
"y": 1200,
|
"y": 1200,
|
||||||
"x_spacing": 60,
|
"x_spacing": 60,
|
||||||
"y_spacing": 60
|
"y_spacing": 60,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
],
|
],
|
||||||
"auto_link": {
|
"auto_link": {
|
||||||
"links": [
|
"links": [
|
||||||
{
|
{"from_group": "spine", "to_group": "leaf", "strategy": "mesh"},
|
||||||
"from_group": "spine",
|
{"from_group": "leaf", "to_group": "access", "strategy": "paired", "count": 10},
|
||||||
"to_group": "leaf",
|
{"from_group": "access", "to_group": "server", "strategy": "paired", "count": 2},
|
||||||
"strategy": "mesh"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"from_group": "leaf",
|
|
||||||
"to_group": "access",
|
|
||||||
"strategy": "paired",
|
|
||||||
"count": 10
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"from_group": "access",
|
|
||||||
"to_group": "server",
|
|
||||||
"strategy": "paired",
|
|
||||||
"count": 2
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"layout": "auto_spine_leaf",
|
"layout": "auto_spine_leaf",
|
||||||
"resource_limits": {
|
"resource_limits": {"max_ram_mb": 750000, "max_vcpus": 724},
|
||||||
"max_ram_mb": 750000,
|
|
||||||
"max_vcpus": 724
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Execution Result
|
# Execution Result
|
||||||
@ -1381,8 +1328,8 @@ All servers connect to access switches in pairs.
|
|||||||
{"name": "spine", "created": 4, "failed": 0},
|
{"name": "spine", "created": 4, "failed": 0},
|
||||||
{"name": "leaf", "created": 20, "failed": 0},
|
{"name": "leaf", "created": 20, "failed": 0},
|
||||||
{"name": "access", "created": 200, "failed": 0},
|
{"name": "access", "created": 200, "failed": 0},
|
||||||
{"name": "server", "created": 500, "failed": 0}
|
{"name": "server", "created": 500, "failed": 0},
|
||||||
]
|
],
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -1522,14 +1469,14 @@ class LinkPattern(BaseModel):
|
|||||||
"""Pattern for creating links between node groups."""
|
"""Pattern for creating links between node groups."""
|
||||||
|
|
||||||
from_nodes: NodeSelector # Source nodes
|
from_nodes: NodeSelector # Source nodes
|
||||||
to_nodes: NodeSelector # Destination nodes
|
to_nodes: NodeSelector # Destination nodes
|
||||||
|
|
||||||
strategy: Literal[
|
strategy: Literal[
|
||||||
"one_to_one", # 1:1 pairing
|
"one_to_one", # 1:1 pairing
|
||||||
"one_to_many", # Star topology
|
"one_to_many", # Star topology
|
||||||
"many_to_many", # Full mesh
|
"many_to_many", # Full mesh
|
||||||
"sequential", # Linear chain
|
"sequential", # Linear chain
|
||||||
"ring" # Ring topology
|
"ring", # Ring topology
|
||||||
]
|
]
|
||||||
|
|
||||||
port_allocation: PortAllocationStrategy
|
port_allocation: PortAllocationStrategy
|
||||||
@ -1548,16 +1495,16 @@ class PortAllocationStrategy(BaseModel):
|
|||||||
"""How to allocate ports for links."""
|
"""How to allocate ports for links."""
|
||||||
|
|
||||||
strategy: Literal[
|
strategy: Literal[
|
||||||
"round_robin", # Distribute evenly
|
"round_robin", # Distribute evenly
|
||||||
"sequential", # Use in order
|
"sequential", # Use in order
|
||||||
"optimized", # Smart allocation
|
"optimized", # Smart allocation
|
||||||
"auto" # Automatic selection
|
"auto", # Automatic selection
|
||||||
]
|
]
|
||||||
|
|
||||||
on_conflict: Literal[
|
on_conflict: Literal[
|
||||||
"skip", # Skip if port unavailable
|
"skip", # Skip if port unavailable
|
||||||
"use_next", # Use next available port
|
"use_next", # Use next available port
|
||||||
"fail" # Fail on conflict
|
"fail", # Fail on conflict
|
||||||
] = "use_next"
|
] = "use_next"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@ -35,7 +35,7 @@ Add `max_nodes` field to both `User` and `UserGroup` models:
|
|||||||
# In User model
|
# In User model
|
||||||
max_nodes = Column(Integer, nullable=True) # NULL = no limit
|
max_nodes = Column(Integer, nullable=True) # NULL = no limit
|
||||||
|
|
||||||
# In UserGroup model
|
# In UserGroup model
|
||||||
max_nodes = Column(Integer, nullable=True) # NULL = no limit
|
max_nodes = Column(Integer, nullable=True) # NULL = no limit
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -49,9 +49,7 @@ Add node limit configuration to `ControllerSettings`:
|
|||||||
class NodeLimitSettings(BaseModel):
|
class NodeLimitSettings(BaseModel):
|
||||||
enabled: bool = False # Feature toggle (default disabled)
|
enabled: bool = False # Feature toggle (default disabled)
|
||||||
default_max_nodes: int = 5 # Default limit when enabled
|
default_max_nodes: int = 5 # Default limit when enabled
|
||||||
excluded_node_types: List[str] = Field(default_factory=lambda: [
|
excluded_node_types: List[str] = Field(default_factory=lambda: ["ethernet_switch", "ethernet_hub", "cloud", "nat"])
|
||||||
"ethernet_switch", "ethernet_hub", "cloud", "nat"
|
|
||||||
])
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 3. Core Service Implementation
|
#### 3. Core Service Implementation
|
||||||
@ -83,15 +81,13 @@ Add limit checking to node startup endpoint:
|
|||||||
async def start_node(
|
async def start_node(
|
||||||
node: Node = Depends(dep_node),
|
node: Node = Depends(dep_node),
|
||||||
current_user: User = Depends(get_current_active_user),
|
current_user: User = Depends(get_current_active_user),
|
||||||
node_limit_service: NodeLimitService = Depends(get_node_limit_service)
|
node_limit_service: NodeLimitService = Depends(get_node_limit_service),
|
||||||
):
|
):
|
||||||
# Node limit check
|
# Node limit check
|
||||||
can_start, error_msg = await node_limit_service.check_user_node_limit(
|
can_start, error_msg = await node_limit_service.check_user_node_limit(current_user, node.project)
|
||||||
current_user, node.project
|
|
||||||
)
|
|
||||||
if not can_start:
|
if not can_start:
|
||||||
raise HTTPException(status_code=403, detail=error_msg)
|
raise HTTPException(status_code=403, detail=error_msg)
|
||||||
|
|
||||||
# Original startup logic
|
# Original startup logic
|
||||||
await node.start()
|
await node.start()
|
||||||
```
|
```
|
||||||
|
|||||||
@ -36,6 +36,7 @@ AI_COPILOT_AVAILABLE = False
|
|||||||
try:
|
try:
|
||||||
from .gns3_copilot.project_agent_manager import get_project_agent_manager
|
from .gns3_copilot.project_agent_manager import get_project_agent_manager
|
||||||
from .gns3_copilot.project_agent_manager import ProjectAgentManager
|
from .gns3_copilot.project_agent_manager import ProjectAgentManager
|
||||||
|
|
||||||
AI_COPILOT_AVAILABLE = True
|
AI_COPILOT_AVAILABLE = True
|
||||||
|
|
||||||
# Start skills repository initialization in background.
|
# Start skills repository initialization in background.
|
||||||
@ -64,8 +65,7 @@ except ImportError as e:
|
|||||||
RuntimeError: If AI Copilot dependencies are not installed
|
RuntimeError: If AI Copilot dependencies are not installed
|
||||||
"""
|
"""
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"AI Copilot is not available. "
|
"AI Copilot is not available. Install AI dependencies with: pip install gns3-server[ai-features]"
|
||||||
"Install AI dependencies with: pip install gns3-server[ai-features]"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
class ProjectAgentManager:
|
class ProjectAgentManager:
|
||||||
@ -75,8 +75,7 @@ except ImportError as e:
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"AI Copilot is not available. "
|
"AI Copilot is not available. Install AI dependencies with: pip install gns3-server[ai-features]"
|
||||||
"Install AI dependencies with: pip install gns3-server[ai-features]"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -88,6 +87,7 @@ try:
|
|||||||
# Use importlib so the top-level SDK name "mcp" is not bound in this
|
# Use importlib so the top-level SDK name "mcp" is not bound in this
|
||||||
# namespace — it would shadow the gns3server.agent.mcp subpackage.
|
# namespace — it would shadow the gns3server.agent.mcp subpackage.
|
||||||
import importlib
|
import importlib
|
||||||
|
|
||||||
importlib.import_module("mcp.server.fastmcp")
|
importlib.import_module("mcp.server.fastmcp")
|
||||||
MCP_AVAILABLE = True
|
MCP_AVAILABLE = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
|
|||||||
@ -67,9 +67,12 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
# Initialize tiktoken encoding (required dependency)
|
# Initialize tiktoken encoding (required dependency)
|
||||||
import time
|
import time
|
||||||
|
|
||||||
logger.debug("Initializing tiktoken encoding (cl100k_base)...")
|
logger.debug("Initializing tiktoken encoding (cl100k_base)...")
|
||||||
logger.debug(f"Cache directory: {_cache_dir}")
|
logger.debug(f"Cache directory: {_cache_dir}")
|
||||||
logger.debug("This may take a moment on first run (downloading ~1.6MB encoding file from openaipublic.blob.core.windows.net)")
|
logger.debug(
|
||||||
|
"This may take a moment on first run (downloading ~1.6MB encoding file from openaipublic.blob.core.windows.net)"
|
||||||
|
)
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
_tiktoken_encoding = tiktoken.get_encoding("cl100k_base")
|
_tiktoken_encoding = tiktoken.get_encoding("cl100k_base")
|
||||||
elapsed = time.time() - start_time
|
elapsed = time.time() - start_time
|
||||||
@ -149,21 +152,16 @@ def estimate_tool_tokens(tools: list[Any]) -> int:
|
|||||||
if hasattr(tool, "args_schema") and tool.args_schema:
|
if hasattr(tool, "args_schema") and tool.args_schema:
|
||||||
try:
|
try:
|
||||||
# Try Pydantic v2 method (model_json_schema)
|
# Try Pydantic v2 method (model_json_schema)
|
||||||
tool_schema["function"]["parameters"] = (
|
tool_schema["function"]["parameters"] = tool.args_schema.model_json_schema()
|
||||||
tool.args_schema.model_json_schema()
|
|
||||||
)
|
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
# Fallback to Pydantic v1 method (schema)
|
# Fallback to Pydantic v1 method (schema)
|
||||||
try:
|
try:
|
||||||
tool_schema["function"]["parameters"] = (
|
tool_schema["function"]["parameters"] = tool.args_schema.schema()
|
||||||
tool.args_schema.schema()
|
|
||||||
)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
# Both methods failed, use empty schema
|
# Both methods failed, use empty schema
|
||||||
tool_name = getattr(tool, "name", "unknown")
|
tool_name = getattr(tool, "name", "unknown")
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Failed to get schema for tool %s, using empty "
|
"Failed to get schema for tool %s, using empty parameters",
|
||||||
"parameters",
|
|
||||||
tool_name,
|
tool_name,
|
||||||
)
|
)
|
||||||
tool_schema["function"]["parameters"] = {}
|
tool_schema["function"]["parameters"] = {}
|
||||||
@ -171,15 +169,12 @@ def estimate_tool_tokens(tools: list[Any]) -> int:
|
|||||||
# model_json_schema() raised an exception
|
# model_json_schema() raised an exception
|
||||||
tool_name = getattr(tool, "name", "unknown")
|
tool_name = getattr(tool, "name", "unknown")
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"model_json_schema() failed for tool %s: %s, "
|
"model_json_schema() failed for tool %s: %s, trying v1 fallback",
|
||||||
"trying v1 fallback",
|
|
||||||
tool_name,
|
tool_name,
|
||||||
e,
|
e,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
tool_schema["function"]["parameters"] = (
|
tool_schema["function"]["parameters"] = tool.args_schema.schema()
|
||||||
tool.args_schema.schema()
|
|
||||||
)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
tool_schema["function"]["parameters"] = {}
|
tool_schema["function"]["parameters"] = {}
|
||||||
|
|
||||||
@ -331,14 +326,11 @@ def create_pre_model_hook(
|
|||||||
|
|
||||||
if not llm_config:
|
if not llm_config:
|
||||||
logger.error("LLM config not found. context_limit is required.")
|
logger.error("LLM config not found. context_limit is required.")
|
||||||
raise ValueError(
|
raise ValueError("LLM config not found. context_limit is required.")
|
||||||
"LLM config not found. context_limit is required."
|
|
||||||
)
|
|
||||||
|
|
||||||
if "context_limit" not in llm_config:
|
if "context_limit" not in llm_config:
|
||||||
logger.error(
|
logger.error(
|
||||||
"context_limit not found in LLM config. "
|
"context_limit not found in LLM config. This is a required field. Please configure context_limit."
|
||||||
"This is a required field. Please configure context_limit."
|
|
||||||
)
|
)
|
||||||
raise ValueError("context_limit is required in LLM config")
|
raise ValueError("context_limit is required in LLM config")
|
||||||
|
|
||||||
@ -385,9 +377,7 @@ def create_pre_model_hook(
|
|||||||
system_tokens = _count_tokens_for_message(system_message)
|
system_tokens = _count_tokens_for_message(system_message)
|
||||||
|
|
||||||
# Calculate tokens for messages_with_system (including system)
|
# Calculate tokens for messages_with_system (including system)
|
||||||
messages_with_system_tokens = sum(
|
messages_with_system_tokens = sum(_count_tokens_for_message(m) for m in messages_with_system)
|
||||||
_count_tokens_for_message(m) for m in messages_with_system
|
|
||||||
)
|
|
||||||
|
|
||||||
# Calculate available budget
|
# Calculate available budget
|
||||||
model_limit_tokens = context_limit_k * TOKENS_PER_K
|
model_limit_tokens = context_limit_k * TOKENS_PER_K
|
||||||
@ -449,14 +439,11 @@ def create_pre_model_hook(
|
|||||||
|
|
||||||
# Calculate final token counts
|
# Calculate final token counts
|
||||||
final_total = sum(_count_tokens_for_message(m) for m in trimmed)
|
final_total = sum(_count_tokens_for_message(m) for m in trimmed)
|
||||||
usage_percent = (
|
usage_percent = (final_total + tool_tokens) / model_limit_tokens * 100
|
||||||
(final_total + tool_tokens) / model_limit_tokens * 100
|
|
||||||
)
|
|
||||||
|
|
||||||
if len(trimmed) < len(messages_with_system):
|
if len(trimmed) < len(messages_with_system):
|
||||||
logger.info(
|
logger.info(
|
||||||
"Messages trimmed: %d → %d msgs. Total: ~%d tokens + %d "
|
"Messages trimmed: %d → %d msgs. Total: ~%d tokens + %d tools = %d / %dK (%.1f%%), strategy=%s",
|
||||||
"tools = %d / %dK (%.1f%%), strategy=%s",
|
|
||||||
len(messages_with_system),
|
len(messages_with_system),
|
||||||
len(trimmed),
|
len(trimmed),
|
||||||
final_total,
|
final_total,
|
||||||
@ -468,8 +455,7 @@ def create_pre_model_hook(
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Context ready: %d msgs, ~%d tokens + %d tools = %d / %dK "
|
"Context ready: %d msgs, ~%d tokens + %d tools = %d / %dK (%.1f%%), strategy=%s",
|
||||||
"(%.1f%%), strategy=%s",
|
|
||||||
len(trimmed),
|
len(trimmed),
|
||||||
final_total,
|
final_total,
|
||||||
tool_tokens,
|
tool_tokens,
|
||||||
@ -520,33 +506,23 @@ def _inject_topology_into_system(
|
|||||||
|
|
||||||
if topology_data:
|
if topology_data:
|
||||||
topology_str = str(topology_data)
|
topology_str = str(topology_data)
|
||||||
formatted_prompt = system_prompt.replace(
|
formatted_prompt = system_prompt.replace("{{topology_info}}", f"\n\n## Current Topology\n{topology_str}")
|
||||||
"{{topology_info}}", f"\n\n## Current Topology\n{topology_str}"
|
|
||||||
)
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"✓ Topology injected: %d chars, nodes: %s",
|
"✓ Topology injected: %d chars, nodes: %s",
|
||||||
len(topology_str),
|
len(topology_str),
|
||||||
list(topology_data.get("nodes", {}).keys())[:5],
|
list(topology_data.get("nodes", {}).keys())[:5],
|
||||||
) # Show first 5 node names
|
) # Show first 5 node names
|
||||||
logger.debug(
|
logger.debug("Full topology data: %s", topology_str[:500]) # First 500 chars
|
||||||
"Full topology data: %s", topology_str[:500]
|
|
||||||
) # First 500 chars
|
|
||||||
else:
|
else:
|
||||||
formatted_prompt = system_prompt.replace(
|
formatted_prompt = system_prompt.replace("{{topology_info}}", "(No topology information available)")
|
||||||
"{{topology_info}}", "(No topology information available)"
|
|
||||||
)
|
|
||||||
logger.warning("✗ Topology data is None, injecting placeholder")
|
logger.warning("✗ Topology data is None, injecting placeholder")
|
||||||
|
|
||||||
# Filter out existing SystemMessage instances
|
# Filter out existing SystemMessage instances
|
||||||
non_system_messages = [
|
non_system_messages = [m for m in messages if not isinstance(m, SystemMessage)]
|
||||||
m for m in messages if not isinstance(m, SystemMessage)
|
|
||||||
]
|
|
||||||
|
|
||||||
filtered_count = len(messages) - len(non_system_messages)
|
filtered_count = len(messages) - len(non_system_messages)
|
||||||
if filtered_count > 0:
|
if filtered_count > 0:
|
||||||
logger.debug(
|
logger.debug("Filtered out %d existing SystemMessage(s)", filtered_count)
|
||||||
"Filtered out %d existing SystemMessage(s)", filtered_count
|
|
||||||
)
|
|
||||||
|
|
||||||
return [SystemMessage(content=formatted_prompt)] + non_system_messages
|
return [SystemMessage(content=formatted_prompt)] + non_system_messages
|
||||||
|
|
||||||
@ -568,8 +544,7 @@ def prepare_context_messages(
|
|||||||
**DEPRECATED**: Use create_pre_model_hook() instead.
|
**DEPRECATED**: Use create_pre_model_hook() instead.
|
||||||
"""
|
"""
|
||||||
warnings.warn(
|
warnings.warn(
|
||||||
"prepare_context_messages() is deprecated. Use "
|
"prepare_context_messages() is deprecated. Use create_pre_model_hook() instead.",
|
||||||
"create_pre_model_hook() instead.",
|
|
||||||
DeprecationWarning,
|
DeprecationWarning,
|
||||||
stacklevel=2,
|
stacklevel=2,
|
||||||
)
|
)
|
||||||
@ -577,13 +552,9 @@ def prepare_context_messages(
|
|||||||
if "{{topology_info}}" not in system_prompt:
|
if "{{topology_info}}" not in system_prompt:
|
||||||
formatted_prompt = system_prompt
|
formatted_prompt = system_prompt
|
||||||
elif topology_context:
|
elif topology_context:
|
||||||
formatted_prompt = system_prompt.replace(
|
formatted_prompt = system_prompt.replace("{{topology_info}}", f"\n\n## Current Topology\n{topology_context}")
|
||||||
"{{topology_info}}", f"\n\n## Current Topology\n{topology_context}"
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
formatted_prompt = system_prompt.replace(
|
formatted_prompt = system_prompt.replace("{{topology_info}}", "(No topology information available)")
|
||||||
"{{topology_info}}", "(No topology information available)"
|
|
||||||
)
|
|
||||||
|
|
||||||
return [SystemMessage(content=formatted_prompt)] + state_messages
|
return [SystemMessage(content=formatted_prompt)] + state_messages
|
||||||
|
|
||||||
@ -629,9 +600,7 @@ if __name__ == "__main__":
|
|||||||
# Test invocation
|
# Test invocation
|
||||||
print("\nTest 3: Invoke pre_model_hook")
|
print("\nTest 3: Invoke pre_model_hook")
|
||||||
test_state = {
|
test_state = {
|
||||||
"messages": [
|
"messages": [HumanMessage(f"Message {i}: {'x' * 50}") for i in range(5)],
|
||||||
HumanMessage(f"Message {i}: {'x' * 50}") for i in range(5)
|
|
||||||
],
|
|
||||||
"topology_info": {"project_id": "test123", "nodes": 3},
|
"topology_info": {"project_id": "test123", "nodes": 3},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -311,8 +311,7 @@ def llm_call(state: dict, config: RunnableConfig | None = None):
|
|||||||
if topology and "error" not in topology:
|
if topology and "error" not in topology:
|
||||||
topology_info = topology
|
topology_info = topology
|
||||||
logger.info(
|
logger.info(
|
||||||
"Successfully retrieved topology for project_id: %s, "
|
"Successfully retrieved topology for project_id: %s, name: %s",
|
||||||
"name: %s",
|
|
||||||
project_id,
|
project_id,
|
||||||
topology.get("name"),
|
topology.get("name"),
|
||||||
)
|
)
|
||||||
@ -320,9 +319,7 @@ def llm_call(state: dict, config: RunnableConfig | None = None):
|
|||||||
logger.warning(
|
logger.warning(
|
||||||
"Failed to retrieve topology for project_id %s: %s",
|
"Failed to retrieve topology for project_id %s: %s",
|
||||||
project_id,
|
project_id,
|
||||||
topology.get("error", "Unknown error")
|
topology.get("error", "Unknown error") if topology else "No result",
|
||||||
if topology
|
|
||||||
else "No result",
|
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@ -338,20 +335,13 @@ def llm_call(state: dict, config: RunnableConfig | None = None):
|
|||||||
copilot_mode = llm_config.get("copilot_mode", "teaching_assistant").lower()
|
copilot_mode = llm_config.get("copilot_mode", "teaching_assistant").lower()
|
||||||
if copilot_mode == "lab_automation_assistant":
|
if copilot_mode == "lab_automation_assistant":
|
||||||
mode_tools = LAB_AUTOMATION_ASSISTANT_MODE_TOOLS
|
mode_tools = LAB_AUTOMATION_ASSISTANT_MODE_TOOLS
|
||||||
logger.info(
|
logger.info("Using LAB_AUTOMATION_ASSISTANT mode tools (includes configuration tools)")
|
||||||
"Using LAB_AUTOMATION_ASSISTANT mode tools (includes "
|
|
||||||
"configuration tools)"
|
|
||||||
)
|
|
||||||
elif copilot_mode == "troubleshooting_injection":
|
elif copilot_mode == "troubleshooting_injection":
|
||||||
mode_tools = TROUBLESHOOTING_INJECTION_MODE_TOOLS
|
mode_tools = TROUBLESHOOTING_INJECTION_MODE_TOOLS
|
||||||
logger.info(
|
logger.info("Using TROUBLESHOOTING_INJECTION mode tools (fault injection)")
|
||||||
"Using TROUBLESHOOTING_INJECTION mode tools (fault injection)"
|
|
||||||
)
|
|
||||||
else: # teaching_assistant mode (default)
|
else: # teaching_assistant mode (default)
|
||||||
mode_tools = TEACHING_ASSISTANT_MODE_TOOLS
|
mode_tools = TEACHING_ASSISTANT_MODE_TOOLS
|
||||||
logger.info(
|
logger.info("Using TEACHING_ASSISTANT mode tools (diagnostic tools only)")
|
||||||
"Using TEACHING_ASSISTANT mode tools (diagnostic tools only)"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create pre_model_hook for automatic topology injection and trimming
|
# Create pre_model_hook for automatic topology injection and trimming
|
||||||
# Load system prompt based on copilot_mode configuration
|
# Load system prompt based on copilot_mode configuration
|
||||||
@ -372,22 +362,16 @@ def llm_call(state: dict, config: RunnableConfig | None = None):
|
|||||||
copilot_mode,
|
copilot_mode,
|
||||||
len(mode_tools),
|
len(mode_tools),
|
||||||
)
|
)
|
||||||
model_with_tools = create_base_model_with_tools(
|
model_with_tools = create_base_model_with_tools(mode_tools, llm_config=llm_config)
|
||||||
mode_tools, llm_config=llm_config
|
|
||||||
)
|
|
||||||
|
|
||||||
# Call pre_hook directly to prepare messages (topology injection +
|
# Call pre_hook directly to prepare messages (topology injection +
|
||||||
# trimming)
|
# trimming)
|
||||||
# Note: LangGraph's pre_model_hook only works with prebuilt agents, not
|
# Note: LangGraph's pre_model_hook only works with prebuilt agents, not
|
||||||
# custom StateGraph
|
# custom StateGraph
|
||||||
logger.debug("Calling pre_hook to prepare %d messages", len(messages))
|
logger.debug("Calling pre_hook to prepare %d messages", len(messages))
|
||||||
prepared_state = pre_hook(
|
prepared_state = pre_hook({"messages": messages, "topology_info": topology_info})
|
||||||
{"messages": messages, "topology_info": topology_info}
|
|
||||||
)
|
|
||||||
prepared_messages = prepared_state["messages"]
|
prepared_messages = prepared_state["messages"]
|
||||||
logger.info(
|
logger.info("Messages prepared: %d → %d", len(messages), len(prepared_messages))
|
||||||
"Messages prepared: %d → %d", len(messages), len(prepared_messages)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Invoke model with prepared messages
|
# Invoke model with prepared messages
|
||||||
response = model_with_tools.invoke(prepared_messages)
|
response = model_with_tools.invoke(prepared_messages)
|
||||||
@ -421,9 +405,7 @@ def llm_call(state: dict, config: RunnableConfig | None = None):
|
|||||||
|
|
||||||
|
|
||||||
# Define generate title node
|
# Define generate title node
|
||||||
def generate_title(
|
def generate_title(state: MessagesState, config: RunnableConfig | None = None) -> dict:
|
||||||
state: MessagesState, config: RunnableConfig | None = None
|
|
||||||
) -> dict:
|
|
||||||
"""
|
"""
|
||||||
Generate a conversation title using a lightweight assistant LLM
|
Generate a conversation title using a lightweight assistant LLM
|
||||||
(title_model). This node is only executed when no title has been set yet
|
(title_model). This node is only executed when no title has been set yet
|
||||||
@ -469,27 +451,21 @@ def generate_title(
|
|||||||
|
|
||||||
# Validate the generated title
|
# Validate the generated title
|
||||||
if not new_title or len(new_title) < 3:
|
if not new_title or len(new_title) < 3:
|
||||||
raise ValueError(
|
raise ValueError(f"Generated title too short or empty: '{new_title}'")
|
||||||
f"Generated title too short or empty: '{new_title}'"
|
|
||||||
)
|
|
||||||
|
|
||||||
if new_title in [
|
if new_title in [
|
||||||
"New Conversation",
|
"New Conversation",
|
||||||
"Untitled Session",
|
"Untitled Session",
|
||||||
"GNS3 Session",
|
"GNS3 Session",
|
||||||
]:
|
]:
|
||||||
raise ValueError(
|
raise ValueError(f"Generated title is a default value: '{new_title}'")
|
||||||
f"Generated title is a default value: '{new_title}'"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Safety: truncate long titles and avoid line breaks
|
# Safety: truncate long titles and avoid line breaks
|
||||||
if len(new_title) > TITLE_MAX_LENGTH:
|
if len(new_title) > TITLE_MAX_LENGTH:
|
||||||
new_title = new_title[: TITLE_MAX_LENGTH - 2] + "..."
|
new_title = new_title[: TITLE_MAX_LENGTH - 2] + "..."
|
||||||
|
|
||||||
# Remove unwanted characters
|
# Remove unwanted characters
|
||||||
new_title = (
|
new_title = new_title.replace("\n", " ").replace('"', "").replace("'", "")
|
||||||
new_title.replace("\n", " ").replace('"', "").replace("'", "")
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.debug("Generated new title: %s", new_title)
|
logger.debug("Generated new title: %s", new_title)
|
||||||
return {"conversation_title": new_title, "session_id": state.get("session_id")}
|
return {"conversation_title": new_title, "session_id": state.get("session_id")}
|
||||||
@ -540,9 +516,7 @@ def tool_node(state: dict, config: RunnableConfig | None = None):
|
|||||||
result = []
|
result = []
|
||||||
for tool_call in tool_calls:
|
for tool_call in tool_calls:
|
||||||
tool_name = tool_call["name"]
|
tool_name = tool_call["name"]
|
||||||
logger.debug(
|
logger.debug("Executing tool: %s with args: %s", tool_name, tool_call["args"])
|
||||||
"Executing tool: %s with args: %s", tool_name, tool_call["args"]
|
|
||||||
)
|
|
||||||
tool = tools_by_name[tool_name]
|
tool = tools_by_name[tool_name]
|
||||||
try:
|
try:
|
||||||
observation = tool.invoke(tool_call["args"])
|
observation = tool.invoke(tool_call["args"])
|
||||||
@ -590,11 +564,13 @@ def abort_handler_node(state: dict) -> dict:
|
|||||||
result = []
|
result = []
|
||||||
for tool_call in last_message.tool_calls:
|
for tool_call in last_message.tool_calls:
|
||||||
tool_msg = ToolMessage(
|
tool_msg = ToolMessage(
|
||||||
content=json.dumps({
|
content=json.dumps(
|
||||||
"status": "aborted",
|
{
|
||||||
"message": "Tool execution was aborted by user",
|
"status": "aborted",
|
||||||
"tool_call_id": tool_call["id"],
|
"message": "Tool execution was aborted by user",
|
||||||
}),
|
"tool_call_id": tool_call["id"],
|
||||||
|
}
|
||||||
|
),
|
||||||
tool_call_id=tool_call["id"],
|
tool_call_id=tool_call["id"],
|
||||||
name=tool_call["name"],
|
name=tool_call["name"],
|
||||||
metadata={"created_at": datetime.utcnow().isoformat(), "aborted": True},
|
metadata={"created_at": datetime.utcnow().isoformat(), "aborted": True},
|
||||||
|
|||||||
@ -94,8 +94,7 @@ def create_base_model(
|
|||||||
|
|
||||||
# Log the loaded configuration (mask sensitive data)
|
# Log the loaded configuration (mask sensitive data)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Creating base model: name=%s, provider=%s, base_url=%s, "
|
"Creating base model: name=%s, provider=%s, base_url=%s, temperature=%s",
|
||||||
"temperature=%s",
|
|
||||||
config_vars["model_name"],
|
config_vars["model_name"],
|
||||||
config_vars["model_provider"],
|
config_vars["model_provider"],
|
||||||
config_vars["base_url"] if config_vars["base_url"] else "default",
|
config_vars["base_url"] if config_vars["base_url"] else "default",
|
||||||
@ -163,8 +162,7 @@ def create_title_model(
|
|||||||
config_vars = _load_llm_config(llm_config)
|
config_vars = _load_llm_config(llm_config)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Creating title model: name=%s, provider=%s, base_url=%s, "
|
"Creating title model: name=%s, provider=%s, base_url=%s, temperature=1.0",
|
||||||
"temperature=1.0",
|
|
||||||
config_vars["model_name"],
|
config_vars["model_name"],
|
||||||
config_vars["model_provider"],
|
config_vars["model_provider"],
|
||||||
config_vars["base_url"] if config_vars["base_url"] else "default",
|
config_vars["base_url"] if config_vars["base_url"] else "default",
|
||||||
|
|||||||
@ -123,9 +123,7 @@ class AgentService:
|
|||||||
return self._checkpointer
|
return self._checkpointer
|
||||||
|
|
||||||
checkpoint_dir = self._get_checkpoint_dir()
|
checkpoint_dir = self._get_checkpoint_dir()
|
||||||
checkpointer_path = os.path.join(
|
checkpointer_path = os.path.join(checkpoint_dir, "copilot_checkpoints.db")
|
||||||
checkpoint_dir, "copilot_checkpoints.db"
|
|
||||||
)
|
|
||||||
|
|
||||||
log.debug("Creating checkpointer at: %s", checkpointer_path)
|
log.debug("Creating checkpointer at: %s", checkpointer_path)
|
||||||
|
|
||||||
@ -135,17 +133,13 @@ class AgentService:
|
|||||||
await self._checkpointer_conn.close()
|
await self._checkpointer_conn.close()
|
||||||
log.debug("Closed previous checkpointer connection")
|
log.debug("Closed previous checkpointer connection")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.warning(
|
log.warning("Error closing old checkpointer connection: %s", e)
|
||||||
"Error closing old checkpointer connection: %s", e
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create new connection
|
# Create new connection
|
||||||
conn = await aiosqlite.connect(checkpointer_path)
|
conn = await aiosqlite.connect(checkpointer_path)
|
||||||
# Enable WAL mode for better concurrent performance
|
# Enable WAL mode for better concurrent performance
|
||||||
await conn.execute("PRAGMA journal_mode=WAL;")
|
await conn.execute("PRAGMA journal_mode=WAL;")
|
||||||
self._checkpointer_conn = (
|
self._checkpointer_conn = conn # Save connection reference to prevent GC
|
||||||
conn # Save connection reference to prevent GC
|
|
||||||
)
|
|
||||||
self._checkpointer = AsyncSqliteSaver(conn)
|
self._checkpointer = AsyncSqliteSaver(conn)
|
||||||
|
|
||||||
# CRITICAL: Initialize database schema
|
# CRITICAL: Initialize database schema
|
||||||
@ -197,14 +191,8 @@ class AgentService:
|
|||||||
""")
|
""")
|
||||||
|
|
||||||
# Create indexes
|
# Create indexes
|
||||||
await conn.execute(
|
await conn.execute("CREATE INDEX IF NOT EXISTS idx_thread_id ON chat_sessions(thread_id)")
|
||||||
"CREATE INDEX IF NOT EXISTS idx_thread_id ON "
|
await conn.execute("CREATE INDEX IF NOT EXISTS idx_user_project ON chat_sessions(user_id, project_id)")
|
||||||
"chat_sessions(thread_id)"
|
|
||||||
)
|
|
||||||
await conn.execute(
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_user_project ON "
|
|
||||||
"chat_sessions(user_id, project_id)"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check if pinned column exists, add it if not (migration for existing
|
# Check if pinned column exists, add it if not (migration for existing
|
||||||
# databases)
|
# databases)
|
||||||
@ -214,16 +202,12 @@ class AgentService:
|
|||||||
|
|
||||||
if "pinned" not in column_names:
|
if "pinned" not in column_names:
|
||||||
log.debug("Adding pinned column to existing chat_sessions table")
|
log.debug("Adding pinned column to existing chat_sessions table")
|
||||||
await conn.execute(
|
await conn.execute("ALTER TABLE chat_sessions ADD COLUMN pinned BOOLEAN DEFAULT FALSE")
|
||||||
"ALTER TABLE chat_sessions ADD COLUMN pinned BOOLEAN DEFAULT "
|
|
||||||
"FALSE"
|
|
||||||
)
|
|
||||||
await conn.commit()
|
await conn.commit()
|
||||||
|
|
||||||
# Create pinned index (after column is guaranteed to exist)
|
# Create pinned index (after column is guaranteed to exist)
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
"CREATE INDEX IF NOT EXISTS idx_pinned_updated ON "
|
"CREATE INDEX IF NOT EXISTS idx_pinned_updated ON chat_sessions(pinned DESC, updated_at DESC)"
|
||||||
"chat_sessions(pinned DESC, updated_at DESC)"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
await conn.commit()
|
await conn.commit()
|
||||||
@ -234,9 +218,7 @@ class AgentService:
|
|||||||
if self._graph is None:
|
if self._graph is None:
|
||||||
checkpointer = await self._get_checkpointer()
|
checkpointer = await self._get_checkpointer()
|
||||||
self._graph = agent_builder.compile(checkpointer=checkpointer)
|
self._graph = agent_builder.compile(checkpointer=checkpointer)
|
||||||
log.info(
|
log.info("LangGraph agent compiled for project: %s", self.project_path)
|
||||||
"LangGraph agent compiled for project: %s", self.project_path
|
|
||||||
)
|
|
||||||
return self._graph
|
return self._graph
|
||||||
|
|
||||||
async def stream_chat(
|
async def stream_chat(
|
||||||
@ -266,8 +248,7 @@ class AgentService:
|
|||||||
Dict containing SSE-compatible response chunks
|
Dict containing SSE-compatible response chunks
|
||||||
"""
|
"""
|
||||||
log.info(
|
log.info(
|
||||||
"Stream chat started: project_id=%s, user_id=%s, session_id=%s, "
|
"Stream chat started: project_id=%s, user_id=%s, session_id=%s, mode=%s",
|
||||||
"mode=%s",
|
|
||||||
project_id,
|
project_id,
|
||||||
user_id,
|
user_id,
|
||||||
session_id,
|
session_id,
|
||||||
@ -293,11 +274,7 @@ class AgentService:
|
|||||||
title="New Conversation",
|
title="New Conversation",
|
||||||
copilot_mode=copilot_mode,
|
copilot_mode=copilot_mode,
|
||||||
)
|
)
|
||||||
log.debug(
|
log.debug("Created new chat session: thread_id=%s, copilot_mode=%s", session_id, copilot_mode)
|
||||||
"Created new chat session: thread_id=%s, copilot_mode=%s",
|
|
||||||
session_id,
|
|
||||||
copilot_mode
|
|
||||||
)
|
|
||||||
|
|
||||||
# Set request-scoped context variables (memory-only, not persisted)
|
# Set request-scoped context variables (memory-only, not persisted)
|
||||||
if jwt_token:
|
if jwt_token:
|
||||||
@ -387,39 +364,25 @@ class AgentService:
|
|||||||
|
|
||||||
# Stream events
|
# Stream events
|
||||||
try:
|
try:
|
||||||
async for event in graph.astream_events(
|
async for event in graph.astream_events(inputs, config=config, version="v2"):
|
||||||
inputs, config=config, version="v2"
|
|
||||||
):
|
|
||||||
event_type = event.get("event", "")
|
event_type = event.get("event", "")
|
||||||
data = event.get("data", {})
|
data = event.get("data", {})
|
||||||
|
|
||||||
# Track LLM calls and tokens
|
# Track LLM calls and tokens
|
||||||
if event_type == "on_chat_model_start":
|
if event_type == "on_chat_model_start":
|
||||||
# Filter out title_generator_node from statistics
|
# Filter out title_generator_node from statistics
|
||||||
langgraph_node = event.get("metadata", {}).get(
|
langgraph_node = event.get("metadata", {}).get("langgraph_node", "")
|
||||||
"langgraph_node", ""
|
|
||||||
)
|
|
||||||
if langgraph_node != "title_generator_node":
|
if langgraph_node != "title_generator_node":
|
||||||
llm_calls_count += 1
|
llm_calls_count += 1
|
||||||
log.debug(
|
log.debug("LLM call started, count=%d", llm_calls_count)
|
||||||
"LLM call started, count=%d", llm_calls_count
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
log.debug(
|
log.debug("Skipping LLM call count for internal node: title_generator_node")
|
||||||
"Skipping LLM call count for internal node: "
|
|
||||||
"title_generator_node"
|
|
||||||
)
|
|
||||||
|
|
||||||
elif event_type == "on_chat_model_end":
|
elif event_type == "on_chat_model_end":
|
||||||
# Filter out title_generator_node from token counting
|
# Filter out title_generator_node from token counting
|
||||||
langgraph_node = event.get("metadata", {}).get(
|
langgraph_node = event.get("metadata", {}).get("langgraph_node", "")
|
||||||
"langgraph_node", ""
|
|
||||||
)
|
|
||||||
if langgraph_node == "title_generator_node":
|
if langgraph_node == "title_generator_node":
|
||||||
log.debug(
|
log.debug("Skipping token counting for internal node: title_generator_node")
|
||||||
"Skipping token counting for internal node: "
|
|
||||||
"title_generator_node"
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
# Extract token usage from response metadata
|
# Extract token usage from response metadata
|
||||||
# Try multiple possible locations where token usage
|
# Try multiple possible locations where token usage
|
||||||
@ -441,12 +404,8 @@ class AgentService:
|
|||||||
if hasattr(output_msg, "usage_metadata"):
|
if hasattr(output_msg, "usage_metadata"):
|
||||||
usage = output_msg.usage_metadata
|
usage = output_msg.usage_metadata
|
||||||
if usage:
|
if usage:
|
||||||
input_tokens += usage.get(
|
input_tokens += usage.get("input_tokens", 0)
|
||||||
"input_tokens", 0
|
output_tokens += usage.get("output_tokens", 0)
|
||||||
)
|
|
||||||
output_tokens += usage.get(
|
|
||||||
"output_tokens", 0
|
|
||||||
)
|
|
||||||
token_info_found = True
|
token_info_found = True
|
||||||
|
|
||||||
# Method 3: Check data directly for token usage fields
|
# Method 3: Check data directly for token usage fields
|
||||||
@ -455,10 +414,7 @@ class AgentService:
|
|||||||
input_tokens += data.get("input_tokens", 0)
|
input_tokens += data.get("input_tokens", 0)
|
||||||
if "output_tokens" in data:
|
if "output_tokens" in data:
|
||||||
output_tokens += data.get("output_tokens", 0)
|
output_tokens += data.get("output_tokens", 0)
|
||||||
if (
|
if "input_tokens" in data or "output_tokens" in data:
|
||||||
"input_tokens" in data
|
|
||||||
or "output_tokens" in data
|
|
||||||
):
|
|
||||||
token_info_found = True
|
token_info_found = True
|
||||||
|
|
||||||
# Count AI response as one message (only once per turn)
|
# Count AI response as one message (only once per turn)
|
||||||
@ -470,9 +426,7 @@ class AgentService:
|
|||||||
elif event_type == "on_tool_end":
|
elif event_type == "on_tool_end":
|
||||||
message_count += 1 # Tool result message
|
message_count += 1 # Tool result message
|
||||||
tool_messages_counted += 1
|
tool_messages_counted += 1
|
||||||
log.debug(
|
log.debug("Tool message counted, message_count=%d", message_count)
|
||||||
"Tool message counted, message_count=%d", message_count
|
|
||||||
)
|
|
||||||
|
|
||||||
# Convert event to chunk for SSE streaming
|
# Convert event to chunk for SSE streaming
|
||||||
# Use accumulator for on_chat_model_stream events to handle
|
# Use accumulator for on_chat_model_stream events to handle
|
||||||
@ -480,16 +434,11 @@ class AgentService:
|
|||||||
|
|
||||||
# Filter out internal nodes (title_generator_node) from
|
# Filter out internal nodes (title_generator_node) from
|
||||||
# streaming to frontend
|
# streaming to frontend
|
||||||
langgraph_node = event.get("metadata", {}).get(
|
langgraph_node = event.get("metadata", {}).get("langgraph_node", "")
|
||||||
"langgraph_node", ""
|
|
||||||
)
|
|
||||||
if langgraph_node == "title_generator_node":
|
if langgraph_node == "title_generator_node":
|
||||||
# Skip all events from the title_generator_node (internal
|
# Skip all events from the title_generator_node (internal
|
||||||
# use only)
|
# use only)
|
||||||
log.debug(
|
log.debug("Skipping event from internal node: title_generator_node")
|
||||||
"Skipping event from internal node: "
|
|
||||||
"title_generator_node"
|
|
||||||
)
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if event_type == "on_chat_model_stream":
|
if event_type == "on_chat_model_stream":
|
||||||
@ -540,8 +489,7 @@ class AgentService:
|
|||||||
last_message_at=last_message_at,
|
last_message_at=last_message_at,
|
||||||
)
|
)
|
||||||
log.info(
|
log.info(
|
||||||
"Session statistics updated: thread_id=%s, messages=%d, "
|
"Session statistics updated: thread_id=%s, messages=%d, llm_calls=%d, tokens=%d+%d=%d",
|
||||||
"llm_calls=%d, tokens=%d+%d=%d",
|
|
||||||
session_id,
|
session_id,
|
||||||
message_count,
|
message_count,
|
||||||
llm_calls_count,
|
llm_calls_count,
|
||||||
@ -555,13 +503,8 @@ class AgentService:
|
|||||||
if final_state and "conversation_title" in final_state.values:
|
if final_state and "conversation_title" in final_state.values:
|
||||||
generated_title = final_state.values["conversation_title"]
|
generated_title = final_state.values["conversation_title"]
|
||||||
current_session = await repo.get_session_by_thread(session_id)
|
current_session = await repo.get_session_by_thread(session_id)
|
||||||
if (
|
if current_session and current_session.title != generated_title:
|
||||||
current_session
|
await repo.update_session(thread_id=session_id, title=generated_title)
|
||||||
and current_session.title != generated_title
|
|
||||||
):
|
|
||||||
await repo.update_session(
|
|
||||||
thread_id=session_id, title=generated_title
|
|
||||||
)
|
|
||||||
log.info(
|
log.info(
|
||||||
"Auto-generated title synced: thread_id=%s, title=%s",
|
"Auto-generated title synced: thread_id=%s, title=%s",
|
||||||
session_id,
|
session_id,
|
||||||
@ -576,9 +519,7 @@ class AgentService:
|
|||||||
"session_id": session_id,
|
"session_id": session_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
def _convert_event_to_chunk(
|
def _convert_event_to_chunk(self, event: Dict[str, Any], session_id: str) -> Optional[Dict[str, Any]]:
|
||||||
self, event: Dict[str, Any], session_id: str
|
|
||||||
) -> Optional[Dict[str, Any]]:
|
|
||||||
"""
|
"""
|
||||||
Convert LangGraph event to API response chunk.
|
Convert LangGraph event to API response chunk.
|
||||||
|
|
||||||
@ -627,9 +568,7 @@ class AgentService:
|
|||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def get_history(
|
async def get_history(self, session_id: str, limit: int = 100) -> Dict[str, Any]:
|
||||||
self, session_id: str, limit: int = 100
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
"""
|
||||||
Get conversation history for a session.
|
Get conversation history for a session.
|
||||||
|
|
||||||
@ -651,9 +590,7 @@ class AgentService:
|
|||||||
for msg in state.values["messages"][-limit:]:
|
for msg in state.values["messages"][-limit:]:
|
||||||
messages.append(self._convert_message_to_dict(msg))
|
messages.append(self._convert_message_to_dict(msg))
|
||||||
|
|
||||||
title = state.values.get(
|
title = state.values.get("conversation_title", "New Conversation")
|
||||||
"conversation_title", "New Conversation"
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"thread_id": session_id,
|
"thread_id": session_id,
|
||||||
@ -710,9 +647,7 @@ class AgentService:
|
|||||||
repo = ChatSessionsRepository(self._checkpointer_conn)
|
repo = ChatSessionsRepository(self._checkpointer_conn)
|
||||||
return await repo.delete_session(session_id)
|
return await repo.delete_session(session_id)
|
||||||
|
|
||||||
async def rename_session(
|
async def rename_session(self, session_id: str, new_title: str) -> Optional[Dict[str, Any]]:
|
||||||
self, session_id: str, new_title: str
|
|
||||||
) -> Optional[Dict[str, Any]]:
|
|
||||||
"""
|
"""
|
||||||
Rename a chat session.
|
Rename a chat session.
|
||||||
|
|
||||||
@ -727,14 +662,10 @@ class AgentService:
|
|||||||
await self._get_checkpointer()
|
await self._get_checkpointer()
|
||||||
|
|
||||||
repo = ChatSessionsRepository(self._checkpointer_conn)
|
repo = ChatSessionsRepository(self._checkpointer_conn)
|
||||||
session = await repo.update_session(
|
session = await repo.update_session(thread_id=session_id, title=new_title)
|
||||||
thread_id=session_id, title=new_title
|
|
||||||
)
|
|
||||||
return session.to_dict() if session else None
|
return session.to_dict() if session else None
|
||||||
|
|
||||||
async def pin_session(
|
async def pin_session(self, session_id: str, pinned: bool = True) -> Optional[Dict[str, Any]]:
|
||||||
self, session_id: str, pinned: bool = True
|
|
||||||
) -> Optional[Dict[str, Any]]:
|
|
||||||
"""
|
"""
|
||||||
Pin or unpin a chat session.
|
Pin or unpin a chat session.
|
||||||
|
|
||||||
@ -760,10 +691,7 @@ class AgentService:
|
|||||||
if self._checkpointer_conn:
|
if self._checkpointer_conn:
|
||||||
try:
|
try:
|
||||||
# Add timeout to prevent blocking on database close
|
# Add timeout to prevent blocking on database close
|
||||||
await asyncio.wait_for(
|
await asyncio.wait_for(self._checkpointer_conn.close(), timeout=5.0)
|
||||||
self._checkpointer_conn.close(),
|
|
||||||
timeout=5.0
|
|
||||||
)
|
|
||||||
log.debug(
|
log.debug(
|
||||||
"Checkpointer connection closed for: %s",
|
"Checkpointer connection closed for: %s",
|
||||||
self.project_path,
|
self.project_path,
|
||||||
|
|||||||
@ -156,16 +156,11 @@ class ChatSessionsRepository:
|
|||||||
await self.conn.commit()
|
await self.conn.commit()
|
||||||
|
|
||||||
session_id = cursor.lastrowid
|
session_id = cursor.lastrowid
|
||||||
log.info(
|
log.info("Created chat session: id=%s, thread_id=%s, copilot_mode=%s", session_id, thread_id, copilot_mode)
|
||||||
"Created chat session: id=%s, thread_id=%s, copilot_mode=%s",
|
|
||||||
session_id, thread_id, copilot_mode
|
|
||||||
)
|
|
||||||
|
|
||||||
return await self.get_session_by_id(session_id)
|
return await self.get_session_by_id(session_id)
|
||||||
|
|
||||||
async def get_session_by_id(
|
async def get_session_by_id(self, session_id: int) -> Optional[ChatSession]:
|
||||||
self, session_id: int
|
|
||||||
) -> Optional[ChatSession]:
|
|
||||||
"""
|
"""
|
||||||
Get a session by its database ID.
|
Get a session by its database ID.
|
||||||
|
|
||||||
@ -175,18 +170,14 @@ class ChatSessionsRepository:
|
|||||||
Returns:
|
Returns:
|
||||||
ChatSession or None
|
ChatSession or None
|
||||||
"""
|
"""
|
||||||
cursor = await self.conn.execute(
|
cursor = await self.conn.execute("SELECT * FROM chat_sessions WHERE id = ?", (session_id,))
|
||||||
"SELECT * FROM chat_sessions WHERE id = ?", (session_id,)
|
|
||||||
)
|
|
||||||
row = await cursor.fetchone()
|
row = await cursor.fetchone()
|
||||||
|
|
||||||
if row:
|
if row:
|
||||||
return self._row_to_session(row)
|
return self._row_to_session(row)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def get_session_by_thread(
|
async def get_session_by_thread(self, thread_id: str) -> Optional[ChatSession]:
|
||||||
self, thread_id: str
|
|
||||||
) -> Optional[ChatSession]:
|
|
||||||
"""
|
"""
|
||||||
Get a session by thread_id.
|
Get a session by thread_id.
|
||||||
|
|
||||||
@ -196,9 +187,7 @@ class ChatSessionsRepository:
|
|||||||
Returns:
|
Returns:
|
||||||
ChatSession or None
|
ChatSession or None
|
||||||
"""
|
"""
|
||||||
cursor = await self.conn.execute(
|
cursor = await self.conn.execute("SELECT * FROM chat_sessions WHERE thread_id = ?", (thread_id,))
|
||||||
"SELECT * FROM chat_sessions WHERE thread_id = ?", (thread_id,)
|
|
||||||
)
|
|
||||||
row = await cursor.fetchone()
|
row = await cursor.fetchone()
|
||||||
|
|
||||||
if row:
|
if row:
|
||||||
@ -318,10 +307,7 @@ class ChatSessionsRepository:
|
|||||||
params.append(now)
|
params.append(now)
|
||||||
params.append(thread_id)
|
params.append(thread_id)
|
||||||
|
|
||||||
query = (
|
query = f"UPDATE chat_sessions SET {', '.join(updates)} WHERE thread_id = ?"
|
||||||
f"UPDATE chat_sessions SET {', '.join(updates)} WHERE thread_id "
|
|
||||||
f"= ?"
|
|
||||||
)
|
|
||||||
|
|
||||||
await self.conn.execute(query, params)
|
await self.conn.execute(query, params)
|
||||||
await self.conn.commit()
|
await self.conn.commit()
|
||||||
@ -340,21 +326,15 @@ class ChatSessionsRepository:
|
|||||||
True if deleted, False if not found
|
True if deleted, False if not found
|
||||||
"""
|
"""
|
||||||
# First, delete the checkpoint data
|
# First, delete the checkpoint data
|
||||||
await self.conn.execute(
|
await self.conn.execute("DELETE FROM checkpoints WHERE thread_id = ?", (thread_id,))
|
||||||
"DELETE FROM checkpoints WHERE thread_id = ?", (thread_id,)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Then delete the session
|
# Then delete the session
|
||||||
cursor = await self.conn.execute(
|
cursor = await self.conn.execute("DELETE FROM chat_sessions WHERE thread_id = ?", (thread_id,))
|
||||||
"DELETE FROM chat_sessions WHERE thread_id = ?", (thread_id,)
|
|
||||||
)
|
|
||||||
await self.conn.commit()
|
await self.conn.commit()
|
||||||
|
|
||||||
deleted = cursor.rowcount > 0
|
deleted = cursor.rowcount > 0
|
||||||
if deleted:
|
if deleted:
|
||||||
log.info(
|
log.info("Deleted chat session and checkpoints: thread_id=%s", thread_id)
|
||||||
"Deleted chat session and checkpoints: thread_id=%s", thread_id
|
|
||||||
)
|
|
||||||
|
|
||||||
return deleted
|
return deleted
|
||||||
|
|
||||||
@ -378,13 +358,9 @@ class ChatSessionsRepository:
|
|||||||
|
|
||||||
# Delete checkpoints and sessions
|
# Delete checkpoints and sessions
|
||||||
for thread_id in thread_ids:
|
for thread_id in thread_ids:
|
||||||
await self.conn.execute(
|
await self.conn.execute("DELETE FROM checkpoints WHERE thread_id = ?", (thread_id,))
|
||||||
"DELETE FROM checkpoints WHERE thread_id = ?", (thread_id,)
|
|
||||||
)
|
|
||||||
|
|
||||||
cursor = await self.conn.execute(
|
cursor = await self.conn.execute("DELETE FROM chat_sessions WHERE project_id = ?", (project_id,))
|
||||||
"DELETE FROM chat_sessions WHERE project_id = ?", (project_id,)
|
|
||||||
)
|
|
||||||
await self.conn.commit()
|
await self.conn.commit()
|
||||||
|
|
||||||
deleted_count = cursor.rowcount
|
deleted_count = cursor.rowcount
|
||||||
@ -397,9 +373,7 @@ class ChatSessionsRepository:
|
|||||||
|
|
||||||
return deleted_count
|
return deleted_count
|
||||||
|
|
||||||
async def pin_session(
|
async def pin_session(self, thread_id: str, pinned: bool = True) -> Optional[ChatSession]:
|
||||||
self, thread_id: str, pinned: bool = True
|
|
||||||
) -> Optional[ChatSession]:
|
|
||||||
"""
|
"""
|
||||||
Pin or unpin a session.
|
Pin or unpin a session.
|
||||||
|
|
||||||
@ -412,8 +386,7 @@ class ChatSessionsRepository:
|
|||||||
"""
|
"""
|
||||||
now = datetime.utcnow().isoformat()
|
now = datetime.utcnow().isoformat()
|
||||||
await self.conn.execute(
|
await self.conn.execute(
|
||||||
"UPDATE chat_sessions SET pinned = ?, updated_at = ? WHERE "
|
"UPDATE chat_sessions SET pinned = ?, updated_at = ? WHERE thread_id = ?",
|
||||||
"thread_id = ?",
|
|
||||||
(1 if pinned else 0, now, thread_id),
|
(1 if pinned else 0, now, thread_id),
|
||||||
)
|
)
|
||||||
await self.conn.commit()
|
await self.conn.commit()
|
||||||
|
|||||||
@ -35,13 +35,10 @@ from gns3server.config import Config
|
|||||||
SKILLS_CONFIG = {
|
SKILLS_CONFIG = {
|
||||||
# Git repository URL for skills
|
# Git repository URL for skills
|
||||||
"repo_url": "https://github.com/gns3/gns3-skills.git",
|
"repo_url": "https://github.com/gns3/gns3-skills.git",
|
||||||
|
|
||||||
# Git branch to use
|
# Git branch to use
|
||||||
"branch": "main",
|
"branch": "main",
|
||||||
|
|
||||||
# Automatically pull updates on reload
|
# Automatically pull updates on reload
|
||||||
"auto_update": True,
|
"auto_update": True,
|
||||||
|
|
||||||
# Enable external skills loading
|
# Enable external skills loading
|
||||||
# If False, use the built-in hardcoded skills
|
# If False, use the built-in hardcoded skills
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
|
|||||||
@ -70,22 +70,53 @@ MAX_NODE_FILE_BYTES = 50 * 1024 # 50 KiB
|
|||||||
|
|
||||||
VALID_NODE_FIELDS = {
|
VALID_NODE_FIELDS = {
|
||||||
# NodeBase
|
# NodeBase
|
||||||
"compute_id", "name", "node_type", "node_id",
|
"compute_id",
|
||||||
"console", "console_type", "console_auto_start",
|
"name",
|
||||||
"aux", "aux_type", "properties", "label", "symbol",
|
"node_type",
|
||||||
"x", "y", "z", "locked",
|
"node_id",
|
||||||
"port_name_format", "port_segment_size", "first_port_name",
|
"console",
|
||||||
"custom_adapters", "tags",
|
"console_type",
|
||||||
|
"console_auto_start",
|
||||||
|
"aux",
|
||||||
|
"aux_type",
|
||||||
|
"properties",
|
||||||
|
"label",
|
||||||
|
"symbol",
|
||||||
|
"x",
|
||||||
|
"y",
|
||||||
|
"z",
|
||||||
|
"locked",
|
||||||
|
"port_name_format",
|
||||||
|
"port_segment_size",
|
||||||
|
"first_port_name",
|
||||||
|
"custom_adapters",
|
||||||
|
"tags",
|
||||||
# Node
|
# Node
|
||||||
"template_id", "project_id", "node_directory", "status",
|
"template_id",
|
||||||
"command_line", "width", "height", "ports", "console_host",
|
"project_id",
|
||||||
|
"node_directory",
|
||||||
|
"status",
|
||||||
|
"command_line",
|
||||||
|
"width",
|
||||||
|
"height",
|
||||||
|
"ports",
|
||||||
|
"console_host",
|
||||||
}
|
}
|
||||||
|
|
||||||
VALID_LINK_FIELDS = {
|
VALID_LINK_FIELDS = {
|
||||||
"link_id", "project_id", "link_type", "nodes", "suspend",
|
"link_id",
|
||||||
"link_style", "filters", "show_filters_icon",
|
"project_id",
|
||||||
"capturing", "capture_file_name", "capture_file_path",
|
"link_type",
|
||||||
"capture_compute_id", "wireshark",
|
"nodes",
|
||||||
|
"suspend",
|
||||||
|
"link_style",
|
||||||
|
"filters",
|
||||||
|
"show_filters_icon",
|
||||||
|
"capturing",
|
||||||
|
"capture_file_name",
|
||||||
|
"capture_file_path",
|
||||||
|
"capture_compute_id",
|
||||||
|
"wireshark",
|
||||||
}
|
}
|
||||||
|
|
||||||
LINK_DEFAULT_FIELDS = ["link_id", "link_type", "nodes"]
|
LINK_DEFAULT_FIELDS = ["link_id", "link_type", "nodes"]
|
||||||
@ -93,6 +124,7 @@ LINK_DEFAULT_FIELDS = ["link_id", "link_type", "nodes"]
|
|||||||
|
|
||||||
# ── Helpers ────────────────────────────────────────────────────────────────
|
# ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def _get_connector(gns3_ctx: dict[str, Any]):
|
def _get_connector(gns3_ctx: dict[str, Any]):
|
||||||
return Gns3Connector(
|
return Gns3Connector(
|
||||||
url=gns3_ctx["server_url"],
|
url=gns3_ctx["server_url"],
|
||||||
@ -102,9 +134,7 @@ def _get_connector(gns3_ctx: dict[str, Any]):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def build_gns3_ctx(
|
def build_gns3_ctx(jwt_token: str | None = None, url: str | None = None) -> dict[str, Any] | None:
|
||||||
jwt_token: str | None = None, url: str | None = None
|
|
||||||
) -> dict[str, Any] | None:
|
|
||||||
"""
|
"""
|
||||||
Build a handler ``gns3_ctx`` for in-process copilot callers.
|
Build a handler ``gns3_ctx`` for in-process copilot callers.
|
||||||
|
|
||||||
@ -183,13 +213,14 @@ def _normalize_link_nodes(nodes) -> list[dict[str, Any]]:
|
|||||||
]
|
]
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Unrecognized link nodes format. "
|
f"Unrecognized link nodes format. "
|
||||||
f"Use standard [{{\"node_id\":\"..\",\"adapter_number\":0,\"port_number\":0}},...] "
|
f'Use standard [{{"node_id":"..","adapter_number":0,"port_number":0}},...] '
|
||||||
f"or compact [\"id\",0,0,\"id\",0,0], got: {nodes}"
|
f'or compact ["id",0,0,"id",0,0], got: {nodes}'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ── Node handlers ──────────────────────────────────────────────────────────
|
# ── Node handlers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def get_nodes_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
def get_nodes_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||||
project_id = params.get("project_id")
|
project_id = params.get("project_id")
|
||||||
if not project_id:
|
if not project_id:
|
||||||
@ -199,7 +230,7 @@ def get_nodes_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[
|
|||||||
fields = params.get("fields")
|
fields = params.get("fields")
|
||||||
if fields:
|
if fields:
|
||||||
if not isinstance(fields, list):
|
if not isinstance(fields, list):
|
||||||
return {"error": "fields must be a list of field names, e.g. [\"name\", \"status\"]"}
|
return {"error": 'fields must be a list of field names, e.g. ["name", "status"]'}
|
||||||
invalid = [f for f in fields if f not in VALID_NODE_FIELDS]
|
invalid = [f for f in fields if f not in VALID_NODE_FIELDS]
|
||||||
if invalid:
|
if invalid:
|
||||||
return {
|
return {
|
||||||
@ -221,7 +252,7 @@ def get_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[s
|
|||||||
fields = params.get("fields")
|
fields = params.get("fields")
|
||||||
if fields:
|
if fields:
|
||||||
if not isinstance(fields, list):
|
if not isinstance(fields, list):
|
||||||
return {"error": "fields must be a list of field names, e.g. [\"name\", \"status\"]"}
|
return {"error": 'fields must be a list of field names, e.g. ["name", "status"]'}
|
||||||
invalid = [f for f in fields if f not in VALID_NODE_FIELDS]
|
invalid = [f for f in fields if f not in VALID_NODE_FIELDS]
|
||||||
if invalid:
|
if invalid:
|
||||||
return {
|
return {
|
||||||
@ -235,12 +266,14 @@ def get_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[s
|
|||||||
|
|
||||||
def _batch_lifecycle(project_id, node_ids, action, conn, action_label):
|
def _batch_lifecycle(project_id, node_ids, action, conn, action_label):
|
||||||
"""Helper to run a lifecycle action on multiple nodes in parallel."""
|
"""Helper to run a lifecycle action on multiple nodes in parallel."""
|
||||||
|
|
||||||
def _act(nid):
|
def _act(nid):
|
||||||
try:
|
try:
|
||||||
conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/{nid}/{action}")
|
conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/{nid}/{action}")
|
||||||
return {"node_id": nid, "status": "success", "message": f"Node {nid} {action_label}"}
|
return {"node_id": nid, "status": "success", "message": f"Node {nid} {action_label}"}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"node_id": nid, "status": "error", "error": str(e)}
|
return {"node_id": nid, "status": "error", "error": str(e)}
|
||||||
|
|
||||||
with ThreadPoolExecutor(max_workers=min(len(node_ids), BATCH_MAX_WORKERS)) as pool:
|
with ThreadPoolExecutor(max_workers=min(len(node_ids), BATCH_MAX_WORKERS)) as pool:
|
||||||
return list(pool.map(_act, node_ids))
|
return list(pool.map(_act, node_ids))
|
||||||
|
|
||||||
@ -307,7 +340,7 @@ def create_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic
|
|||||||
|
|
||||||
fields = params.get("fields")
|
fields = params.get("fields")
|
||||||
if fields is not None and not isinstance(fields, list):
|
if fields is not None and not isinstance(fields, list):
|
||||||
return {"error": "fields must be a list, e.g. [\"node_id\", \"name\"]"}
|
return {"error": 'fields must be a list, e.g. ["node_id", "name"]'}
|
||||||
|
|
||||||
nodes = params.get("nodes")
|
nodes = params.get("nodes")
|
||||||
# Batch mode: nodes=[{template_id?, x, y, name?, compute_id?}]
|
# Batch mode: nodes=[{template_id?, x, y, name?, compute_id?}]
|
||||||
@ -317,6 +350,7 @@ def create_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic
|
|||||||
return {"error": "nodes must be a non-empty array"}
|
return {"error": "nodes must be a non-empty array"}
|
||||||
default_tid = params.get("template_id")
|
default_tid = params.get("template_id")
|
||||||
conn = _get_connector(gns3_ctx)
|
conn = _get_connector(gns3_ctx)
|
||||||
|
|
||||||
def _create_one(node_data):
|
def _create_one(node_data):
|
||||||
tid = node_data.get("template_id", default_tid)
|
tid = node_data.get("template_id", default_tid)
|
||||||
if not tid:
|
if not tid:
|
||||||
@ -335,6 +369,7 @@ def create_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic
|
|||||||
return {"template_id": tid, "status": "success", "node": _filter_node_response(resp, fields)}
|
return {"template_id": tid, "status": "success", "node": _filter_node_response(resp, fields)}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"template_id": tid, "status": "error", "error": str(e)}
|
return {"template_id": tid, "status": "error", "error": str(e)}
|
||||||
|
|
||||||
if any(not node.get("name") for node in nodes):
|
if any(not node.get("name") for node in nodes):
|
||||||
# The controller assigns default names (R-1, R-2, ...) and console
|
# The controller assigns default names (R-1, R-2, ...) and console
|
||||||
# ports in request arrival order, and a parallel fan-out makes the
|
# ports in request arrival order, and a parallel fan-out makes the
|
||||||
@ -375,12 +410,14 @@ def delete_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic
|
|||||||
if not isinstance(node_ids, list):
|
if not isinstance(node_ids, list):
|
||||||
return {"error": "node_ids must be a list"}
|
return {"error": "node_ids must be a list"}
|
||||||
conn = _get_connector(gns3_ctx)
|
conn = _get_connector(gns3_ctx)
|
||||||
|
|
||||||
def _del(nid):
|
def _del(nid):
|
||||||
try:
|
try:
|
||||||
conn.http_call("delete", f"{conn.base_url}/projects/{project_id}/nodes/{nid}")
|
conn.http_call("delete", f"{conn.base_url}/projects/{project_id}/nodes/{nid}")
|
||||||
return {"node_id": nid, "status": "success", "message": f"Node {nid} deleted"}
|
return {"node_id": nid, "status": "success", "message": f"Node {nid} deleted"}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"node_id": nid, "status": "error", "error": str(e)}
|
return {"node_id": nid, "status": "error", "error": str(e)}
|
||||||
|
|
||||||
with ThreadPoolExecutor(max_workers=min(len(node_ids), BATCH_MAX_WORKERS)) as pool:
|
with ThreadPoolExecutor(max_workers=min(len(node_ids), BATCH_MAX_WORKERS)) as pool:
|
||||||
return list(pool.map(_del, node_ids))
|
return list(pool.map(_del, node_ids))
|
||||||
node_id = params.get("node_id")
|
node_id = params.get("node_id")
|
||||||
@ -423,12 +460,16 @@ def get_node_console_info_handler(params: dict[str, Any], gns3_ctx: dict[str, An
|
|||||||
# the ~200-char JWT previously embedded here (dropped header segment →
|
# the ~200-char JWT previously embedded here (dropped header segment →
|
||||||
# "Missing 'alg' value in header" on the server).
|
# "Missing 'alg' value in header" on the server).
|
||||||
username = gns3_ctx.get("jwt_username")
|
username = gns3_ctx.get("jwt_username")
|
||||||
ticket = access_ticket_service.mint(
|
ticket = (
|
||||||
username,
|
access_ticket_service.mint(
|
||||||
token_version=gns3_ctx.get("jwt_token_version", 0),
|
username,
|
||||||
project_id=project_id,
|
token_version=gns3_ctx.get("jwt_token_version", 0),
|
||||||
node_id=node_id,
|
project_id=project_id,
|
||||||
) if username else None
|
node_id=node_id,
|
||||||
|
)
|
||||||
|
if username
|
||||||
|
else None
|
||||||
|
)
|
||||||
raw_url = f"{gns3_ctx['server_url']}/v3/projects/{project_id}/nodes/{node_id}/console/ws"
|
raw_url = f"{gns3_ctx['server_url']}/v3/projects/{project_id}/nodes/{node_id}/console/ws"
|
||||||
if ticket:
|
if ticket:
|
||||||
raw_url += f"?token={ticket}"
|
raw_url += f"?token={ticket}"
|
||||||
@ -499,7 +540,7 @@ def get_node_file_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> d
|
|||||||
total_lines = len(lines)
|
total_lines = len(lines)
|
||||||
|
|
||||||
# Apply offset/limit
|
# Apply offset/limit
|
||||||
selected = lines[offset: offset + limit] if offset < total_lines else []
|
selected = lines[offset : offset + limit] if offset < total_lines else []
|
||||||
has_more = (offset + limit) < total_lines or truncated
|
has_more = (offset + limit) < total_lines or truncated
|
||||||
content = "".join(selected)
|
content = "".join(selected)
|
||||||
|
|
||||||
@ -581,7 +622,9 @@ def duplicate_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) ->
|
|||||||
return {"error": "project_id and node_id are required"}
|
return {"error": "project_id and node_id are required"}
|
||||||
conn = _get_connector(gns3_ctx)
|
conn = _get_connector(gns3_ctx)
|
||||||
data = {k: v for k, v in params.items() if k not in ("project_id", "node_id") and v is not None}
|
data = {k: v for k, v in params.items() if k not in ("project_id", "node_id") and v is not None}
|
||||||
result = conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/duplicate", json_data=data).json()
|
result = conn.http_call(
|
||||||
|
"post", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/duplicate", json_data=data
|
||||||
|
).json()
|
||||||
return {"message": f"Node {node_id} duplicated", "node": result}
|
return {"message": f"Node {node_id} duplicated", "node": result}
|
||||||
|
|
||||||
|
|
||||||
@ -617,6 +660,7 @@ def get_node_links_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) ->
|
|||||||
|
|
||||||
# ── Link handlers ──────────────────────────────────────────────────────────
|
# ── Link handlers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def get_links_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
def get_links_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||||
project_id = params.get("project_id")
|
project_id = params.get("project_id")
|
||||||
if not project_id:
|
if not project_id:
|
||||||
@ -626,7 +670,7 @@ def get_links_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[
|
|||||||
fields = params.get("fields")
|
fields = params.get("fields")
|
||||||
if fields:
|
if fields:
|
||||||
if not isinstance(fields, list):
|
if not isinstance(fields, list):
|
||||||
return {"error": "fields must be a list, e.g. [\"link_id\", \"nodes\"]"}
|
return {"error": 'fields must be a list, e.g. ["link_id", "nodes"]'}
|
||||||
invalid = [f for f in fields if f not in VALID_LINK_FIELDS]
|
invalid = [f for f in fields if f not in VALID_LINK_FIELDS]
|
||||||
if invalid:
|
if invalid:
|
||||||
return {
|
return {
|
||||||
@ -669,7 +713,7 @@ def create_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic
|
|||||||
|
|
||||||
fields = params.get("fields")
|
fields = params.get("fields")
|
||||||
if fields is not None and not isinstance(fields, list):
|
if fields is not None and not isinstance(fields, list):
|
||||||
return {"error": "fields must be a list, e.g. [\"link_id\", \"nodes\"]"}
|
return {"error": 'fields must be a list, e.g. ["link_id", "nodes"]'}
|
||||||
|
|
||||||
links = params.get("links")
|
links = params.get("links")
|
||||||
# Batch mode: links=[{nodes, link_type?, filters?, suspend?}]
|
# Batch mode: links=[{nodes, link_type?, filters?, suspend?}]
|
||||||
@ -677,6 +721,7 @@ def create_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic
|
|||||||
if not isinstance(links, list) or not links:
|
if not isinstance(links, list) or not links:
|
||||||
return {"error": "links must be a non-empty array"}
|
return {"error": "links must be a non-empty array"}
|
||||||
conn = _get_connector(gns3_ctx)
|
conn = _get_connector(gns3_ctx)
|
||||||
|
|
||||||
def _create_one(link_data):
|
def _create_one(link_data):
|
||||||
raw_nodes = link_data.get("nodes")
|
raw_nodes = link_data.get("nodes")
|
||||||
if not raw_nodes:
|
if not raw_nodes:
|
||||||
@ -694,6 +739,7 @@ def create_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic
|
|||||||
return {"status": "success", "link": _filter_link_response(resp, fields)}
|
return {"status": "success", "link": _filter_link_response(resp, fields)}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"status": "error", "error": str(e)}
|
return {"status": "error", "error": str(e)}
|
||||||
|
|
||||||
with ThreadPoolExecutor(max_workers=min(len(links), BATCH_MAX_WORKERS)) as pool:
|
with ThreadPoolExecutor(max_workers=min(len(links), BATCH_MAX_WORKERS)) as pool:
|
||||||
# pool.map keeps the submission order, so callers can correlate
|
# pool.map keeps the submission order, so callers can correlate
|
||||||
# results with the links they sent regardless of completion order
|
# results with the links they sent regardless of completion order
|
||||||
@ -725,12 +771,14 @@ def delete_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic
|
|||||||
if not isinstance(link_ids, list):
|
if not isinstance(link_ids, list):
|
||||||
return {"error": "link_ids must be a list"}
|
return {"error": "link_ids must be a list"}
|
||||||
conn = _get_connector(gns3_ctx)
|
conn = _get_connector(gns3_ctx)
|
||||||
|
|
||||||
def _del(lid):
|
def _del(lid):
|
||||||
try:
|
try:
|
||||||
conn.http_call("delete", f"{conn.base_url}/projects/{project_id}/links/{lid}")
|
conn.http_call("delete", f"{conn.base_url}/projects/{project_id}/links/{lid}")
|
||||||
return {"link_id": lid, "status": "success", "message": f"Link {lid} deleted"}
|
return {"link_id": lid, "status": "success", "message": f"Link {lid} deleted"}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"link_id": lid, "status": "error", "error": str(e)}
|
return {"link_id": lid, "status": "error", "error": str(e)}
|
||||||
|
|
||||||
with ThreadPoolExecutor(max_workers=min(len(link_ids), BATCH_MAX_WORKERS)) as pool:
|
with ThreadPoolExecutor(max_workers=min(len(link_ids), BATCH_MAX_WORKERS)) as pool:
|
||||||
return list(pool.map(_del, link_ids))
|
return list(pool.map(_del, link_ids))
|
||||||
link_id = params.get("link_id")
|
link_id = params.get("link_id")
|
||||||
@ -770,6 +818,7 @@ def reset_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict
|
|||||||
if not isinstance(link_ids, list):
|
if not isinstance(link_ids, list):
|
||||||
return {"error": "link_ids must be a list"}
|
return {"error": "link_ids must be a list"}
|
||||||
conn = _get_connector(gns3_ctx)
|
conn = _get_connector(gns3_ctx)
|
||||||
|
|
||||||
def _rst(lid):
|
def _rst(lid):
|
||||||
try:
|
try:
|
||||||
url = f"{conn.base_url}/projects/{project_id}/links/{lid}/reset"
|
url = f"{conn.base_url}/projects/{project_id}/links/{lid}/reset"
|
||||||
@ -777,6 +826,7 @@ def reset_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict
|
|||||||
return {"link_id": lid, "status": "reset", "link": r}
|
return {"link_id": lid, "status": "reset", "link": r}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"link_id": lid, "status": "error", "error": str(e)}
|
return {"link_id": lid, "status": "error", "error": str(e)}
|
||||||
|
|
||||||
with ThreadPoolExecutor(max_workers=min(len(link_ids), BATCH_MAX_WORKERS)) as pool:
|
with ThreadPoolExecutor(max_workers=min(len(link_ids), BATCH_MAX_WORKERS)) as pool:
|
||||||
return list(pool.map(_rst, link_ids))
|
return list(pool.map(_rst, link_ids))
|
||||||
link_id = params.get("link_id")
|
link_id = params.get("link_id")
|
||||||
@ -790,6 +840,7 @@ def reset_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict
|
|||||||
|
|
||||||
def _batch_capture(project_id, link_ids, action, data_builder, conn):
|
def _batch_capture(project_id, link_ids, action, data_builder, conn):
|
||||||
"""Helper for batch capture start/stop."""
|
"""Helper for batch capture start/stop."""
|
||||||
|
|
||||||
def _act(lid):
|
def _act(lid):
|
||||||
try:
|
try:
|
||||||
url = f"{conn.base_url}/projects/{project_id}/links/{lid}/capture/{action}"
|
url = f"{conn.base_url}/projects/{project_id}/links/{lid}/capture/{action}"
|
||||||
@ -798,6 +849,7 @@ def _batch_capture(project_id, link_ids, action, data_builder, conn):
|
|||||||
return {"link_id": lid, "status": "success"}
|
return {"link_id": lid, "status": "success"}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"link_id": lid, "status": "error", "error": str(e)}
|
return {"link_id": lid, "status": "error", "error": str(e)}
|
||||||
|
|
||||||
with ThreadPoolExecutor(max_workers=min(len(link_ids), BATCH_MAX_WORKERS)) as pool:
|
with ThreadPoolExecutor(max_workers=min(len(link_ids), BATCH_MAX_WORKERS)) as pool:
|
||||||
return list(pool.map(_act, link_ids))
|
return list(pool.map(_act, link_ids))
|
||||||
|
|
||||||
@ -814,11 +866,13 @@ def start_capture_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> d
|
|||||||
dlt = params.get("data_link_type", "DLT_EN10MB")
|
dlt = params.get("data_link_type", "DLT_EN10MB")
|
||||||
ws = params.get("wireshark", False)
|
ws = params.get("wireshark", False)
|
||||||
fname = params.get("capture_file_name")
|
fname = params.get("capture_file_name")
|
||||||
|
|
||||||
def _build(lid):
|
def _build(lid):
|
||||||
data = {"data_link_type": dlt, "wireshark": ws}
|
data = {"data_link_type": dlt, "wireshark": ws}
|
||||||
if fname:
|
if fname:
|
||||||
data["capture_file_name"] = fname
|
data["capture_file_name"] = fname
|
||||||
return {"json_data": data}
|
return {"json_data": data}
|
||||||
|
|
||||||
return _batch_capture(project_id, link_ids, "start", _build, conn)
|
return _batch_capture(project_id, link_ids, "start", _build, conn)
|
||||||
link_id = params.get("link_id")
|
link_id = params.get("link_id")
|
||||||
if not link_id:
|
if not link_id:
|
||||||
@ -879,7 +933,11 @@ def download_capture_file_handler(params: dict[str, Any], gns3_ctx: dict[str, An
|
|||||||
if not isinstance(link_ids, list):
|
if not isinstance(link_ids, list):
|
||||||
return {"error": "link_ids must be a list"}
|
return {"error": "link_ids must be a list"}
|
||||||
results = [_download(lid) for lid in link_ids]
|
results = [_download(lid) for lid in link_ids]
|
||||||
return {"downloads": results, "count": len(results), "note": "Files are in pcap format. URLs include a 10-minute ticket."}
|
return {
|
||||||
|
"downloads": results,
|
||||||
|
"count": len(results),
|
||||||
|
"note": "Files are in pcap format. URLs include a 10-minute ticket.",
|
||||||
|
}
|
||||||
|
|
||||||
link_id = params.get("link_id")
|
link_id = params.get("link_id")
|
||||||
if not link_id:
|
if not link_id:
|
||||||
@ -946,12 +1004,18 @@ def link_marker_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic
|
|||||||
elif direction in ("tx", "rx"):
|
elif direction in ("tx", "rx"):
|
||||||
body["direction"] = direction
|
body["direction"] = direction
|
||||||
if not body:
|
if not body:
|
||||||
return {"error": "At least one update field is required (bpf, tag, enabled, direction, color, highlight_duration)"}
|
return {
|
||||||
|
"error": "At least one update field is required (bpf, tag, enabled, direction, color, highlight_duration)"
|
||||||
|
}
|
||||||
return conn.http_call("put", url, json_data=body).json()
|
return conn.http_call("put", url, json_data=body).json()
|
||||||
|
|
||||||
# action == "delete"
|
# action == "delete"
|
||||||
conn.http_call("delete", url)
|
conn.http_call("delete", url)
|
||||||
return {"message": f"Marker '{marker_name}' deleted from link {link_id}", "link_id": link_id, "marker_name": marker_name}
|
return {
|
||||||
|
"message": f"Marker '{marker_name}' deleted from link {link_id}",
|
||||||
|
"link_id": link_id,
|
||||||
|
"marker_name": marker_name,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def marker_definition_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
def marker_definition_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||||
@ -1002,7 +1066,9 @@ def marker_definition_handler(params: dict[str, Any], gns3_ctx: dict[str, Any])
|
|||||||
if params.get(opt) is not None:
|
if params.get(opt) is not None:
|
||||||
body[opt] = params[opt]
|
body[opt] = params[opt]
|
||||||
if not body:
|
if not body:
|
||||||
return {"error": "At least one update field is required (bpf, tag, color, highlight_duration, data_link_type)"}
|
return {
|
||||||
|
"error": "At least one update field is required (bpf, tag, color, highlight_duration, data_link_type)"
|
||||||
|
}
|
||||||
return conn.http_call("put", url, json_data=body).json()
|
return conn.http_call("put", url, json_data=body).json()
|
||||||
|
|
||||||
# action == "delete"
|
# action == "delete"
|
||||||
|
|||||||
@ -139,17 +139,11 @@ class Gns3Connector:
|
|||||||
self.session.headers["Accept"] = "application/json" # pragma: no cover
|
self.session.headers["Accept"] = "application/json" # pragma: no cover
|
||||||
|
|
||||||
# Set authentication based on API version
|
# Set authentication based on API version
|
||||||
if (
|
if self.auth_type == "basic" and self.user is not None and self.cred is not None:
|
||||||
self.auth_type == "basic"
|
|
||||||
and self.user is not None
|
|
||||||
and self.cred is not None
|
|
||||||
):
|
|
||||||
self.session.auth = (self.user, self.cred) # pragma: no cover
|
self.session.auth = (self.user, self.cred) # pragma: no cover
|
||||||
|
|
||||||
elif self.auth_type == "jwt" and self.access_token:
|
elif self.auth_type == "jwt" and self.access_token:
|
||||||
self.session.headers["Authorization"] = (
|
self.session.headers["Authorization"] = f"Bearer {self.access_token}"
|
||||||
f"Bearer {self.access_token}"
|
|
||||||
)
|
|
||||||
|
|
||||||
def _authenticate_v3(self) -> None:
|
def _authenticate_v3(self) -> None:
|
||||||
"""
|
"""
|
||||||
@ -161,15 +155,10 @@ class Gns3Connector:
|
|||||||
return
|
return
|
||||||
|
|
||||||
if not self.user or not self.cred:
|
if not self.user or not self.cred:
|
||||||
raise ValueError(
|
raise ValueError("Username and password are required for v3 authentication when no JWT token is provided")
|
||||||
"Username and password are required for v3 authentication "
|
|
||||||
"when no JWT token is provided"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Construct authentication URL (v3 API uses different base URL)
|
# Construct authentication URL (v3 API uses different base URL)
|
||||||
auth_url = (
|
auth_url = f"{self.base_url.replace('/v3', '')}/v3/access/users/authenticate"
|
||||||
f"{self.base_url.replace('/v3', '')}/v3/access/users/authenticate"
|
|
||||||
)
|
|
||||||
auth_data = {"username": self.user, "password": self.cred}
|
auth_data = {"username": self.user, "password": self.cred}
|
||||||
|
|
||||||
# Use temporary session for authentication
|
# Use temporary session for authentication
|
||||||
@ -177,21 +166,14 @@ class Gns3Connector:
|
|||||||
temp_session.headers["Content-Type"] = "application/json"
|
temp_session.headers["Content-Type"] = "application/json"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = temp_session.post(
|
response = temp_session.post(auth_url, json=auth_data, verify=self.verify, timeout=10.0)
|
||||||
auth_url, json=auth_data, verify=self.verify, timeout=10.0
|
|
||||||
)
|
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
auth_result = response.json()
|
auth_result = response.json()
|
||||||
self.access_token = auth_result["access_token"]
|
self.access_token = auth_result["access_token"]
|
||||||
# Update session with new token
|
# Update session with new token
|
||||||
self.session.headers["Authorization"] = (
|
self.session.headers["Authorization"] = f"Bearer {self.access_token}"
|
||||||
f"Bearer {self.access_token}"
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
raise HTTPError(
|
raise HTTPError(f"v3 API authentication failed: {response.status_code} - {response.text}")
|
||||||
f"v3 API authentication failed: {response.status_code} - "
|
|
||||||
f"{response.text}"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPError(f"v3 API authentication error: {str(e)}") from e
|
raise HTTPError(f"v3 API authentication error: {str(e)}") from e
|
||||||
|
|
||||||
@ -205,9 +187,7 @@ class Gns3Connector:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Decode token without verification to check expiry
|
# Decode token without verification to check expiry
|
||||||
decoded: dict[str, Any] = jwt.decode(
|
decoded: dict[str, Any] = jwt.decode(token, options={"verify_signature": False})
|
||||||
token, options={"verify_signature": False}
|
|
||||||
)
|
|
||||||
exp = decoded.get("exp")
|
exp = decoded.get("exp")
|
||||||
if exp is not None:
|
if exp is not None:
|
||||||
return time.time() > float(exp)
|
return time.time() > float(exp)
|
||||||
@ -237,12 +217,7 @@ class Gns3Connector:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# Handle JWT authentication
|
# Handle JWT authentication
|
||||||
if (
|
if self.auth_type == "jwt" and not self.access_token and self.user and self.cred:
|
||||||
self.auth_type == "jwt"
|
|
||||||
and not self.access_token
|
|
||||||
and self.user
|
|
||||||
and self.cred
|
|
||||||
):
|
|
||||||
self._authenticate_v3()
|
self._authenticate_v3()
|
||||||
|
|
||||||
# Get request function (e.g., session.get, session.post)
|
# Get request function (e.g., session.get, session.post)
|
||||||
@ -285,15 +260,10 @@ class Gns3Connector:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Only attempt parsing when Content-Type is JSON
|
# Only attempt parsing when Content-Type is JSON
|
||||||
if (
|
if "application/json" in response.headers.get("Content-Type", "").lower():
|
||||||
"application/json"
|
|
||||||
in response.headers.get("Content-Type", "").lower()
|
|
||||||
):
|
|
||||||
error_json = response.json()
|
error_json = response.json()
|
||||||
status = error_json.get("status", "Unknown Status")
|
status = error_json.get("status", "Unknown Status")
|
||||||
message = error_json.get(
|
message = error_json.get("message", "No message provided in JSON.")
|
||||||
"message", "No message provided in JSON."
|
|
||||||
)
|
|
||||||
# Construct a more descriptive new error
|
# Construct a more descriptive new error
|
||||||
new_err = HTTPError(
|
new_err = HTTPError(
|
||||||
f"{status}: {message} (Original {response.status_code} Error)",
|
f"{status}: {message} (Original {response.status_code} Error)",
|
||||||
|
|||||||
@ -76,10 +76,7 @@ def _get_url_from_controller() -> Optional[str]:
|
|||||||
controller = Controller.instance()
|
controller = Controller.instance()
|
||||||
local_compute = controller.get_compute("local")
|
local_compute = controller.get_compute("local")
|
||||||
|
|
||||||
url = (
|
url = f"{local_compute.protocol}://{local_compute.host}:{local_compute.port}"
|
||||||
f"{local_compute.protocol}://{local_compute.host}:"
|
|
||||||
f"{local_compute.port}"
|
|
||||||
)
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Got GNS3 URL from Controller: %s (protocol=%s, host=%s, port=%s)",
|
"Got GNS3 URL from Controller: %s (protocol=%s, host=%s, port=%s)",
|
||||||
url,
|
url,
|
||||||
@ -98,9 +95,7 @@ def _get_url_from_controller() -> Optional[str]:
|
|||||||
logger.debug("Local compute not found in Controller: %s", str(e))
|
logger.debug("Local compute not found in Controller: %s", str(e))
|
||||||
return None
|
return None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(
|
logger.warning("Unexpected error getting URL from Controller: %s", str(e))
|
||||||
"Unexpected error getting URL from Controller: %s", str(e)
|
|
||||||
)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@ -114,10 +109,7 @@ def _get_url_from_config() -> Optional[str]:
|
|||||||
from gns3server.config import Config
|
from gns3server.config import Config
|
||||||
|
|
||||||
server_config = Config.instance().settings.Server
|
server_config = Config.instance().settings.Server
|
||||||
url = (
|
url = f"{server_config.protocol.value}://{server_config.host}:{server_config.port}"
|
||||||
f"{server_config.protocol.value}://{server_config.host}:"
|
|
||||||
f"{server_config.port}"
|
|
||||||
)
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Got GNS3 URL from Config: %s (protocol=%s, host=%s, port=%s)",
|
"Got GNS3 URL from Config: %s (protocol=%s, host=%s, port=%s)",
|
||||||
url,
|
url,
|
||||||
@ -137,9 +129,7 @@ def _get_url_from_config() -> Optional[str]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def get_gns3_connector(
|
def get_gns3_connector(jwt_token: Optional[str] = None, url: Optional[str] = None) -> Optional[Gns3Connector]:
|
||||||
jwt_token: Optional[str] = None, url: Optional[str] = None
|
|
||||||
) -> Optional[Gns3Connector]:
|
|
||||||
"""Create and return a Gns3Connector instance with JWT authentication.
|
"""Create and return a Gns3Connector instance with JWT authentication.
|
||||||
|
|
||||||
URL Resolution Strategy (in order):
|
URL Resolution Strategy (in order):
|
||||||
@ -223,9 +213,7 @@ def get_gns3_connector(
|
|||||||
return connector
|
return connector
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error("Failed to create Gns3Connector: %s", str(e), exc_info=True)
|
||||||
"Failed to create Gns3Connector: %s", str(e), exc_info=True
|
|
||||||
)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@ -284,9 +272,7 @@ async def get_gns3_connector_with_llm_config(
|
|||||||
url = _detect_url_for_api()
|
url = _detect_url_for_api()
|
||||||
|
|
||||||
# Step 3: Get LLM config
|
# Step 3: Get LLM config
|
||||||
llm_config = get_llm_config(
|
llm_config = get_llm_config(user_id=user_id, jwt_token=jwt_token, app=app)
|
||||||
user_id=user_id, jwt_token=jwt_token, app=app
|
|
||||||
)
|
|
||||||
|
|
||||||
if not llm_config:
|
if not llm_config:
|
||||||
logger.warning(f"No LLM config found for user {user_id}")
|
logger.warning(f"No LLM config found for user {user_id}")
|
||||||
@ -303,9 +289,7 @@ async def get_gns3_connector_with_llm_config(
|
|||||||
return {"connector": connector, "llm_config": llm_config}
|
return {"connector": connector, "llm_config": llm_config}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(f"Failed to get GNS3 connector with LLM config: {e}", exc_info=True)
|
||||||
f"Failed to get GNS3 connector with LLM config: {e}", exc_info=True
|
|
||||||
)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@ -375,9 +359,7 @@ def get_gns3_server_host() -> str:
|
|||||||
logger.debug("Extracted GNS3 server host: %s from URL: %s", host, url)
|
logger.debug("Extracted GNS3 server host: %s from URL: %s", host, url)
|
||||||
return host
|
return host
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(
|
logger.warning("Failed to extract host from URL %s: %s, using fallback", url, e)
|
||||||
"Failed to extract host from URL %s: %s, using fallback", url, e
|
|
||||||
)
|
|
||||||
return DEFAULT_GNS3_URL.split("://")[1].split(":")[0]
|
return DEFAULT_GNS3_URL.split("://")[1].split(":")[0]
|
||||||
|
|
||||||
|
|
||||||
@ -428,9 +410,7 @@ def get_llm_config(user_id, jwt_token: str, app=None) -> Optional[dict]:
|
|||||||
# We're in an async context with a running loop
|
# We're in an async context with a running loop
|
||||||
# This shouldn't happen since this is a sync function
|
# This shouldn't happen since this is a sync function
|
||||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||||
future = executor.submit(
|
future = executor.submit(asyncio.run, get_user_llm_config_with_app(user_id, app))
|
||||||
asyncio.run, get_user_llm_config_with_app(user_id, app)
|
|
||||||
)
|
|
||||||
return future.result(timeout=10)
|
return future.result(timeout=10)
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
# No running event loop - we're in a sync context
|
# No running event loop - we're in a sync context
|
||||||
@ -449,26 +429,19 @@ def get_llm_config(user_id, jwt_token: str, app=None) -> Optional[dict]:
|
|||||||
return future.result(timeout=10)
|
return future.result(timeout=10)
|
||||||
else:
|
else:
|
||||||
# Loop exists but not running - use it
|
# Loop exists but not running - use it
|
||||||
return loop.run_until_complete(
|
return loop.run_until_complete(get_user_llm_config_with_app(user_id, app))
|
||||||
get_user_llm_config_with_app(user_id, app)
|
|
||||||
)
|
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
# No event loop exists - create a new one
|
# No event loop exists - create a new one
|
||||||
loop = asyncio.new_event_loop()
|
loop = asyncio.new_event_loop()
|
||||||
asyncio.set_event_loop(loop)
|
asyncio.set_event_loop(loop)
|
||||||
try:
|
try:
|
||||||
return loop.run_until_complete(
|
return loop.run_until_complete(get_user_llm_config_with_app(user_id, app))
|
||||||
get_user_llm_config_with_app(user_id, app)
|
|
||||||
)
|
|
||||||
finally:
|
finally:
|
||||||
loop.close()
|
loop.close()
|
||||||
|
|
||||||
# Fallback: No app provided, try API call (will mask group config API
|
# Fallback: No app provided, try API call (will mask group config API
|
||||||
# keys)
|
# keys)
|
||||||
logger.warning(
|
logger.warning("No app provided for get_llm_config, group config API keys may be masked")
|
||||||
"No app provided for get_llm_config, group config API keys may be "
|
|
||||||
"masked"
|
|
||||||
)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@ -62,12 +62,8 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
# Context variables for request-scoped data
|
# Context variables for request-scoped data
|
||||||
# Automatically cleaned up when request context ends
|
# Automatically cleaned up when request context ends
|
||||||
_jwt_token_context: ContextVar[Optional[str]] = ContextVar(
|
_jwt_token_context: ContextVar[Optional[str]] = ContextVar("_jwt_token_context", default=None)
|
||||||
"_jwt_token_context", default=None
|
_llm_config_context: ContextVar[Optional[dict]] = ContextVar("_llm_config_context", default=None)
|
||||||
)
|
|
||||||
_llm_config_context: ContextVar[Optional[dict]] = ContextVar(
|
|
||||||
"_llm_config_context", default=None
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def set_current_jwt_token(token: str) -> None:
|
def set_current_jwt_token(token: str) -> None:
|
||||||
|
|||||||
@ -90,20 +90,13 @@ class GNS3ProjectInfoTool(BaseTool):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# Log received input
|
# Log received input
|
||||||
logger.info(
|
logger.info("Received tool_input: %s, project_id: %s", tool_input, project_id)
|
||||||
"Received tool_input: %s, project_id: %s", tool_input, project_id
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Validate project_id parameter
|
# Validate project_id parameter
|
||||||
if not project_id:
|
if not project_id:
|
||||||
logger.error("project_id parameter is required.")
|
logger.error("project_id parameter is required.")
|
||||||
return {
|
return {"error": ("project_id parameter is required. Please provide a valid project UUID.")}
|
||||||
"error": (
|
|
||||||
"project_id parameter is required. Please provide a valid "
|
|
||||||
"project UUID."
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
# Build handler context (JWT + server URL from request context)
|
# Build handler context (JWT + server URL from request context)
|
||||||
logger.debug("Connecting to GNS3 server...")
|
logger.debug("Connecting to GNS3 server...")
|
||||||
@ -111,17 +104,10 @@ class GNS3ProjectInfoTool(BaseTool):
|
|||||||
|
|
||||||
if gns3_ctx is None:
|
if gns3_ctx is None:
|
||||||
logger.error("Failed to create GNS3 connector")
|
logger.error("Failed to create GNS3 connector")
|
||||||
return {
|
return {"error": ("Failed to connect to GNS3 server. Please check your configuration.")}
|
||||||
"error": (
|
|
||||||
"Failed to connect to GNS3 server. Please check your "
|
|
||||||
"configuration."
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
# Use the provided project_id directly
|
# Use the provided project_id directly
|
||||||
logger.info(
|
logger.info(f"Retrieving project info for project_id: {project_id}")
|
||||||
f"Retrieving project info for project_id: {project_id}"
|
|
||||||
)
|
|
||||||
inventory = fetch_project_inventory(gns3_ctx, project_id)
|
inventory = fetch_project_inventory(gns3_ctx, project_id)
|
||||||
|
|
||||||
# Get node and link counts
|
# Get node and link counts
|
||||||
|
|||||||
@ -100,18 +100,13 @@ class GNS3TopologyTool(BaseTool):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# Log received input
|
# Log received input
|
||||||
logger.info(
|
logger.info("Received tool_input: %s, project_id: %s", tool_input, project_id)
|
||||||
"Received tool_input: %s, project_id: %s", tool_input, project_id
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Validate project_id parameter
|
# Validate project_id parameter
|
||||||
if not project_id:
|
if not project_id:
|
||||||
logger.error("project_id parameter is required.")
|
logger.error("project_id parameter is required.")
|
||||||
return {
|
return {"error": "project_id parameter is required. Please provide a valid project UUID."}
|
||||||
"error": "project_id parameter is required. "
|
|
||||||
"Please provide a valid project UUID."
|
|
||||||
}
|
|
||||||
|
|
||||||
# Build handler context (JWT + server URL)
|
# Build handler context (JWT + server URL)
|
||||||
# jwt_token/url can be passed explicitly (e.g. from MCP handlers)
|
# jwt_token/url can be passed explicitly (e.g. from MCP handlers)
|
||||||
@ -121,10 +116,7 @@ class GNS3TopologyTool(BaseTool):
|
|||||||
|
|
||||||
if gns3_ctx is None:
|
if gns3_ctx is None:
|
||||||
logger.error("Failed to create GNS3 connector")
|
logger.error("Failed to create GNS3 connector")
|
||||||
return {
|
return {"error": "Failed to connect to GNS3 server. Please check your configuration."}
|
||||||
"error": "Failed to connect to GNS3 server. Please check "
|
|
||||||
"your configuration."
|
|
||||||
}
|
|
||||||
|
|
||||||
# Use the provided project_id directly
|
# Use the provided project_id directly
|
||||||
logger.info(f"Retrieving topology for project_id: {project_id}")
|
logger.info(f"Retrieving topology for project_id: {project_id}")
|
||||||
@ -135,16 +127,13 @@ class GNS3TopologyTool(BaseTool):
|
|||||||
"project_id": inventory["project_id"],
|
"project_id": inventory["project_id"],
|
||||||
"name": inventory["name"],
|
"name": inventory["name"],
|
||||||
"status": inventory["status"],
|
"status": inventory["status"],
|
||||||
"nodes": self._clean_nodes_ports(
|
"nodes": self._clean_nodes_ports(copy.deepcopy(inventory["nodes_inventory"])),
|
||||||
copy.deepcopy(inventory["nodes_inventory"])
|
|
||||||
),
|
|
||||||
"links": inventory["links_summary"],
|
"links": inventory["links_summary"],
|
||||||
}
|
}
|
||||||
|
|
||||||
# Log topology result
|
# Log topology result
|
||||||
logger.info(
|
logger.info(
|
||||||
"Topology retrieved: project_id=%s, name=%s, nodes=%d, "
|
"Topology retrieved: project_id=%s, name=%s, nodes=%d, links=%d",
|
||||||
"links=%d",
|
|
||||||
topology.get("project_id"),
|
topology.get("project_id"),
|
||||||
topology.get("name"),
|
topology.get("name"),
|
||||||
len(topology.get("nodes", {})),
|
len(topology.get("nodes", {})),
|
||||||
@ -167,10 +156,7 @@ class GNS3TopologyTool(BaseTool):
|
|||||||
"""
|
"""
|
||||||
for node in data.values(): # Iterate through R-1, R-2, R-3, R-4
|
for node in data.values(): # Iterate through R-1, R-2, R-3, R-4
|
||||||
if "ports" in node and isinstance(node["ports"], list):
|
if "ports" in node and isinstance(node["ports"], list):
|
||||||
node["ports"] = [
|
node["ports"] = [{"name": port["name"], "short_name": port["short_name"]} for port in node["ports"]]
|
||||||
{"name": port["name"], "short_name": port["short_name"]}
|
|
||||||
for port in node["ports"]
|
|
||||||
]
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -38,9 +38,7 @@ from urllib.parse import urlparse
|
|||||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import _get_connector
|
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import _get_connector
|
||||||
|
|
||||||
|
|
||||||
def build_nodes_inventory(
|
def build_nodes_inventory(nodes: list[dict[str, Any]], server_host: str | None) -> dict[str, Any]:
|
||||||
nodes: list[dict[str, Any]], server_host: str | None
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""
|
"""
|
||||||
Build an inventory-style dict keyed by node name.
|
Build an inventory-style dict keyed by node name.
|
||||||
|
|
||||||
@ -70,9 +68,7 @@ def build_nodes_inventory(
|
|||||||
return inventory
|
return inventory
|
||||||
|
|
||||||
|
|
||||||
def build_links_summary(
|
def build_links_summary(nodes: list[dict[str, Any]], links: list[dict[str, Any]]) -> list[dict[str, str]]:
|
||||||
nodes: list[dict[str, Any]], links: list[dict[str, Any]]
|
|
||||||
) -> list[dict[str, str]]:
|
|
||||||
"""
|
"""
|
||||||
Build a human/LLM-friendly link list resolving node and port names.
|
Build a human/LLM-friendly link list resolving node and port names.
|
||||||
|
|
||||||
@ -87,46 +83,40 @@ def build_links_summary(
|
|||||||
side_a = link["nodes"][0]
|
side_a = link["nodes"][0]
|
||||||
side_b = link["nodes"][1]
|
side_b = link["nodes"][1]
|
||||||
try:
|
try:
|
||||||
node_a = next(
|
node_a = next(x for x in nodes if x.get("node_id") == side_a["node_id"])
|
||||||
x for x in nodes if x.get("node_id") == side_a["node_id"]
|
|
||||||
)
|
|
||||||
port_a = str(
|
port_a = str(
|
||||||
next(
|
next(
|
||||||
p["name"]
|
p["name"]
|
||||||
for p in (node_a.get("ports") or [])
|
for p in (node_a.get("ports") or [])
|
||||||
if p["port_number"] == side_a["port_number"]
|
if p["port_number"] == side_a["port_number"] and p["adapter_number"] == side_a["adapter_number"]
|
||||||
and p["adapter_number"] == side_a["adapter_number"]
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
node_b = next(
|
node_b = next(x for x in nodes if x.get("node_id") == side_b["node_id"])
|
||||||
x for x in nodes if x.get("node_id") == side_b["node_id"]
|
|
||||||
)
|
|
||||||
port_b = str(
|
port_b = str(
|
||||||
next(
|
next(
|
||||||
p["name"]
|
p["name"]
|
||||||
for p in (node_b.get("ports") or [])
|
for p in (node_b.get("ports") or [])
|
||||||
if p["port_number"] == side_b["port_number"]
|
if p["port_number"] == side_b["port_number"] and p["adapter_number"] == side_b["adapter_number"]
|
||||||
and p["adapter_number"] == side_b["adapter_number"]
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
name_a = str(node_a["name"]) if node_a.get("name") else "Unknown"
|
name_a = str(node_a["name"]) if node_a.get("name") else "Unknown"
|
||||||
name_b = str(node_b["name"]) if node_b.get("name") else "Unknown"
|
name_b = str(node_b["name"]) if node_b.get("name") else "Unknown"
|
||||||
summary.append({
|
summary.append(
|
||||||
"link_id": link.get("link_id"),
|
{
|
||||||
"node_a": name_a,
|
"link_id": link.get("link_id"),
|
||||||
"port_a": port_a,
|
"node_a": name_a,
|
||||||
"node_b": name_b,
|
"port_a": port_a,
|
||||||
"port_b": port_b,
|
"node_b": name_b,
|
||||||
})
|
"port_b": port_b,
|
||||||
|
}
|
||||||
|
)
|
||||||
except (StopIteration, KeyError, AttributeError):
|
except (StopIteration, KeyError, AttributeError):
|
||||||
# Prevent errors when lookups can't match data
|
# Prevent errors when lookups can't match data
|
||||||
continue
|
continue
|
||||||
return summary
|
return summary
|
||||||
|
|
||||||
|
|
||||||
def fetch_project_inventory(
|
def fetch_project_inventory(gns3_ctx: dict[str, Any], project_id: str) -> dict[str, Any]:
|
||||||
gns3_ctx: dict[str, Any], project_id: str
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""
|
"""
|
||||||
Fetch a project's metadata, nodes and links and return the aggregated
|
Fetch a project's metadata, nodes and links and return the aggregated
|
||||||
inventory — the equivalent of the old ``Project.get()`` +
|
inventory — the equivalent of the old ``Project.get()`` +
|
||||||
|
|||||||
@ -61,9 +61,7 @@ class ProjectAgentManager:
|
|||||||
cls._instance._lock = asyncio.Lock()
|
cls._instance._lock = asyncio.Lock()
|
||||||
return cls._instance
|
return cls._instance
|
||||||
|
|
||||||
async def get_agent(
|
async def get_agent(self, project_id: str, project_path: str) -> AgentService:
|
||||||
self, project_id: str, project_path: str
|
|
||||||
) -> AgentService:
|
|
||||||
"""
|
"""
|
||||||
Get or create an AgentService for a project.
|
Get or create an AgentService for a project.
|
||||||
|
|
||||||
|
|||||||
@ -70,10 +70,7 @@ def load_system_prompt(llm_config: dict | None = None) -> str:
|
|||||||
str: The system prompt string, or empty string if not found.
|
str: The system prompt string, or empty string if not found.
|
||||||
"""
|
"""
|
||||||
if not llm_config:
|
if not llm_config:
|
||||||
logger.debug(
|
logger.debug("No LLM config provided, using default TEACHING_ASSISTANT prompt mode")
|
||||||
"No LLM config provided, using default TEACHING_ASSISTANT "
|
|
||||||
"prompt mode"
|
|
||||||
)
|
|
||||||
return _load_prompt("teaching_assistant")
|
return _load_prompt("teaching_assistant")
|
||||||
|
|
||||||
# llm_config is a flattened dict with copilot_mode at the top level
|
# llm_config is a flattened dict with copilot_mode at the top level
|
||||||
@ -81,15 +78,10 @@ def load_system_prompt(llm_config: dict | None = None) -> str:
|
|||||||
mode = llm_config.get("copilot_mode", "teaching_assistant").lower()
|
mode = llm_config.get("copilot_mode", "teaching_assistant").lower()
|
||||||
|
|
||||||
if mode == "lab_automation_assistant":
|
if mode == "lab_automation_assistant":
|
||||||
logger.debug(
|
logger.debug("Using LAB_AUTOMATION_ASSISTANT prompt mode (diagnostics + configuration)")
|
||||||
"Using LAB_AUTOMATION_ASSISTANT prompt mode (diagnostics + "
|
|
||||||
"configuration)"
|
|
||||||
)
|
|
||||||
return _load_prompt("lab_automation_assistant")
|
return _load_prompt("lab_automation_assistant")
|
||||||
elif mode == "troubleshooting_injection":
|
elif mode == "troubleshooting_injection":
|
||||||
logger.debug(
|
logger.debug("Using TROUBLESHOOTING_INJECTION prompt mode (fault injection)")
|
||||||
"Using TROUBLESHOOTING_INJECTION prompt mode (fault injection)"
|
|
||||||
)
|
|
||||||
return _load_prompt("troubleshooting_injection")
|
return _load_prompt("troubleshooting_injection")
|
||||||
else:
|
else:
|
||||||
logger.debug("Using TEACHING_ASSISTANT prompt mode (diagnostics only)")
|
logger.debug("Using TEACHING_ASSISTANT prompt mode (diagnostics only)")
|
||||||
|
|||||||
@ -201,7 +201,9 @@ class SkillsLoader:
|
|||||||
topic_key = yaml_file.stem
|
topic_key = yaml_file.stem
|
||||||
logger.warning(f"No 'topic' field in {yaml_file}, using filename '{topic_key}'")
|
logger.warning(f"No 'topic' field in {yaml_file}, using filename '{topic_key}'")
|
||||||
if topic_key in topics:
|
if topic_key in topics:
|
||||||
logger.warning(f"Duplicate topic '{topic_key}' in {device_path.name} (from {yaml_file}), overwriting")
|
logger.warning(
|
||||||
|
f"Duplicate topic '{topic_key}' in {device_path.name} (from {yaml_file}), overwriting"
|
||||||
|
)
|
||||||
|
|
||||||
# category/topics belong to the base skill only
|
# category/topics belong to the base skill only
|
||||||
topic_data.pop("category", None)
|
topic_data.pop("category", None)
|
||||||
|
|||||||
@ -49,9 +49,9 @@ logger = logging.getLogger(__name__)
|
|||||||
# Applied per-command via the `env` parameter to avoid polluting
|
# Applied per-command via the `env` parameter to avoid polluting
|
||||||
# the global process environment.
|
# the global process environment.
|
||||||
_GIT_TIMEOUT_ENV = {
|
_GIT_TIMEOUT_ENV = {
|
||||||
'GIT_HTTP_TIMEOUT': '10', # Connection timeout (default: 120s)
|
"GIT_HTTP_TIMEOUT": "10", # Connection timeout (default: 120s)
|
||||||
'GIT_HTTP_LOW_SPEED_TIME': '5', # Slow speed threshold window
|
"GIT_HTTP_LOW_SPEED_TIME": "5", # Slow speed threshold window
|
||||||
'GIT_HTTP_LOW_SPEED_LIMIT': '10240', # < 10 KB/s = slow → abort
|
"GIT_HTTP_LOW_SPEED_LIMIT": "10240", # < 10 KB/s = slow → abort
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -66,12 +66,7 @@ class SkillsManager:
|
|||||||
- Version tracking
|
- Version tracking
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, repo_url: str = None, branch: str = "main", auto_update: bool = False):
|
||||||
self,
|
|
||||||
repo_url: str = None,
|
|
||||||
branch: str = "main",
|
|
||||||
auto_update: bool = False
|
|
||||||
):
|
|
||||||
"""
|
"""
|
||||||
Initialize the skills manager.
|
Initialize the skills manager.
|
||||||
|
|
||||||
@ -147,7 +142,7 @@ class SkillsManager:
|
|||||||
logger.warning(
|
logger.warning(
|
||||||
"Skills repository has uncommitted changes, skipping pull. "
|
"Skills repository has uncommitted changes, skipping pull. "
|
||||||
"Commit or stash changes in %s to enable automatic updates.",
|
"Commit or stash changes in %s to enable automatic updates.",
|
||||||
self.local_path
|
self.local_path,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -161,14 +156,9 @@ class SkillsManager:
|
|||||||
|
|
||||||
# Check if behind and pull
|
# Check if behind and pull
|
||||||
try:
|
try:
|
||||||
behind_commits = list(self._repo.iter_commits(
|
behind_commits = list(self._repo.iter_commits(f"{self.branch}..origin/{self.branch}"))
|
||||||
f'{self.branch}..origin/{self.branch}'
|
|
||||||
))
|
|
||||||
if behind_commits:
|
if behind_commits:
|
||||||
logger.info(
|
logger.info("Skills repository is behind by %d commit(s), pulling...", len(behind_commits))
|
||||||
"Skills repository is behind by %d commit(s), pulling...",
|
|
||||||
len(behind_commits)
|
|
||||||
)
|
|
||||||
origin.pull(self.branch, env=_GIT_TIMEOUT_ENV)
|
origin.pull(self.branch, env=_GIT_TIMEOUT_ENV)
|
||||||
logger.info(f"Updated to commit {self.get_current_version()}")
|
logger.info(f"Updated to commit {self.get_current_version()}")
|
||||||
else:
|
else:
|
||||||
@ -188,12 +178,7 @@ class SkillsManager:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._repo = git.Repo.clone_from(
|
self._repo = git.Repo.clone_from(self.repo_url, self.local_path, branch=self.branch, env=_GIT_TIMEOUT_ENV)
|
||||||
self.repo_url,
|
|
||||||
self.local_path,
|
|
||||||
branch=self.branch,
|
|
||||||
env=_GIT_TIMEOUT_ENV
|
|
||||||
)
|
|
||||||
logger.info(f"Successfully cloned skills repository to {self.local_path}")
|
logger.info(f"Successfully cloned skills repository to {self.local_path}")
|
||||||
return True
|
return True
|
||||||
except git.GitCommandError as e:
|
except git.GitCommandError as e:
|
||||||
@ -256,7 +241,9 @@ class SkillsManager:
|
|||||||
del SKILLS_REGISTRY[k]
|
del SKILLS_REGISTRY[k]
|
||||||
SKILLS_REGISTRY.update(all_skills)
|
SKILLS_REGISTRY.update(all_skills)
|
||||||
|
|
||||||
logger.info(f"Loaded {len(new_injection_skills)} injection skills, {len(new_device_skills)} device skills, and {len(new_feature_skills)} feature skills")
|
logger.info(
|
||||||
|
f"Loaded {len(new_injection_skills)} injection skills, {len(new_device_skills)} device skills, and {len(new_feature_skills)} feature skills"
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to reload skills: {e}")
|
logger.error(f"Failed to reload skills: {e}")
|
||||||
@ -302,12 +289,7 @@ class SkillsManager:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Available prompt names
|
# Available prompt names
|
||||||
prompt_names = [
|
prompt_names = ["lab_automation_assistant", "teaching_assistant", "troubleshooting_injection", "title"]
|
||||||
"lab_automation_assistant",
|
|
||||||
"teaching_assistant",
|
|
||||||
"troubleshooting_injection",
|
|
||||||
"title"
|
|
||||||
]
|
|
||||||
|
|
||||||
# Load all prompts
|
# Load all prompts
|
||||||
loaded_count = 0
|
loaded_count = 0
|
||||||
@ -385,6 +367,7 @@ class SkillsManager:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from .registry import INJECTION_SKILLS_REGISTRY
|
from .registry import INJECTION_SKILLS_REGISTRY
|
||||||
|
|
||||||
return len(INJECTION_SKILLS_REGISTRY)
|
return len(INJECTION_SKILLS_REGISTRY)
|
||||||
except Exception:
|
except Exception:
|
||||||
return 0
|
return 0
|
||||||
@ -413,7 +396,7 @@ class SkillsManager:
|
|||||||
"skill_count": self.get_skill_count(),
|
"skill_count": self.get_skill_count(),
|
||||||
"prompt_count": self.get_prompt_count(),
|
"prompt_count": self.get_prompt_count(),
|
||||||
"auto_update": self.auto_update,
|
"auto_update": self.auto_update,
|
||||||
"is_initialized": (self.local_path / ".git").exists()
|
"is_initialized": (self.local_path / ".git").exists(),
|
||||||
}
|
}
|
||||||
|
|
||||||
def rollback(self, commit_hash: str) -> bool:
|
def rollback(self, commit_hash: str) -> bool:
|
||||||
@ -462,12 +445,14 @@ class SkillsManager:
|
|||||||
|
|
||||||
commits = []
|
commits = []
|
||||||
for commit in self._repo.iter_commits(max_count=limit):
|
for commit in self._repo.iter_commits(max_count=limit):
|
||||||
commits.append({
|
commits.append(
|
||||||
"hash": commit.hexsha,
|
{
|
||||||
"message": commit.message.strip(),
|
"hash": commit.hexsha,
|
||||||
"author": str(commit.author),
|
"message": commit.message.strip(),
|
||||||
"date": commit.committed_datetime.isoformat()
|
"author": str(commit.author),
|
||||||
})
|
"date": commit.committed_datetime.isoformat(),
|
||||||
|
}
|
||||||
|
)
|
||||||
return commits
|
return commits
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to get commit history: {e}")
|
logger.error(f"Failed to get commit history: {e}")
|
||||||
|
|||||||
@ -115,7 +115,7 @@ def _ensure_skills_manager():
|
|||||||
manager = SkillsManager(
|
manager = SkillsManager(
|
||||||
repo_url=config.get("repo_url"),
|
repo_url=config.get("repo_url"),
|
||||||
branch=config.get("branch", "main"),
|
branch=config.get("branch", "main"),
|
||||||
auto_update=config.get("auto_update", False)
|
auto_update=config.get("auto_update", False),
|
||||||
)
|
)
|
||||||
|
|
||||||
if not manager.initialize():
|
if not manager.initialize():
|
||||||
@ -224,7 +224,7 @@ def reload_injection_skills() -> dict[str, Any]:
|
|||||||
"success": False,
|
"success": False,
|
||||||
"message": "Skills manager not initialized",
|
"message": "Skills manager not initialized",
|
||||||
"skill_count": len(INJECTION_SKILLS_REGISTRY),
|
"skill_count": len(INJECTION_SKILLS_REGISTRY),
|
||||||
"version": ""
|
"version": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -233,7 +233,7 @@ def reload_injection_skills() -> dict[str, Any]:
|
|||||||
"success": success,
|
"success": success,
|
||||||
"message": "Skills reloaded successfully" if success else "Failed to reload skills",
|
"message": "Skills reloaded successfully" if success else "Failed to reload skills",
|
||||||
"skill_count": manager.get_skill_count(),
|
"skill_count": manager.get_skill_count(),
|
||||||
"version": manager.get_current_version()
|
"version": manager.get_current_version(),
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error during skills reload: {e}")
|
logger.error(f"Error during skills reload: {e}")
|
||||||
@ -241,7 +241,7 @@ def reload_injection_skills() -> dict[str, Any]:
|
|||||||
"success": False,
|
"success": False,
|
||||||
"message": f"Error: {str(e)}",
|
"message": f"Error: {str(e)}",
|
||||||
"skill_count": len(INJECTION_SKILLS_REGISTRY),
|
"skill_count": len(INJECTION_SKILLS_REGISTRY),
|
||||||
"version": ""
|
"version": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -263,12 +263,7 @@ def reload_prompts() -> dict[str, Any]:
|
|||||||
"""
|
"""
|
||||||
manager = get_skills_manager()
|
manager = get_skills_manager()
|
||||||
if manager is None:
|
if manager is None:
|
||||||
return {
|
return {"success": False, "message": "Skills manager not initialized", "prompt_count": 0, "version": ""}
|
||||||
"success": False,
|
|
||||||
"message": "Skills manager not initialized",
|
|
||||||
"prompt_count": 0,
|
|
||||||
"version": ""
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
success = manager.reload_prompts()
|
success = manager.reload_prompts()
|
||||||
@ -276,7 +271,7 @@ def reload_prompts() -> dict[str, Any]:
|
|||||||
"success": success,
|
"success": success,
|
||||||
"message": "Prompts reloaded successfully" if success else "Failed to reload prompts",
|
"message": "Prompts reloaded successfully" if success else "Failed to reload prompts",
|
||||||
"prompt_count": manager.get_prompt_count(),
|
"prompt_count": manager.get_prompt_count(),
|
||||||
"version": manager.get_current_version()
|
"version": manager.get_current_version(),
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error during prompts reload: {e}")
|
logger.error(f"Error during prompts reload: {e}")
|
||||||
@ -285,7 +280,7 @@ def reload_prompts() -> dict[str, Any]:
|
|||||||
"success": False,
|
"success": False,
|
||||||
"message": f"Error: {str(e)}",
|
"message": f"Error: {str(e)}",
|
||||||
"prompt_count": manager.get_prompt_count() if manager else 0,
|
"prompt_count": manager.get_prompt_count() if manager else 0,
|
||||||
"version": ""
|
"version": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -315,16 +310,11 @@ def reload_forbidden_commands() -> dict[str, Any]:
|
|||||||
"success": True,
|
"success": True,
|
||||||
"message": "Forbidden commands reloaded",
|
"message": "Forbidden commands reloaded",
|
||||||
"command_count": len(commands),
|
"command_count": len(commands),
|
||||||
"version": manager.get_current_version() if manager else ""
|
"version": manager.get_current_version() if manager else "",
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error during forbidden commands reload: {e}")
|
logger.error(f"Error during forbidden commands reload: {e}")
|
||||||
return {
|
return {"success": False, "message": f"Error: {str(e)}", "command_count": 0, "version": ""}
|
||||||
"success": False,
|
|
||||||
"message": f"Error: {str(e)}",
|
|
||||||
"command_count": 0,
|
|
||||||
"version": ""
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_prompt(prompt_name: str) -> str:
|
def get_prompt(prompt_name: str) -> str:
|
||||||
@ -360,10 +350,7 @@ def get_skills_repository_info() -> dict[str, Any]:
|
|||||||
"""
|
"""
|
||||||
manager = get_skills_manager()
|
manager = get_skills_manager()
|
||||||
if manager is None:
|
if manager is None:
|
||||||
return {
|
return {"initialized": False, "message": "Skills manager not initialized"}
|
||||||
"initialized": False,
|
|
||||||
"message": "Skills manager not initialized"
|
|
||||||
}
|
|
||||||
|
|
||||||
return manager.get_repository_info()
|
return manager.get_repository_info()
|
||||||
|
|
||||||
@ -411,8 +398,7 @@ def get_skill(
|
|||||||
"error": f"device_type '{device_type}' is not in category '{category}'",
|
"error": f"device_type '{device_type}' is not in category '{category}'",
|
||||||
"device_category": skill.get("category"),
|
"device_category": skill.get("category"),
|
||||||
"available_in_category": [
|
"available_in_category": [
|
||||||
did for did, s in SKILLS_REGISTRY.items()
|
did for did, s in SKILLS_REGISTRY.items() if s.get("category", "").lower() == category.lower()
|
||||||
if s.get("category", "").lower() == category.lower()
|
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -484,8 +470,7 @@ def get_skill(
|
|||||||
}
|
}
|
||||||
if topics:
|
if topics:
|
||||||
result["topics"] = {
|
result["topics"] = {
|
||||||
k: {"name": v.get("name", k), "description": v.get("description", "")}
|
k: {"name": v.get("name", k), "description": v.get("description", "")} for k, v in topics.items()
|
||||||
for k, v in topics.items()
|
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@ -503,19 +488,23 @@ def list_available_skills(category: str | None = None) -> list[dict[str, Any]]:
|
|||||||
for did, skill in SKILLS_REGISTRY.items():
|
for did, skill in SKILLS_REGISTRY.items():
|
||||||
if category:
|
if category:
|
||||||
if skill.get("category", "").lower() == category.lower():
|
if skill.get("category", "").lower() == category.lower():
|
||||||
skills.append({
|
skills.append(
|
||||||
|
{
|
||||||
|
"device_type": did,
|
||||||
|
"name": skill.get("name", did),
|
||||||
|
"category": skill.get("category"),
|
||||||
|
"topic_count": len(skill.get("topics", {})),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
skills.append(
|
||||||
|
{
|
||||||
"device_type": did,
|
"device_type": did,
|
||||||
"name": skill.get("name", did),
|
"name": skill.get("name", did),
|
||||||
"category": skill.get("category"),
|
"category": skill.get("category"),
|
||||||
"topic_count": len(skill.get("topics", {})),
|
"topic_count": len(skill.get("topics", {})),
|
||||||
})
|
}
|
||||||
else:
|
)
|
||||||
skills.append({
|
|
||||||
"device_type": did,
|
|
||||||
"name": skill.get("name", did),
|
|
||||||
"category": skill.get("category"),
|
|
||||||
"topic_count": len(skill.get("topics", {})),
|
|
||||||
})
|
|
||||||
return skills
|
return skills
|
||||||
|
|
||||||
|
|
||||||
@ -541,7 +530,7 @@ def get_injection_skill(
|
|||||||
return {
|
return {
|
||||||
"error": f"Unknown injection fault type: {device_type}",
|
"error": f"Unknown injection fault type: {device_type}",
|
||||||
"available_fault_types": list(INJECTION_SKILLS_REGISTRY.keys()),
|
"available_fault_types": list(INJECTION_SKILLS_REGISTRY.keys()),
|
||||||
"hint": "Use {'action': 'list'} to see all available fault types"
|
"hint": "Use {'action': 'list'} to see all available fault types",
|
||||||
}
|
}
|
||||||
|
|
||||||
issues = skill.get("issues", {})
|
issues = skill.get("issues", {})
|
||||||
@ -611,11 +600,13 @@ def list_available_injection_skills(context: list[str] | None = None) -> list[di
|
|||||||
category_lower = category.lower()
|
category_lower = category.lower()
|
||||||
if not any(kw.lower() in category_lower or kw.lower() in did.lower() for kw in context):
|
if not any(kw.lower() in category_lower or kw.lower() in did.lower() for kw in context):
|
||||||
continue
|
continue
|
||||||
skills.append({
|
skills.append(
|
||||||
"device_type": did,
|
{
|
||||||
"name": skill.get("name", did),
|
"device_type": did,
|
||||||
"category": category,
|
"name": skill.get("name", did),
|
||||||
})
|
"category": category,
|
||||||
|
}
|
||||||
|
)
|
||||||
return skills
|
return skills
|
||||||
|
|
||||||
|
|
||||||
@ -658,11 +649,13 @@ def list_available_packet_analysis_protocols() -> list[dict[str, str]]:
|
|||||||
"""
|
"""
|
||||||
protocols = []
|
protocols = []
|
||||||
for key, data in PACKET_ANALYSIS_REGISTRY.items():
|
for key, data in PACKET_ANALYSIS_REGISTRY.items():
|
||||||
protocols.append({
|
protocols.append(
|
||||||
"protocol": key,
|
{
|
||||||
"name": data.get("name", key),
|
"protocol": key,
|
||||||
"description": data.get("description", ""),
|
"name": data.get("name", key),
|
||||||
})
|
"description": data.get("description", ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
return protocols
|
return protocols
|
||||||
|
|
||||||
|
|
||||||
@ -711,10 +704,14 @@ class DeviceSkillsTool(BaseTool):
|
|||||||
try:
|
try:
|
||||||
params = json.loads(tool_input)
|
params = json.loads(tool_input)
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
return json.dumps({
|
return json.dumps(
|
||||||
"error": f"Invalid JSON input: {e}",
|
{
|
||||||
"hint": 'Expected format: {"device_type": "xxx"} or {"action": "list"}'
|
"error": f"Invalid JSON input: {e}",
|
||||||
}, ensure_ascii=False, indent=2)
|
"hint": 'Expected format: {"device_type": "xxx"} or {"action": "list"}',
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
params = tool_input
|
params = tool_input
|
||||||
|
|
||||||
@ -722,18 +719,19 @@ class DeviceSkillsTool(BaseTool):
|
|||||||
|
|
||||||
if action == "list":
|
if action == "list":
|
||||||
skills = list_available_skills()
|
skills = list_available_skills()
|
||||||
return json.dumps({
|
return json.dumps({"count": len(skills), "skills": skills}, ensure_ascii=False, indent=2)
|
||||||
"count": len(skills),
|
|
||||||
"skills": skills
|
|
||||||
}, ensure_ascii=False, indent=2)
|
|
||||||
|
|
||||||
device_type = params.get("device_type")
|
device_type = params.get("device_type")
|
||||||
if not device_type:
|
if not device_type:
|
||||||
return json.dumps({
|
return json.dumps(
|
||||||
"error": "Missing required field: device_type",
|
{
|
||||||
"available_device_types": list(SKILLS_REGISTRY.keys()),
|
"error": "Missing required field: device_type",
|
||||||
"hint": 'Use {"action": "list"} to see all available device types'
|
"available_device_types": list(SKILLS_REGISTRY.keys()),
|
||||||
}, ensure_ascii=False, indent=2)
|
"hint": 'Use {"action": "list"} to see all available device types',
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
|
||||||
category = params.get("category")
|
category = params.get("category")
|
||||||
detail = params.get("detail", "full")
|
detail = params.get("detail", "full")
|
||||||
@ -788,10 +786,14 @@ class InjectionSkillsTool(BaseTool):
|
|||||||
try:
|
try:
|
||||||
params = json.loads(tool_input)
|
params = json.loads(tool_input)
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
return json.dumps({
|
return json.dumps(
|
||||||
"error": f"Invalid JSON input: {e}",
|
{
|
||||||
"hint": 'Expected format: {"device_type": "xxx"} or {"action": "list"}'
|
"error": f"Invalid JSON input: {e}",
|
||||||
}, ensure_ascii=False, indent=2)
|
"hint": 'Expected format: {"device_type": "xxx"} or {"action": "list"}',
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
params = tool_input
|
params = tool_input
|
||||||
|
|
||||||
@ -800,33 +802,44 @@ class InjectionSkillsTool(BaseTool):
|
|||||||
if action == "list":
|
if action == "list":
|
||||||
context = params.get("context")
|
context = params.get("context")
|
||||||
if not context or not isinstance(context, list) or len(context) == 0:
|
if not context or not isinstance(context, list) or len(context) == 0:
|
||||||
return json.dumps({
|
return json.dumps(
|
||||||
"error": "context parameter is required when action='list'",
|
{
|
||||||
"hint": "Analyze the topology and device configurations first, "
|
"error": "context parameter is required when action='list'",
|
||||||
"then pass the protocols/services you found as context. "
|
"hint": "Analyze the topology and device configurations first, "
|
||||||
'Example: {"action": "list", "context": ["ospf", "bgp", "vlan"]}',
|
"then pass the protocols/services you found as context. "
|
||||||
"available_categories": sorted(set(
|
'Example: {"action": "list", "context": ["ospf", "bgp", "vlan"]}',
|
||||||
skill.get("category", "")
|
"available_categories": sorted(
|
||||||
for skill in INJECTION_SKILLS_REGISTRY.values()
|
set(skill.get("category", "") for skill in INJECTION_SKILLS_REGISTRY.values())
|
||||||
))
|
),
|
||||||
}, ensure_ascii=False, indent=2)
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
|
||||||
skills = list_available_injection_skills(context=context)
|
skills = list_available_injection_skills(context=context)
|
||||||
logger.debug(f"Injection skills filtered by context={context}: {len(skills)} matching")
|
logger.debug(f"Injection skills filtered by context={context}: {len(skills)} matching")
|
||||||
return json.dumps({
|
return json.dumps(
|
||||||
"count": len(skills),
|
{
|
||||||
"total_available": len(INJECTION_SKILLS_REGISTRY),
|
"count": len(skills),
|
||||||
"context": context,
|
"total_available": len(INJECTION_SKILLS_REGISTRY),
|
||||||
"fault_types": skills
|
"context": context,
|
||||||
}, ensure_ascii=False, indent=2)
|
"fault_types": skills,
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
|
||||||
device_type = params.get("device_type")
|
device_type = params.get("device_type")
|
||||||
if not device_type:
|
if not device_type:
|
||||||
return json.dumps({
|
return json.dumps(
|
||||||
"error": "Missing required field: device_type",
|
{
|
||||||
"available_fault_types": list(INJECTION_SKILLS_REGISTRY.keys()),
|
"error": "Missing required field: device_type",
|
||||||
"hint": 'Use {"action": "list"} to see all available fault types'
|
"available_fault_types": list(INJECTION_SKILLS_REGISTRY.keys()),
|
||||||
}, ensure_ascii=False, indent=2)
|
"hint": 'Use {"action": "list"} to see all available fault types',
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
|
||||||
detail = params.get("detail", "full")
|
detail = params.get("detail", "full")
|
||||||
issue = params.get("issue")
|
issue = params.get("issue")
|
||||||
@ -876,10 +889,14 @@ class PacketAnalysisSkillsTool(BaseTool):
|
|||||||
try:
|
try:
|
||||||
params = json.loads(tool_input)
|
params = json.loads(tool_input)
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
return json.dumps({
|
return json.dumps(
|
||||||
"error": f"Invalid JSON input: {e}",
|
{
|
||||||
"hint": 'Expected format: {"action": "get", "protocol": "ospf"}'
|
"error": f"Invalid JSON input: {e}",
|
||||||
}, ensure_ascii=False, indent=2)
|
"hint": 'Expected format: {"action": "get", "protocol": "ospf"}',
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
params = tool_input
|
params = tool_input
|
||||||
|
|
||||||
@ -887,17 +904,18 @@ class PacketAnalysisSkillsTool(BaseTool):
|
|||||||
|
|
||||||
if action == "list":
|
if action == "list":
|
||||||
protocols = list_available_packet_analysis_protocols()
|
protocols = list_available_packet_analysis_protocols()
|
||||||
return json.dumps({
|
return json.dumps({"count": len(protocols), "protocols": protocols}, ensure_ascii=False, indent=2)
|
||||||
"count": len(protocols),
|
|
||||||
"protocols": protocols
|
|
||||||
}, ensure_ascii=False, indent=2)
|
|
||||||
|
|
||||||
protocol = params.get("protocol")
|
protocol = params.get("protocol")
|
||||||
if not protocol:
|
if not protocol:
|
||||||
return json.dumps({
|
return json.dumps(
|
||||||
"error": "Missing required field: protocol",
|
{
|
||||||
"available_protocols": list(PACKET_ANALYSIS_REGISTRY.keys()),
|
"error": "Missing required field: protocol",
|
||||||
}, ensure_ascii=False, indent=2)
|
"available_protocols": list(PACKET_ANALYSIS_REGISTRY.keys()),
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
|
||||||
result = get_packet_analysis_protocol(protocol)
|
result = get_packet_analysis_protocol(protocol)
|
||||||
return json.dumps(result, ensure_ascii=False, indent=2)
|
return json.dumps(result, ensure_ascii=False, indent=2)
|
||||||
|
|||||||
@ -196,56 +196,35 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
|
|||||||
|
|
||||||
# Validate input
|
# Validate input
|
||||||
device_configs_list, project_id = self._validate_tool_input(tool_input)
|
device_configs_list, project_id = self._validate_tool_input(tool_input)
|
||||||
if (
|
if isinstance(device_configs_list, list) and len(device_configs_list) > 0 and "error" in device_configs_list[0]:
|
||||||
isinstance(device_configs_list, list)
|
|
||||||
and len(device_configs_list) > 0
|
|
||||||
and "error" in device_configs_list[0]
|
|
||||||
):
|
|
||||||
return device_configs_list
|
return device_configs_list
|
||||||
|
|
||||||
# Filter forbidden commands and store blocked commands info
|
# Filter forbidden commands and store blocked commands info
|
||||||
device_configs_list, blocked_commands_map = (
|
device_configs_list, blocked_commands_map = self._filter_forbidden_commands_from_device_configs(
|
||||||
self._filter_forbidden_commands_from_device_configs(
|
device_configs_list
|
||||||
device_configs_list
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Expand multiline commands (e.g., banner commands with embedded newlines)
|
# Expand multiline commands (e.g., banner commands with embedded newlines)
|
||||||
# This converts commands like "banner motd #\nline1\nline2\n#" into
|
# This converts commands like "banner motd #\nline1\nline2\n#" into
|
||||||
# separate commands: ["banner motd #", "line1", "line2", "#"]
|
# separate commands: ["banner motd #", "line1", "line2", "#"]
|
||||||
device_configs_list = self._expand_multiline_commands(
|
device_configs_list = self._expand_multiline_commands(device_configs_list)
|
||||||
device_configs_list
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create a mapping of device names to their configuration commands
|
# Create a mapping of device names to their configuration commands
|
||||||
device_configs_map = self._configs_map(device_configs_list)
|
device_configs_map = self._configs_map(device_configs_list)
|
||||||
|
|
||||||
# Prepare device hosts data
|
# Prepare device hosts data
|
||||||
try:
|
try:
|
||||||
hosts_data = self._prepare_device_hosts_data(
|
hosts_data = self._prepare_device_hosts_data(device_configs_list, project_id, jwt_token=jwt_token, url=url)
|
||||||
device_configs_list, project_id, jwt_token=jwt_token, url=url
|
|
||||||
)
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
logger.error("Failed to prepare device hosts data: %s", e)
|
logger.error("Failed to prepare device hosts data: %s", e)
|
||||||
return [{"status": "failed", "error": str(e)}]
|
return [{"status": "failed", "error": str(e)}]
|
||||||
|
|
||||||
# Check if any devices have errors (e.g., missing device_type tag)
|
# Check if any devices have errors (e.g., missing device_type tag)
|
||||||
error_devices = {
|
error_devices = {name: data for name, data in hosts_data.items() if "error" in data}
|
||||||
name: data
|
|
||||||
for name, data in hosts_data.items()
|
|
||||||
if "error" in data
|
|
||||||
}
|
|
||||||
if error_devices:
|
if error_devices:
|
||||||
logger.error(
|
logger.error("Devices with configuration errors: %s", list(error_devices.keys()))
|
||||||
"Devices with configuration errors: %s",
|
|
||||||
list(error_devices.keys())
|
|
||||||
)
|
|
||||||
return [
|
return [
|
||||||
{
|
{"device_name": name, "status": "failed", "error": data["error"]}
|
||||||
"device_name": name,
|
|
||||||
"status": "failed",
|
|
||||||
"error": data["error"]
|
|
||||||
}
|
|
||||||
for name, data in error_devices.items()
|
for name, data in error_devices.items()
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -275,9 +254,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Overall execution failed
|
# Overall execution failed
|
||||||
logger.error(
|
logger.error("Error executing configurations on all devices: %s", e)
|
||||||
"Error executing configurations on all devices: %s", e
|
|
||||||
)
|
|
||||||
return [{"status": "failed", "error": f"Execution error: {str(e)}"}]
|
return [{"status": "failed", "error": f"Execution error: {str(e)}"}]
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
@ -287,22 +264,16 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
|
|||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
def _run_all_device_configs_with_single_retry(
|
def _run_all_device_configs_with_single_retry(self, task: Task, device_configs_map: dict[str, list[str]]) -> Result:
|
||||||
self, task: Task, device_configs_map: dict[str, list[str]]
|
|
||||||
) -> Result:
|
|
||||||
"""Execute configuration commands with single retry mechanism."""
|
"""Execute configuration commands with single retry mechanism."""
|
||||||
device_name = task.host.name
|
device_name = task.host.name
|
||||||
config_commands = device_configs_map.get(device_name, [])
|
config_commands = device_configs_map.get(device_name, [])
|
||||||
|
|
||||||
if not config_commands:
|
if not config_commands:
|
||||||
return Result(
|
return Result(host=task.host, result="No configuration commands to execute")
|
||||||
host=task.host, result="No configuration commands to execute"
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
_result = task.run(
|
_result = task.run(task=netmiko_send_config, config_commands=config_commands)
|
||||||
task=netmiko_send_config, config_commands=config_commands
|
|
||||||
)
|
|
||||||
return Result(host=task.host, result=_result.result)
|
return Result(host=task.host, result=_result.result)
|
||||||
|
|
||||||
except ReadTimeout as e:
|
except ReadTimeout as e:
|
||||||
@ -322,9 +293,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
|
|||||||
# Handle Cisco IOSv L2 where '#' prompt char may be delayed,
|
# Handle Cisco IOSv L2 where '#' prompt char may be delayed,
|
||||||
# causing Netmiko failures. Implements retry logic.
|
# causing Netmiko failures. Implements retry logic.
|
||||||
if "netmiko_send_config (failed)" in str(e):
|
if "netmiko_send_config (failed)" in str(e):
|
||||||
_result = task.run(
|
_result = task.run(task=netmiko_send_config, config_commands=config_commands)
|
||||||
task=netmiko_send_config, config_commands=config_commands
|
|
||||||
)
|
|
||||||
return Result(host=task.host, result=_result.result)
|
return Result(host=task.host, result=_result.result)
|
||||||
|
|
||||||
# Log any other exceptions with full details
|
# Log any other exceptions with full details
|
||||||
@ -364,9 +333,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
|
|||||||
parsed_input = json.loads(tool_input)
|
parsed_input = json.loads(tool_input)
|
||||||
logger.debug("Successfully parsed tool input from JSON string.")
|
logger.debug("Successfully parsed tool input from JSON string.")
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
logger.error(
|
logger.error("Invalid JSON string received as tool input: %s", e)
|
||||||
"Invalid JSON string received as tool input: %s", e
|
|
||||||
)
|
|
||||||
return (
|
return (
|
||||||
[{"status": "failed", "error": f"Invalid JSON string input from model: {e}"}],
|
[{"status": "failed", "error": f"Invalid JSON string input from model: {e}"}],
|
||||||
None,
|
None,
|
||||||
@ -392,10 +359,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
|
|||||||
return ([{"status": "failed", "error": error_msg}], None)
|
return ([{"status": "failed", "error": error_msg}], None)
|
||||||
|
|
||||||
if not self._validate_project_id(project_id):
|
if not self._validate_project_id(project_id):
|
||||||
error_msg = (
|
error_msg = f"Invalid project_id format: {project_id}. Expected UUID format."
|
||||||
f"Invalid project_id format: {project_id}. "
|
|
||||||
"Expected UUID format."
|
|
||||||
)
|
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
return ([{"status": "failed", "error": error_msg}], None)
|
return ([{"status": "failed", "error": error_msg}], None)
|
||||||
|
|
||||||
@ -413,10 +377,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
|
|||||||
|
|
||||||
# Handle legacy format: [...]
|
# Handle legacy format: [...]
|
||||||
elif isinstance(parsed_input, list):
|
elif isinstance(parsed_input, list):
|
||||||
logger.warning(
|
logger.warning("Using legacy format without project_id. Please use new format with project_id.")
|
||||||
"Using legacy format without project_id. "
|
|
||||||
"Please use new format with project_id."
|
|
||||||
)
|
|
||||||
return parsed_input, None
|
return parsed_input, None
|
||||||
|
|
||||||
else:
|
else:
|
||||||
@ -438,9 +399,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
|
|||||||
Returns:
|
Returns:
|
||||||
True if valid UUID format, False otherwise
|
True if valid UUID format, False otherwise
|
||||||
"""
|
"""
|
||||||
uuid_pattern = (
|
uuid_pattern = r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
|
||||||
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
|
|
||||||
)
|
|
||||||
return bool(re.match(uuid_pattern, project_id, re.IGNORECASE))
|
return bool(re.match(uuid_pattern, project_id, re.IGNORECASE))
|
||||||
|
|
||||||
def _filter_forbidden_commands_from_device_configs(
|
def _filter_forbidden_commands_from_device_configs(
|
||||||
@ -465,9 +424,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
|
|||||||
commands = device_config["config_commands"]
|
commands = device_config["config_commands"]
|
||||||
|
|
||||||
# Filter commands
|
# Filter commands
|
||||||
allowed_commands, blocked_info = filter_forbidden_commands(
|
allowed_commands, blocked_info = filter_forbidden_commands(commands)
|
||||||
commands
|
|
||||||
)
|
|
||||||
|
|
||||||
# Update device config with allowed commands only
|
# Update device config with allowed commands only
|
||||||
filtered_config = device_config.copy()
|
filtered_config = device_config.copy()
|
||||||
@ -486,9 +443,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
|
|||||||
|
|
||||||
return filtered_list, blocked_commands_map
|
return filtered_list, blocked_commands_map
|
||||||
|
|
||||||
def _expand_multiline_commands(
|
def _expand_multiline_commands(self, device_configs_list: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
self, device_configs_list: list[dict[str, Any]]
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
"""
|
"""
|
||||||
Expand commands that contain embedded newline characters.
|
Expand commands that contain embedded newline characters.
|
||||||
|
|
||||||
@ -540,9 +495,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
|
|||||||
|
|
||||||
return expanded_list
|
return expanded_list
|
||||||
|
|
||||||
def _configs_map(
|
def _configs_map(self, device_config_list: list[dict[str, Any]]) -> dict[str, list[str]]:
|
||||||
self, device_config_list: list[dict[str, Any]]
|
|
||||||
) -> dict[str, list[str]]:
|
|
||||||
"""Create a mapping of device names to their configuration commands."""
|
"""Create a mapping of device names to their configuration commands."""
|
||||||
device_configs_map = {}
|
device_configs_map = {}
|
||||||
for device_config in device_config_list:
|
for device_config in device_config_list:
|
||||||
@ -561,15 +514,10 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
|
|||||||
) -> dict[str, dict[str, Any]]:
|
) -> dict[str, dict[str, Any]]:
|
||||||
"""Prepare device hosts data from topology information."""
|
"""Prepare device hosts data from topology information."""
|
||||||
# Extract device names list
|
# Extract device names list
|
||||||
device_names = [
|
device_names = [device_config["device_name"] for device_config in device_config_list]
|
||||||
device_config["device_name"]
|
|
||||||
for device_config in device_config_list
|
|
||||||
]
|
|
||||||
|
|
||||||
# Get device port information with project_id
|
# Get device port information with project_id
|
||||||
hosts_data = get_device_ports_from_topology(
|
hosts_data = get_device_ports_from_topology(device_names, project_id, jwt_token=jwt_token, url=url)
|
||||||
device_names, project_id, jwt_token=jwt_token, url=url
|
|
||||||
)
|
|
||||||
|
|
||||||
if not hosts_data:
|
if not hosts_data:
|
||||||
error_msg = (
|
error_msg = (
|
||||||
@ -589,9 +537,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
|
|||||||
|
|
||||||
return hosts_data
|
return hosts_data
|
||||||
|
|
||||||
def _initialize_nornir(
|
def _initialize_nornir(self, hosts_data: dict[str, dict[str, Any]]) -> Nornir:
|
||||||
self, hosts_data: dict[str, dict[str, Any]]
|
|
||||||
) -> Nornir:
|
|
||||||
"""
|
"""
|
||||||
Initialize Nornir with the provided hosts data.
|
Initialize Nornir with the provided hosts data.
|
||||||
|
|
||||||
@ -616,8 +562,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
|
|||||||
|
|
||||||
# Log device types being configured
|
# Log device types being configured
|
||||||
device_types = [
|
device_types = [
|
||||||
host["connection_options"]["netmiko"]["extras"]["device_type"]
|
host["connection_options"]["netmiko"]["extras"]["device_type"] for host in hosts_data.values()
|
||||||
for host in hosts_data.values()
|
|
||||||
]
|
]
|
||||||
logger.info(
|
logger.info(
|
||||||
"Initializing Nornir: host=%s, device_types=%s, hosts=%d",
|
"Initializing Nornir: host=%s, device_types=%s, hosts=%d",
|
||||||
@ -665,16 +610,11 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
|
|||||||
device_result = {
|
device_result = {
|
||||||
"device_name": device_name,
|
"device_name": device_name,
|
||||||
"status": "failed",
|
"status": "failed",
|
||||||
"error": (
|
"error": (f"Device '{device_name}' not found in topology or missing console_port"),
|
||||||
f"Device '{device_name}' not found in topology or "
|
|
||||||
"missing console_port"
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
# Add blocked commands info if any
|
# Add blocked commands info if any
|
||||||
if blocked_commands_info:
|
if blocked_commands_info:
|
||||||
device_result["blocked_commands"] = list(
|
device_result["blocked_commands"] = list(blocked_commands_info.keys())
|
||||||
blocked_commands_info.keys()
|
|
||||||
)
|
|
||||||
device_result["blocked_info"] = blocked_commands_info
|
device_result["blocked_info"] = blocked_commands_info
|
||||||
results.append(device_result)
|
results.append(device_result)
|
||||||
continue
|
continue
|
||||||
@ -684,15 +624,11 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
|
|||||||
device_result = {
|
device_result = {
|
||||||
"device_name": device_name,
|
"device_name": device_name,
|
||||||
"status": "failed",
|
"status": "failed",
|
||||||
"error": (
|
"error": (f"Device '{device_name}' not found in task results"),
|
||||||
f"Device '{device_name}' not found in task results"
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
# Add blocked commands info if any
|
# Add blocked commands info if any
|
||||||
if blocked_commands_info:
|
if blocked_commands_info:
|
||||||
device_result["blocked_commands"] = list(
|
device_result["blocked_commands"] = list(blocked_commands_info.keys())
|
||||||
blocked_commands_info.keys()
|
|
||||||
)
|
|
||||||
device_result["blocked_info"] = blocked_commands_info
|
device_result["blocked_info"] = blocked_commands_info
|
||||||
results.append(device_result)
|
results.append(device_result)
|
||||||
continue
|
continue
|
||||||
@ -704,9 +640,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
|
|||||||
if multi_result[0].failed:
|
if multi_result[0].failed:
|
||||||
# Execution failed
|
# Execution failed
|
||||||
device_result["status"] = "failed"
|
device_result["status"] = "failed"
|
||||||
device_result["error"] = (
|
device_result["error"] = f"Configuration execution failed: {multi_result[0].result}"
|
||||||
f"Configuration execution failed: {multi_result[0].result}"
|
|
||||||
)
|
|
||||||
device_result["output"] = multi_result[0].result
|
device_result["output"] = multi_result[0].result
|
||||||
else:
|
else:
|
||||||
# Execution successful
|
# Execution successful
|
||||||
@ -716,9 +650,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
|
|||||||
|
|
||||||
# Add blocked commands info if any
|
# Add blocked commands info if any
|
||||||
if blocked_commands_info:
|
if blocked_commands_info:
|
||||||
device_result["blocked_commands"] = list(
|
device_result["blocked_commands"] = list(blocked_commands_info.keys())
|
||||||
blocked_commands_info.keys()
|
|
||||||
)
|
|
||||||
device_result["blocked_info"] = blocked_commands_info
|
device_result["blocked_info"] = blocked_commands_info
|
||||||
# Update status if some commands were blocked but succeeded
|
# Update status if some commands were blocked but succeeded
|
||||||
if device_result["status"] == "success":
|
if device_result["status"] == "success":
|
||||||
|
|||||||
@ -199,18 +199,12 @@ class ExecuteMultipleDeviceCommands(BaseTool):
|
|||||||
|
|
||||||
# Validate input
|
# Validate input
|
||||||
device_configs_list, project_id = self._validate_tool_input(tool_input)
|
device_configs_list, project_id = self._validate_tool_input(tool_input)
|
||||||
if (
|
if isinstance(device_configs_list, list) and len(device_configs_list) > 0 and "error" in device_configs_list[0]:
|
||||||
isinstance(device_configs_list, list)
|
|
||||||
and len(device_configs_list) > 0
|
|
||||||
and "error" in device_configs_list[0]
|
|
||||||
):
|
|
||||||
return device_configs_list
|
return device_configs_list
|
||||||
|
|
||||||
# Filter forbidden commands and store blocked commands info
|
# Filter forbidden commands and store blocked commands info
|
||||||
device_configs_list, blocked_commands_map = (
|
device_configs_list, blocked_commands_map = self._filter_forbidden_commands_from_device_configs(
|
||||||
self._filter_forbidden_commands_from_device_configs(
|
device_configs_list
|
||||||
device_configs_list
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create a mapping of device names to their display commands
|
# Create a mapping of device names to their display commands
|
||||||
@ -218,30 +212,17 @@ class ExecuteMultipleDeviceCommands(BaseTool):
|
|||||||
|
|
||||||
# Prepare device hosts data
|
# Prepare device hosts data
|
||||||
try:
|
try:
|
||||||
hosts_data = self._prepare_device_hosts_data(
|
hosts_data = self._prepare_device_hosts_data(device_configs_list, project_id, jwt_token=jwt_token, url=url)
|
||||||
device_configs_list, project_id, jwt_token=jwt_token, url=url
|
|
||||||
)
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
logger.error("Failed to prepare device hosts data: %s", e)
|
logger.error("Failed to prepare device hosts data: %s", e)
|
||||||
return [{"status": "failed", "error": str(e)}]
|
return [{"status": "failed", "error": str(e)}]
|
||||||
|
|
||||||
# Check if any devices have errors (e.g., missing device_type tag)
|
# Check if any devices have errors (e.g., missing device_type tag)
|
||||||
error_devices = {
|
error_devices = {name: data for name, data in hosts_data.items() if "error" in data}
|
||||||
name: data
|
|
||||||
for name, data in hosts_data.items()
|
|
||||||
if "error" in data
|
|
||||||
}
|
|
||||||
if error_devices:
|
if error_devices:
|
||||||
logger.error(
|
logger.error("Devices with configuration errors: %s", list(error_devices.keys()))
|
||||||
"Devices with configuration errors: %s",
|
|
||||||
list(error_devices.keys())
|
|
||||||
)
|
|
||||||
return [
|
return [
|
||||||
{
|
{"device_name": name, "status": "failed", "error": data["error"]}
|
||||||
"device_name": name,
|
|
||||||
"status": "failed",
|
|
||||||
"error": data["error"]
|
|
||||||
}
|
|
||||||
for name, data in error_devices.items()
|
for name, data in error_devices.items()
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -281,17 +262,13 @@ class ExecuteMultipleDeviceCommands(BaseTool):
|
|||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
def _run_all_device_configs_with_single_retry(
|
def _run_all_device_configs_with_single_retry(self, task: Task, device_configs_map: dict[str, list[str]]) -> Result:
|
||||||
self, task: Task, device_configs_map: dict[str, list[str]]
|
|
||||||
) -> Result:
|
|
||||||
"""Execute READ-ONLY diagnostic commands with single retry."""
|
"""Execute READ-ONLY diagnostic commands with single retry."""
|
||||||
device_name = task.host.name
|
device_name = task.host.name
|
||||||
diagnostic_commands = device_configs_map.get(device_name, [])
|
diagnostic_commands = device_configs_map.get(device_name, [])
|
||||||
|
|
||||||
if not diagnostic_commands:
|
if not diagnostic_commands:
|
||||||
return Result(
|
return Result(host=task.host, result="No diagnostic commands to execute")
|
||||||
host=task.host, result="No diagnostic commands to execute"
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
_result = task.run(
|
_result = task.run(
|
||||||
@ -366,9 +343,7 @@ class ExecuteMultipleDeviceCommands(BaseTool):
|
|||||||
parsed_input = json.loads(tool_input)
|
parsed_input = json.loads(tool_input)
|
||||||
logger.info("Successfully parsed tool input from JSON string.")
|
logger.info("Successfully parsed tool input from JSON string.")
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
logger.error(
|
logger.error("Invalid JSON string received as tool input: %s", e)
|
||||||
"Invalid JSON string received as tool input: %s", e
|
|
||||||
)
|
|
||||||
return (
|
return (
|
||||||
[{"status": "failed", "error": f"Invalid JSON string input from model: {e}"}],
|
[{"status": "failed", "error": f"Invalid JSON string input from model: {e}"}],
|
||||||
None,
|
None,
|
||||||
@ -412,9 +387,7 @@ class ExecuteMultipleDeviceCommands(BaseTool):
|
|||||||
|
|
||||||
# Handle legacy format: [...]
|
# Handle legacy format: [...]
|
||||||
elif isinstance(parsed_input, list):
|
elif isinstance(parsed_input, list):
|
||||||
logger.warning(
|
logger.warning("Legacy input format without project_id. Use new format.")
|
||||||
"Legacy input format without project_id. Use new format."
|
|
||||||
)
|
|
||||||
return parsed_input, None
|
return parsed_input, None
|
||||||
|
|
||||||
else:
|
else:
|
||||||
@ -435,9 +408,7 @@ class ExecuteMultipleDeviceCommands(BaseTool):
|
|||||||
Returns:
|
Returns:
|
||||||
True if valid UUID format, False otherwise
|
True if valid UUID format, False otherwise
|
||||||
"""
|
"""
|
||||||
uuid_pattern = (
|
uuid_pattern = r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
|
||||||
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
|
|
||||||
)
|
|
||||||
return bool(re.match(uuid_pattern, project_id, re.IGNORECASE))
|
return bool(re.match(uuid_pattern, project_id, re.IGNORECASE))
|
||||||
|
|
||||||
def _filter_forbidden_commands_from_device_configs(
|
def _filter_forbidden_commands_from_device_configs(
|
||||||
@ -462,9 +433,7 @@ class ExecuteMultipleDeviceCommands(BaseTool):
|
|||||||
commands = device_config["commands"]
|
commands = device_config["commands"]
|
||||||
|
|
||||||
# Filter commands
|
# Filter commands
|
||||||
allowed_commands, blocked_info = filter_forbidden_commands(
|
allowed_commands, blocked_info = filter_forbidden_commands(commands)
|
||||||
commands
|
|
||||||
)
|
|
||||||
|
|
||||||
# Update device config with allowed commands only
|
# Update device config with allowed commands only
|
||||||
filtered_config = device_config.copy()
|
filtered_config = device_config.copy()
|
||||||
@ -483,9 +452,7 @@ class ExecuteMultipleDeviceCommands(BaseTool):
|
|||||||
|
|
||||||
return filtered_list, blocked_commands_map
|
return filtered_list, blocked_commands_map
|
||||||
|
|
||||||
def _configs_map(
|
def _configs_map(self, device_config_list: list[dict[str, Any]]) -> dict[str, list[str]]:
|
||||||
self, device_config_list: list[dict[str, Any]]
|
|
||||||
) -> dict[str, list[str]]:
|
|
||||||
"""Create a mapping of device names to their diagnostic commands."""
|
"""Create a mapping of device names to their diagnostic commands."""
|
||||||
device_diagnostic_map = {}
|
device_diagnostic_map = {}
|
||||||
for device_config in device_config_list:
|
for device_config in device_config_list:
|
||||||
@ -504,21 +471,13 @@ class ExecuteMultipleDeviceCommands(BaseTool):
|
|||||||
) -> dict[str, dict[str, Any]]:
|
) -> dict[str, dict[str, Any]]:
|
||||||
"""Prepare device hosts data from topology information."""
|
"""Prepare device hosts data from topology information."""
|
||||||
# Extract device names list
|
# Extract device names list
|
||||||
device_names = [
|
device_names = [device_config["device_name"] for device_config in device_config_list]
|
||||||
device_config["device_name"]
|
|
||||||
for device_config in device_config_list
|
|
||||||
]
|
|
||||||
|
|
||||||
# Get device port information with project_id
|
# Get device port information with project_id
|
||||||
hosts_data = get_device_ports_from_topology(
|
hosts_data = get_device_ports_from_topology(device_names, project_id, jwt_token=jwt_token, url=url)
|
||||||
device_names, project_id, jwt_token=jwt_token, url=url
|
|
||||||
)
|
|
||||||
|
|
||||||
if not hosts_data:
|
if not hosts_data:
|
||||||
error_msg = (
|
error_msg = f"Failed to get device info from topology. Project: {project_id}, Devices: {device_names}"
|
||||||
f"Failed to get device info from topology. "
|
|
||||||
f"Project: {project_id}, Devices: {device_names}"
|
|
||||||
)
|
|
||||||
raise ValueError(error_msg)
|
raise ValueError(error_msg)
|
||||||
|
|
||||||
# Check for missing devices
|
# Check for missing devices
|
||||||
@ -532,9 +491,7 @@ class ExecuteMultipleDeviceCommands(BaseTool):
|
|||||||
|
|
||||||
return hosts_data
|
return hosts_data
|
||||||
|
|
||||||
def _initialize_nornir(
|
def _initialize_nornir(self, hosts_data: dict[str, dict[str, Any]]) -> Nornir:
|
||||||
self, hosts_data: dict[str, dict[str, Any]]
|
|
||||||
) -> Nornir:
|
|
||||||
"""
|
"""
|
||||||
Initialize Nornir with the provided hosts data.
|
Initialize Nornir with the provided hosts data.
|
||||||
|
|
||||||
@ -559,8 +516,7 @@ class ExecuteMultipleDeviceCommands(BaseTool):
|
|||||||
|
|
||||||
# Log device types being configured
|
# Log device types being configured
|
||||||
device_types = [
|
device_types = [
|
||||||
host["connection_options"]["netmiko"]["extras"]["device_type"]
|
host["connection_options"]["netmiko"]["extras"]["device_type"] for host in hosts_data.values()
|
||||||
for host in hosts_data.values()
|
|
||||||
]
|
]
|
||||||
logger.info(
|
logger.info(
|
||||||
"Initializing Nornir: host=%s, device_types=%s, hosts=%d",
|
"Initializing Nornir: host=%s, device_types=%s, hosts=%d",
|
||||||
@ -608,16 +564,11 @@ class ExecuteMultipleDeviceCommands(BaseTool):
|
|||||||
device_result = {
|
device_result = {
|
||||||
"device_name": device_name,
|
"device_name": device_name,
|
||||||
"status": "failed",
|
"status": "failed",
|
||||||
"error": (
|
"error": (f"Device '{device_name}' not found in topology or missing console_port"),
|
||||||
f"Device '{device_name}' not found in topology "
|
|
||||||
"or missing console_port"
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
# Add blocked commands info if any
|
# Add blocked commands info if any
|
||||||
if blocked_commands_info:
|
if blocked_commands_info:
|
||||||
device_result["blocked_commands"] = list(
|
device_result["blocked_commands"] = list(blocked_commands_info.keys())
|
||||||
blocked_commands_info.keys()
|
|
||||||
)
|
|
||||||
device_result["blocked_info"] = blocked_commands_info
|
device_result["blocked_info"] = blocked_commands_info
|
||||||
results.append(device_result)
|
results.append(device_result)
|
||||||
continue
|
continue
|
||||||
@ -627,15 +578,11 @@ class ExecuteMultipleDeviceCommands(BaseTool):
|
|||||||
device_result = {
|
device_result = {
|
||||||
"device_name": device_name,
|
"device_name": device_name,
|
||||||
"status": "failed",
|
"status": "failed",
|
||||||
"error": (
|
"error": (f"Device '{device_name}' not found in task results"),
|
||||||
f"Device '{device_name}' not found in task results"
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
# Add blocked commands info if any
|
# Add blocked commands info if any
|
||||||
if blocked_commands_info:
|
if blocked_commands_info:
|
||||||
device_result["blocked_commands"] = list(
|
device_result["blocked_commands"] = list(blocked_commands_info.keys())
|
||||||
blocked_commands_info.keys()
|
|
||||||
)
|
|
||||||
device_result["blocked_info"] = blocked_commands_info
|
device_result["blocked_info"] = blocked_commands_info
|
||||||
results.append(device_result)
|
results.append(device_result)
|
||||||
continue
|
continue
|
||||||
@ -647,9 +594,7 @@ class ExecuteMultipleDeviceCommands(BaseTool):
|
|||||||
if multi_result[0].failed:
|
if multi_result[0].failed:
|
||||||
# Execution failed
|
# Execution failed
|
||||||
device_result["status"] = "failed"
|
device_result["status"] = "failed"
|
||||||
device_result["error"] = (
|
device_result["error"] = f"Diagnostic command failed: {multi_result[0].result}"
|
||||||
f"Diagnostic command failed: {multi_result[0].result}"
|
|
||||||
)
|
|
||||||
device_result["output"] = multi_result[0].result
|
device_result["output"] = multi_result[0].result
|
||||||
else:
|
else:
|
||||||
# Execution successful
|
# Execution successful
|
||||||
@ -659,9 +604,7 @@ class ExecuteMultipleDeviceCommands(BaseTool):
|
|||||||
|
|
||||||
# Add blocked commands info if any
|
# Add blocked commands info if any
|
||||||
if blocked_commands_info:
|
if blocked_commands_info:
|
||||||
device_result["blocked_commands"] = list(
|
device_result["blocked_commands"] = list(blocked_commands_info.keys())
|
||||||
blocked_commands_info.keys()
|
|
||||||
)
|
|
||||||
device_result["blocked_info"] = blocked_commands_info
|
device_result["blocked_info"] = blocked_commands_info
|
||||||
# Update status if some commands were blocked but succeeded
|
# Update status if some commands were blocked but succeeded
|
||||||
if device_result["status"] == "success":
|
if device_result["status"] == "success":
|
||||||
|
|||||||
@ -132,9 +132,7 @@ class GNS3LinkTool(BaseTool):
|
|||||||
|
|
||||||
if not isinstance(links_data, list) or len(links_data) == 0:
|
if not isinstance(links_data, list) or len(links_data) == 0:
|
||||||
logger.error("Invalid links data: must be a non-empty array")
|
logger.error("Invalid links data: must be a non-empty array")
|
||||||
return [
|
return [{"error": "Invalid links data: must be a non-empty array"}]
|
||||||
{"error": "Invalid links data: must be a non-empty array"}
|
|
||||||
]
|
|
||||||
|
|
||||||
# Build handler context (JWT + server URL from request context)
|
# Build handler context (JWT + server URL from request context)
|
||||||
logger.info("Connecting to GNS3 server...")
|
logger.info("Connecting to GNS3 server...")
|
||||||
@ -142,14 +140,7 @@ class GNS3LinkTool(BaseTool):
|
|||||||
|
|
||||||
if gns3_ctx is None:
|
if gns3_ctx is None:
|
||||||
logger.error("Failed to create GNS3 connector")
|
logger.error("Failed to create GNS3 connector")
|
||||||
return [
|
return [{"error": ("Failed to connect to GNS3 server. Please check your configuration.")}]
|
||||||
{
|
|
||||||
"error": (
|
|
||||||
"Failed to connect to GNS3 server. "
|
|
||||||
"Please check your configuration."
|
|
||||||
)
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
# Fetch all nodes once for port resolution
|
# Fetch all nodes once for port resolution
|
||||||
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
|
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
|
||||||
@ -172,9 +163,7 @@ class GNS3LinkTool(BaseTool):
|
|||||||
|
|
||||||
# Validate link parameters
|
# Validate link parameters
|
||||||
if not all([node_id1, port1, node_id2, port2]):
|
if not all([node_id1, port1, node_id2, port2]):
|
||||||
error_msg = (
|
error_msg = f"Missing required fields in link definition {i}"
|
||||||
f"Missing required fields in link definition {i}"
|
|
||||||
)
|
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
created_links.append({"error": error_msg})
|
created_links.append({"error": error_msg})
|
||||||
continue
|
continue
|
||||||
@ -218,29 +207,15 @@ class GNS3LinkTool(BaseTool):
|
|||||||
"nodes": [
|
"nodes": [
|
||||||
{
|
{
|
||||||
"node_id": node_id1,
|
"node_id": node_id1,
|
||||||
"adapter_number": port1_info.get(
|
"adapter_number": port1_info.get("adapter_number", 0),
|
||||||
"adapter_number", 0
|
"port_number": port1_info.get("port_number", 0),
|
||||||
),
|
"label": {"text": port1_info.get("short_name") or port1},
|
||||||
"port_number": port1_info.get(
|
|
||||||
"port_number", 0
|
|
||||||
),
|
|
||||||
"label": {
|
|
||||||
"text": port1_info.get("short_name")
|
|
||||||
or port1
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"node_id": node_id2,
|
"node_id": node_id2,
|
||||||
"adapter_number": port2_info.get(
|
"adapter_number": port2_info.get("adapter_number", 0),
|
||||||
"adapter_number", 0
|
"port_number": port2_info.get("port_number", 0),
|
||||||
),
|
"label": {"text": port2_info.get("short_name") or port2},
|
||||||
"port_number": port2_info.get(
|
|
||||||
"port_number", 0
|
|
||||||
),
|
|
||||||
"label": {
|
|
||||||
"text": port2_info.get("short_name")
|
|
||||||
or port2
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"fields": ["link_id"],
|
"fields": ["link_id"],
|
||||||
@ -266,9 +241,7 @@ class GNS3LinkTool(BaseTool):
|
|||||||
created_links.append({"error": error_msg})
|
created_links.append({"error": error_msg})
|
||||||
|
|
||||||
# Log final results
|
# Log final results
|
||||||
success_count = len(
|
success_count = len([link for link in created_links if "error" not in link])
|
||||||
[link for link in created_links if "error" not in link]
|
|
||||||
)
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Link creation completed: %d successful, %d failed",
|
"Link creation completed: %d successful, %d failed",
|
||||||
success_count,
|
success_count,
|
||||||
|
|||||||
@ -169,9 +169,7 @@ class GNS3CreateNodeTool(BaseTool):
|
|||||||
# Validate each node in the array
|
# Validate each node in the array
|
||||||
for i, node_data in enumerate(nodes):
|
for i, node_data in enumerate(nodes):
|
||||||
if not isinstance(node_data, dict):
|
if not isinstance(node_data, dict):
|
||||||
logger.error(
|
logger.error("Invalid input: Node %d must be a dictionary.", i + 1)
|
||||||
"Invalid input: Node %d must be a dictionary.", i + 1
|
|
||||||
)
|
|
||||||
return {"error": f"Node {i + 1} must be a dictionary."}
|
return {"error": f"Node {i + 1} must be a dictionary."}
|
||||||
|
|
||||||
template_id = node_data.get("template_id")
|
template_id = node_data.get("template_id")
|
||||||
@ -187,14 +185,10 @@ class GNS3CreateNodeTool(BaseTool):
|
|||||||
]
|
]
|
||||||
):
|
):
|
||||||
logger.error(
|
logger.error(
|
||||||
"Invalid input: Node %d missing or invalid "
|
"Invalid input: Node %d missing or invalid template_id, x, or y.",
|
||||||
"template_id, x, or y.",
|
|
||||||
i + 1,
|
i + 1,
|
||||||
)
|
)
|
||||||
return {
|
return {"error": f"Node {i + 1} missing or invalid template_id, x, or y."}
|
||||||
"error": f"Node {i + 1} missing or invalid "
|
|
||||||
f"template_id, x, or y."
|
|
||||||
}
|
|
||||||
|
|
||||||
# Build handler context (JWT + server URL from request context)
|
# Build handler context (JWT + server URL from request context)
|
||||||
logger.info("Connecting to GNS3 server...")
|
logger.info("Connecting to GNS3 server...")
|
||||||
@ -202,15 +196,10 @@ class GNS3CreateNodeTool(BaseTool):
|
|||||||
|
|
||||||
if gns3_ctx is None:
|
if gns3_ctx is None:
|
||||||
logger.error("Failed to create GNS3 connector")
|
logger.error("Failed to create GNS3 connector")
|
||||||
return {
|
return {"error": "Failed to connect to GNS3 server. Please check your configuration."}
|
||||||
"error": "Failed to connect to GNS3 server. "
|
|
||||||
"Please check your configuration."
|
|
||||||
}
|
|
||||||
|
|
||||||
# Create nodes
|
# Create nodes
|
||||||
logger.info(
|
logger.info("Creating %d nodes in project %s...", len(nodes), project_id)
|
||||||
"Creating %d nodes in project %s...", len(nodes), project_id
|
|
||||||
)
|
|
||||||
results: list[dict[str, Any]] = []
|
results: list[dict[str, Any]] = []
|
||||||
|
|
||||||
for i, node_data in enumerate(nodes):
|
for i, node_data in enumerate(nodes):
|
||||||
@ -261,12 +250,8 @@ class GNS3CreateNodeTool(BaseTool):
|
|||||||
# Continue with next node even if one fails
|
# Continue with next node even if one fails
|
||||||
|
|
||||||
# Calculate summary statistics
|
# Calculate summary statistics
|
||||||
successful_nodes = len(
|
successful_nodes = len([r for r in results if r.get("status") == "success"])
|
||||||
[r for r in results if r.get("status") == "success"]
|
failed_nodes = len([r for r in results if r.get("status") == "failed"])
|
||||||
)
|
|
||||||
failed_nodes = len(
|
|
||||||
[r for r in results if r.get("status") == "failed"]
|
|
||||||
)
|
|
||||||
|
|
||||||
# Prepare final result
|
# Prepare final result
|
||||||
final_result = {
|
final_result = {
|
||||||
@ -293,9 +278,7 @@ class GNS3CreateNodeTool(BaseTool):
|
|||||||
return {"error": f"Invalid JSON input: {e}"}
|
return {"error": f"Invalid JSON input: {e}"}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Failed to process node creation request: %s", e)
|
logger.error("Failed to process node creation request: %s", e)
|
||||||
return {
|
return {"error": f"Failed to process node creation request: {str(e)}"}
|
||||||
"error": f"Failed to process node creation request: {str(e)}"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@ -46,12 +46,12 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
# Built-in templates to filter out (utility templates, not actual network devices)
|
# Built-in templates to filter out (utility templates, not actual network devices)
|
||||||
FILTERED_TEMPLATES = {
|
FILTERED_TEMPLATES = {
|
||||||
"atm_switch", # ATM switch
|
"atm_switch", # ATM switch
|
||||||
"cloud", # Cloud
|
"cloud", # Cloud
|
||||||
"ethernet_hub", # Ethernet hub
|
"ethernet_hub", # Ethernet hub
|
||||||
"ethernet_switch", # Ethernet switch (built-in)
|
"ethernet_switch", # Ethernet switch (built-in)
|
||||||
"frame_relay_switch", # Frame Relay switch
|
"frame_relay_switch", # Frame Relay switch
|
||||||
"nat", # NAT device
|
"nat", # NAT device
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -131,17 +131,10 @@ class GNS3TemplateTool(BaseTool):
|
|||||||
|
|
||||||
if gns3_server is None:
|
if gns3_server is None:
|
||||||
logger.error("Failed to create GNS3 connector")
|
logger.error("Failed to create GNS3 connector")
|
||||||
return {
|
return {"error": ("Failed to connect to GNS3 server. Please check your configuration.")}
|
||||||
"error": (
|
|
||||||
"Failed to connect to GNS3 server. "
|
|
||||||
"Please check your configuration."
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
# Retrieve all available templates
|
# Retrieve all available templates
|
||||||
templates = gns3_server.http_call(
|
templates = gns3_server.http_call("get", f"{gns3_server.base_url}/templates").json()
|
||||||
"get", f"{gns3_server.base_url}/templates"
|
|
||||||
).json()
|
|
||||||
|
|
||||||
# Filter out utility templates and extract relevant info
|
# Filter out utility templates and extract relevant info
|
||||||
template_info = []
|
template_info = []
|
||||||
@ -154,11 +147,13 @@ class GNS3TemplateTool(BaseTool):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Extract name, template_id, and template_type
|
# Extract name, template_id, and template_type
|
||||||
template_info.append({
|
template_info.append(
|
||||||
"name": template.get("name", "N/A"),
|
{
|
||||||
"template_id": template.get("template_id", "N/A"),
|
"name": template.get("name", "N/A"),
|
||||||
"template_type": template.get("template_type", "N/A"),
|
"template_id": template.get("template_id", "N/A"),
|
||||||
})
|
"template_type": template.get("template_type", "N/A"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# Return JSON-formatted result with full logging
|
# Return JSON-formatted result with full logging
|
||||||
result = {"templates": template_info}
|
result = {"templates": template_info}
|
||||||
@ -172,9 +167,7 @@ class GNS3TemplateTool(BaseTool):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error("Failed to connect to GNS3 server or retrieve templates: %s", e)
|
||||||
"Failed to connect to GNS3 server or retrieve templates: %s", e
|
|
||||||
)
|
|
||||||
return {"error": f"Failed to retrieve templates: {str(e)}"}
|
return {"error": f"Failed to retrieve templates: {str(e)}"}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -198,18 +198,14 @@ class GNS3PacketFilterTool(BaseTool):
|
|||||||
valid_actions = ["get_available", "set", "get", "clear"]
|
valid_actions = ["get_available", "set", "get", "clear"]
|
||||||
if action not in valid_actions:
|
if action not in valid_actions:
|
||||||
logger.error("Invalid action: %s. Must be one of %s", action, valid_actions)
|
logger.error("Invalid action: %s. Must be one of %s", action, valid_actions)
|
||||||
return {
|
return {"error": f"Invalid action: {action}. Must be one of {valid_actions}"}
|
||||||
"error": f"Invalid action: {action}. Must be one of {valid_actions}"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Validate filters for "set" action
|
# Validate filters for "set" action
|
||||||
if action == "set":
|
if action == "set":
|
||||||
filters = input_data.get("filters")
|
filters = input_data.get("filters")
|
||||||
if not filters or not isinstance(filters, dict):
|
if not filters or not isinstance(filters, dict):
|
||||||
logger.error("Invalid input: 'set' action requires 'filters' dict.")
|
logger.error("Invalid input: 'set' action requires 'filters' dict.")
|
||||||
return {
|
return {"error": "'set' action requires 'filters' dict with filter configuration."}
|
||||||
"error": "'set' action requires 'filters' dict with filter configuration."
|
|
||||||
}
|
|
||||||
|
|
||||||
# Build handler context (JWT + server URL from request context)
|
# Build handler context (JWT + server URL from request context)
|
||||||
logger.info("Connecting to GNS3 server...")
|
logger.info("Connecting to GNS3 server...")
|
||||||
@ -217,28 +213,19 @@ class GNS3PacketFilterTool(BaseTool):
|
|||||||
|
|
||||||
if gns3_ctx is None:
|
if gns3_ctx is None:
|
||||||
logger.error("Failed to create GNS3 connector")
|
logger.error("Failed to create GNS3 connector")
|
||||||
return {
|
return {"error": "Failed to connect to GNS3 server. Please check your configuration."}
|
||||||
"error": "Failed to connect to GNS3 server. "
|
|
||||||
"Please check your configuration."
|
|
||||||
}
|
|
||||||
|
|
||||||
# Execute action
|
# Execute action
|
||||||
logger.info(
|
logger.info("Processing packet filter action '%s' for link %s...", action, link_id)
|
||||||
"Processing packet filter action '%s' for link %s...", action, link_id
|
|
||||||
)
|
|
||||||
if action == "get_available":
|
if action == "get_available":
|
||||||
result = self._get_available_filters(gns3_ctx, project_id, link_id)
|
result = self._get_available_filters(gns3_ctx, project_id, link_id)
|
||||||
elif action == "set":
|
elif action == "set":
|
||||||
filters = input_data.get("filters", {})
|
filters = input_data.get("filters", {})
|
||||||
result = self._set_filters(
|
result = self._set_filters(gns3_ctx, project_id, link_id, filters, show_filters_icon)
|
||||||
gns3_ctx, project_id, link_id, filters, show_filters_icon
|
|
||||||
)
|
|
||||||
elif action == "get":
|
elif action == "get":
|
||||||
result = self._get_filters(gns3_ctx, project_id, link_id)
|
result = self._get_filters(gns3_ctx, project_id, link_id)
|
||||||
elif action == "clear":
|
elif action == "clear":
|
||||||
result = self._clear_filters(
|
result = self._clear_filters(gns3_ctx, project_id, link_id, show_filters_icon)
|
||||||
gns3_ctx, project_id, link_id, show_filters_icon
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
result = {"error": f"Unknown action: {action}"}
|
result = {"error": f"Unknown action: {action}"}
|
||||||
|
|
||||||
@ -252,18 +239,12 @@ class GNS3PacketFilterTool(BaseTool):
|
|||||||
return {"error": f"Invalid JSON input: {e}"}
|
return {"error": f"Invalid JSON input: {e}"}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Failed to process packet filter request: %s", e)
|
logger.error("Failed to process packet filter request: %s", e)
|
||||||
return {
|
return {"error": f"Failed to process packet filter request: {str(e)}"}
|
||||||
"error": f"Failed to process packet filter request: {str(e)}"
|
|
||||||
}
|
|
||||||
|
|
||||||
def _get_available_filters(
|
def _get_available_filters(self, gns3_ctx: dict, project_id: str, link_id: str) -> dict[str, Any]:
|
||||||
self, gns3_ctx: dict, project_id: str, link_id: str
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Get available filter types for the link."""
|
"""Get available filter types for the link."""
|
||||||
try:
|
try:
|
||||||
filters = available_filters_handler(
|
filters = available_filters_handler({"project_id": project_id, "link_id": link_id}, gns3_ctx)
|
||||||
{"project_id": project_id, "link_id": link_id}, gns3_ctx
|
|
||||||
)
|
|
||||||
if "error" in filters:
|
if "error" in filters:
|
||||||
raise RuntimeError(filters["error"])
|
raise RuntimeError(filters["error"])
|
||||||
logger.info("Retrieved %d available filter types.", len(filters))
|
logger.info("Retrieved %d available filter types.", len(filters))
|
||||||
@ -307,13 +288,9 @@ class GNS3PacketFilterTool(BaseTool):
|
|||||||
if "Invalid" in result.stdout or "Invalid" in result.stderr:
|
if "Invalid" in result.stdout or "Invalid" in result.stderr:
|
||||||
error_lines = []
|
error_lines = []
|
||||||
if "Invalid" in result.stderr:
|
if "Invalid" in result.stderr:
|
||||||
error_lines.extend(
|
error_lines.extend(line for line in result.stderr.split("\n") if "Invalid" in line)
|
||||||
line for line in result.stderr.split("\n") if "Invalid" in line
|
|
||||||
)
|
|
||||||
if "Invalid" in result.stdout:
|
if "Invalid" in result.stdout:
|
||||||
error_lines.extend(
|
error_lines.extend(line for line in result.stdout.split("\n") if "Invalid" in line)
|
||||||
line for line in result.stdout.split("\n") if "Invalid" in line
|
|
||||||
)
|
|
||||||
|
|
||||||
# Strip interface suffix (e.g., "for interface 'lo'") for cleaner error
|
# Strip interface suffix (e.g., "for interface 'lo'") for cleaner error
|
||||||
error_msg_parts = []
|
error_msg_parts = []
|
||||||
@ -336,10 +313,7 @@ class GNS3PacketFilterTool(BaseTool):
|
|||||||
|
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
# tshark not installed - skip validation
|
# tshark not installed - skip validation
|
||||||
logger.warning(
|
logger.warning("tshark not found, skipping BPF syntax validation. Install tshark to enable BPF validation.")
|
||||||
"tshark not found, skipping BPF syntax validation. "
|
|
||||||
"Install tshark to enable BPF validation."
|
|
||||||
)
|
|
||||||
return {"valid": True, "error": None}
|
return {"valid": True, "error": None}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -414,14 +388,10 @@ class GNS3PacketFilterTool(BaseTool):
|
|||||||
"status": "failed",
|
"status": "failed",
|
||||||
}
|
}
|
||||||
|
|
||||||
def _get_filters(
|
def _get_filters(self, gns3_ctx: dict, project_id: str, link_id: str) -> dict[str, Any]:
|
||||||
self, gns3_ctx: dict, project_id: str, link_id: str
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Get current filters configured on the link."""
|
"""Get current filters configured on the link."""
|
||||||
try:
|
try:
|
||||||
link = get_link_handler(
|
link = get_link_handler({"project_id": project_id, "link_id": link_id}, gns3_ctx)
|
||||||
{"project_id": project_id, "link_id": link_id}, gns3_ctx
|
|
||||||
)
|
|
||||||
if "error" in link:
|
if "error" in link:
|
||||||
raise RuntimeError(link["error"])
|
raise RuntimeError(link["error"])
|
||||||
|
|
||||||
|
|||||||
@ -106,13 +106,8 @@ class GNS3StartNodeTool(BaseTool):
|
|||||||
|
|
||||||
# Validate input
|
# Validate input
|
||||||
if not project_id or not node_ids:
|
if not project_id or not node_ids:
|
||||||
logger.error(
|
logger.error("Missing required fields: project_id or node_ids.")
|
||||||
"Missing required fields: project_id or node_ids."
|
return {"error": "Missing required fields: project_id and node_ids."}
|
||||||
)
|
|
||||||
return {
|
|
||||||
"error": "Missing required fields: "
|
|
||||||
"project_id and node_ids."
|
|
||||||
}
|
|
||||||
|
|
||||||
if not isinstance(node_ids, list):
|
if not isinstance(node_ids, list):
|
||||||
logger.error("node_ids must be a list.")
|
logger.error("node_ids must be a list.")
|
||||||
@ -124,10 +119,7 @@ class GNS3StartNodeTool(BaseTool):
|
|||||||
|
|
||||||
if gns3_ctx is None:
|
if gns3_ctx is None:
|
||||||
logger.error("Failed to create GNS3 connector")
|
logger.error("Failed to create GNS3 connector")
|
||||||
return {
|
return {"error": "Failed to connect to GNS3 server. Please check your configuration."}
|
||||||
"error": "Failed to connect to GNS3 server. "
|
|
||||||
"Please check your configuration."
|
|
||||||
}
|
|
||||||
|
|
||||||
# Verify nodes exist and capture pre-start info (one call)
|
# Verify nodes exist and capture pre-start info (one call)
|
||||||
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
|
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
|
||||||
@ -143,14 +135,8 @@ class GNS3StartNodeTool(BaseTool):
|
|||||||
)
|
)
|
||||||
results = []
|
results = []
|
||||||
known_ids = [nid for nid in node_ids if nid in nodes_by_id]
|
known_ids = [nid for nid in node_ids if nid in nodes_by_id]
|
||||||
start_results = start_node_handler(
|
start_results = start_node_handler({"project_id": project_id, "node_ids": known_ids}, gns3_ctx)
|
||||||
{"project_id": project_id, "node_ids": known_ids}, gns3_ctx
|
start_errors = {r["node_id"]: r.get("error") for r in start_results if r.get("status") == "error"}
|
||||||
)
|
|
||||||
start_errors = {
|
|
||||||
r["node_id"]: r.get("error")
|
|
||||||
for r in start_results
|
|
||||||
if r.get("status") == "error"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Get immediate status (likely 'starting' or 'stopped') — one call
|
# Get immediate status (likely 'starting' or 'stopped') — one call
|
||||||
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
|
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
|
||||||
@ -160,9 +146,7 @@ class GNS3StartNodeTool(BaseTool):
|
|||||||
|
|
||||||
for node_id in node_ids:
|
for node_id in node_ids:
|
||||||
if node_id not in nodes_by_id:
|
if node_id not in nodes_by_id:
|
||||||
logger.error(
|
logger.error("Node %s not found in project %s", node_id, project_id)
|
||||||
"Node %s not found in project %s", node_id, project_id
|
|
||||||
)
|
|
||||||
results.append(
|
results.append(
|
||||||
{
|
{
|
||||||
"node_id": node_id,
|
"node_id": node_id,
|
||||||
@ -204,9 +188,7 @@ class GNS3StartNodeTool(BaseTool):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Analyze results (count based on successful command sending)
|
# Analyze results (count based on successful command sending)
|
||||||
successful_nodes = [
|
successful_nodes = [r for r in results if r.get("status") != "error"]
|
||||||
r for r in results if r.get("status") != "error"
|
|
||||||
]
|
|
||||||
failed_nodes = [r for r in results if r.get("status") == "error"]
|
failed_nodes = [r for r in results if r.get("status") == "error"]
|
||||||
|
|
||||||
# Construct final response
|
# Construct final response
|
||||||
@ -217,8 +199,7 @@ class GNS3StartNodeTool(BaseTool):
|
|||||||
"failed": len(failed_nodes),
|
"failed": len(failed_nodes),
|
||||||
"nodes": results,
|
"nodes": results,
|
||||||
"note": (
|
"note": (
|
||||||
"Start commands sent. Nodes are booting in background. "
|
"Start commands sent. Nodes are booting in background. Use wait_seconds, then check node status."
|
||||||
"Use wait_seconds, then check node status."
|
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -249,9 +230,7 @@ if __name__ == "__main__":
|
|||||||
test_input_single = json.dumps(
|
test_input_single = json.dumps(
|
||||||
{
|
{
|
||||||
"project_id": "<PROJECT_UUID>", # Replace with actual project UUID
|
"project_id": "<PROJECT_UUID>", # Replace with actual project UUID
|
||||||
"node_ids": [
|
"node_ids": ["fbeda109-9a74-4d8c-a749-cc3847911a90"], # Replace with actual node UUID
|
||||||
"fbeda109-9a74-4d8c-a749-cc3847911a90"
|
|
||||||
], # Replace with actual node UUID
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
tool = GNS3StartNodeTool()
|
tool = GNS3StartNodeTool()
|
||||||
|
|||||||
@ -94,13 +94,8 @@ class GNS3StopNodeTool(BaseTool):
|
|||||||
|
|
||||||
# Validate input
|
# Validate input
|
||||||
if not project_id or not node_ids:
|
if not project_id or not node_ids:
|
||||||
logger.error(
|
logger.error("Missing required fields: project_id or node_ids.")
|
||||||
"Missing required fields: project_id or node_ids."
|
return {"error": "Missing required fields: project_id and node_ids."}
|
||||||
)
|
|
||||||
return {
|
|
||||||
"error": "Missing required fields: "
|
|
||||||
"project_id and node_ids."
|
|
||||||
}
|
|
||||||
|
|
||||||
if not isinstance(node_ids, list):
|
if not isinstance(node_ids, list):
|
||||||
logger.error("node_ids must be a list.")
|
logger.error("node_ids must be a list.")
|
||||||
@ -112,10 +107,7 @@ class GNS3StopNodeTool(BaseTool):
|
|||||||
|
|
||||||
if gns3_ctx is None:
|
if gns3_ctx is None:
|
||||||
logger.error("Failed to create GNS3 connector")
|
logger.error("Failed to create GNS3 connector")
|
||||||
return {
|
return {"error": "Failed to connect to GNS3 server. Please check your configuration."}
|
||||||
"error": "Failed to connect to GNS3 server. "
|
|
||||||
"Please check your configuration."
|
|
||||||
}
|
|
||||||
|
|
||||||
# Verify nodes exist and capture names (one call)
|
# Verify nodes exist and capture names (one call)
|
||||||
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
|
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
|
||||||
@ -131,14 +123,8 @@ class GNS3StopNodeTool(BaseTool):
|
|||||||
)
|
)
|
||||||
results = []
|
results = []
|
||||||
known_ids = [nid for nid in node_ids if nid in nodes_by_id]
|
known_ids = [nid for nid in node_ids if nid in nodes_by_id]
|
||||||
stop_results = stop_node_handler(
|
stop_results = stop_node_handler({"project_id": project_id, "node_ids": known_ids}, gns3_ctx)
|
||||||
{"project_id": project_id, "node_ids": known_ids}, gns3_ctx
|
stop_errors = {r["node_id"]: r.get("error") for r in stop_results if r.get("status") == "error"}
|
||||||
)
|
|
||||||
stop_errors = {
|
|
||||||
r["node_id"]: r.get("error")
|
|
||||||
for r in stop_results
|
|
||||||
if r.get("status") == "error"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Get updated status — one call
|
# Get updated status — one call
|
||||||
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
|
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
|
||||||
@ -148,9 +134,7 @@ class GNS3StopNodeTool(BaseTool):
|
|||||||
|
|
||||||
for node_id in node_ids:
|
for node_id in node_ids:
|
||||||
if node_id not in nodes_by_id:
|
if node_id not in nodes_by_id:
|
||||||
logger.error(
|
logger.error("Node %s not found in project %s", node_id, project_id)
|
||||||
"Node %s not found in project %s", node_id, project_id
|
|
||||||
)
|
|
||||||
results.append(
|
results.append(
|
||||||
{
|
{
|
||||||
"node_id": node_id,
|
"node_id": node_id,
|
||||||
@ -160,9 +144,7 @@ class GNS3StopNodeTool(BaseTool):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
elif node_id in stop_errors:
|
elif node_id in stop_errors:
|
||||||
logger.error(
|
logger.error("Failed to stop node %s: %s", node_id, stop_errors[node_id])
|
||||||
"Failed to stop node %s: %s", node_id, stop_errors[node_id]
|
|
||||||
)
|
|
||||||
results.append(
|
results.append(
|
||||||
{
|
{
|
||||||
"node_id": node_id,
|
"node_id": node_id,
|
||||||
@ -187,9 +169,7 @@ class GNS3StopNodeTool(BaseTool):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Analyze results
|
# Analyze results
|
||||||
successful_nodes = [
|
successful_nodes = [r for r in results if r.get("status") != "error"]
|
||||||
r for r in results if r.get("status") != "error"
|
|
||||||
]
|
|
||||||
failed_nodes = [r for r in results if r.get("status") == "error"]
|
failed_nodes = [r for r in results if r.get("status") == "error"]
|
||||||
|
|
||||||
# Construct final response
|
# Construct final response
|
||||||
@ -223,9 +203,7 @@ if __name__ == "__main__":
|
|||||||
test_input_single = json.dumps(
|
test_input_single = json.dumps(
|
||||||
{
|
{
|
||||||
"project_id": "<PROJECT_UUID>", # Replace with actual project UUID
|
"project_id": "<PROJECT_UUID>", # Replace with actual project UUID
|
||||||
"node_ids": [
|
"node_ids": ["fbeda109-9a74-4d8c-a749-cc3847911a90"], # Replace with actual node UUID
|
||||||
"fbeda109-9a74-4d8c-a749-cc3847911a90"
|
|
||||||
], # Replace with actual node UUID
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
tool = GNS3StopNodeTool()
|
tool = GNS3StopNodeTool()
|
||||||
|
|||||||
@ -100,13 +100,8 @@ class GNS3SuspendNodeTool(BaseTool):
|
|||||||
|
|
||||||
# Validate input
|
# Validate input
|
||||||
if not project_id or not node_ids:
|
if not project_id or not node_ids:
|
||||||
logger.error(
|
logger.error("Missing required fields: project_id or node_ids.")
|
||||||
"Missing required fields: project_id or node_ids."
|
return {"error": "Missing required fields: project_id and node_ids."}
|
||||||
)
|
|
||||||
return {
|
|
||||||
"error": "Missing required fields: "
|
|
||||||
"project_id and node_ids."
|
|
||||||
}
|
|
||||||
|
|
||||||
if not isinstance(node_ids, list):
|
if not isinstance(node_ids, list):
|
||||||
logger.error("node_ids must be a list.")
|
logger.error("node_ids must be a list.")
|
||||||
@ -118,10 +113,7 @@ class GNS3SuspendNodeTool(BaseTool):
|
|||||||
|
|
||||||
if gns3_ctx is None:
|
if gns3_ctx is None:
|
||||||
logger.error("Failed to create GNS3 connector")
|
logger.error("Failed to create GNS3 connector")
|
||||||
return {
|
return {"error": "Failed to connect to GNS3 server. Please check your configuration."}
|
||||||
"error": "Failed to connect to GNS3 server. "
|
|
||||||
"Please check your configuration."
|
|
||||||
}
|
|
||||||
|
|
||||||
# Verify nodes exist and capture names (one call)
|
# Verify nodes exist and capture names (one call)
|
||||||
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
|
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
|
||||||
@ -137,14 +129,8 @@ class GNS3SuspendNodeTool(BaseTool):
|
|||||||
)
|
)
|
||||||
results = []
|
results = []
|
||||||
known_ids = [nid for nid in node_ids if nid in nodes_by_id]
|
known_ids = [nid for nid in node_ids if nid in nodes_by_id]
|
||||||
suspend_results = suspend_node_handler(
|
suspend_results = suspend_node_handler({"project_id": project_id, "node_ids": known_ids}, gns3_ctx)
|
||||||
{"project_id": project_id, "node_ids": known_ids}, gns3_ctx
|
suspend_errors = {r["node_id"]: r.get("error") for r in suspend_results if r.get("status") == "error"}
|
||||||
)
|
|
||||||
suspend_errors = {
|
|
||||||
r["node_id"]: r.get("error")
|
|
||||||
for r in suspend_results
|
|
||||||
if r.get("status") == "error"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Get updated status — one call
|
# Get updated status — one call
|
||||||
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
|
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
|
||||||
@ -154,9 +140,7 @@ class GNS3SuspendNodeTool(BaseTool):
|
|||||||
|
|
||||||
for node_id in node_ids:
|
for node_id in node_ids:
|
||||||
if node_id not in nodes_by_id:
|
if node_id not in nodes_by_id:
|
||||||
logger.error(
|
logger.error("Node %s not found in project %s", node_id, project_id)
|
||||||
"Node %s not found in project %s", node_id, project_id
|
|
||||||
)
|
|
||||||
results.append(
|
results.append(
|
||||||
{
|
{
|
||||||
"node_id": node_id,
|
"node_id": node_id,
|
||||||
@ -195,9 +179,7 @@ class GNS3SuspendNodeTool(BaseTool):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Analyze results
|
# Analyze results
|
||||||
successful_nodes = [
|
successful_nodes = [r for r in results if r.get("status") != "error"]
|
||||||
r for r in results if r.get("status") != "error"
|
|
||||||
]
|
|
||||||
failed_nodes = [r for r in results if r.get("status") == "error"]
|
failed_nodes = [r for r in results if r.get("status") == "error"]
|
||||||
|
|
||||||
# Construct final response
|
# Construct final response
|
||||||
@ -207,10 +189,7 @@ class GNS3SuspendNodeTool(BaseTool):
|
|||||||
"successful": len(successful_nodes),
|
"successful": len(successful_nodes),
|
||||||
"failed": len(failed_nodes),
|
"failed": len(failed_nodes),
|
||||||
"nodes": results,
|
"nodes": results,
|
||||||
"note": (
|
"note": ("Suspended nodes preserve their state in memory. Use resume to continue where you left off."),
|
||||||
"Suspended nodes preserve their state in memory. "
|
|
||||||
"Use resume to continue where you left off."
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
@ -235,9 +214,7 @@ if __name__ == "__main__":
|
|||||||
test_input_single = json.dumps(
|
test_input_single = json.dumps(
|
||||||
{
|
{
|
||||||
"project_id": "<PROJECT_UUID>", # Replace with actual project UUID
|
"project_id": "<PROJECT_UUID>", # Replace with actual project UUID
|
||||||
"node_ids": [
|
"node_ids": ["fbeda109-9a74-4d8c-a749-cc3847911a90"], # Replace with actual node UUID
|
||||||
"fbeda109-9a74-4d8c-a749-cc3847911a90"
|
|
||||||
], # Replace with actual node UUID
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
tool = GNS3SuspendNodeTool()
|
tool = GNS3SuspendNodeTool()
|
||||||
|
|||||||
@ -125,9 +125,7 @@ class GNS3UpdateNodeNameTool(BaseTool):
|
|||||||
return {"error": f"Node {i + 1} must be a dictionary."}
|
return {"error": f"Node {i + 1} must be a dictionary."}
|
||||||
if "node_id" not in node_data or "new_name" not in node_data:
|
if "node_id" not in node_data or "new_name" not in node_data:
|
||||||
logger.error("Node %d missing node_id or new_name.", i + 1)
|
logger.error("Node %d missing node_id or new_name.", i + 1)
|
||||||
return {
|
return {"error": f"Node {i + 1} missing node_id or new_name."}
|
||||||
"error": f"Node {i + 1} missing node_id or new_name."
|
|
||||||
}
|
|
||||||
|
|
||||||
# Build handler context (JWT + server URL from request context)
|
# Build handler context (JWT + server URL from request context)
|
||||||
logger.info("Connecting to GNS3 server...")
|
logger.info("Connecting to GNS3 server...")
|
||||||
@ -135,10 +133,7 @@ class GNS3UpdateNodeNameTool(BaseTool):
|
|||||||
|
|
||||||
if gns3_ctx is None:
|
if gns3_ctx is None:
|
||||||
logger.error("Failed to create GNS3 connector")
|
logger.error("Failed to create GNS3 connector")
|
||||||
return {
|
return {"error": "Failed to connect to GNS3 server. Please check your configuration."}
|
||||||
"error": "Failed to connect to GNS3 server. "
|
|
||||||
"Please check your configuration."
|
|
||||||
}
|
|
||||||
|
|
||||||
# Fetch current node names in one call (old names + existence)
|
# Fetch current node names in one call (old names + existence)
|
||||||
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
|
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
|
||||||
@ -208,9 +203,7 @@ class GNS3UpdateNodeNameTool(BaseTool):
|
|||||||
"error": "Name verification failed",
|
"error": "Name verification failed",
|
||||||
}
|
}
|
||||||
results.append(error_info)
|
results.append(error_info)
|
||||||
logger.error(
|
logger.error("Failed to update node name for %s", node_id)
|
||||||
"Failed to update node name for %s", node_id
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_info = {
|
error_info = {
|
||||||
@ -223,9 +216,7 @@ class GNS3UpdateNodeNameTool(BaseTool):
|
|||||||
logger.error("Failed to update node %d: %s", i + 1, e)
|
logger.error("Failed to update node %d: %s", i + 1, e)
|
||||||
|
|
||||||
# Analyze results
|
# Analyze results
|
||||||
successful_nodes = [
|
successful_nodes = [r for r in results if r.get("status") == "success"]
|
||||||
r for r in results if r.get("status") == "success"
|
|
||||||
]
|
|
||||||
failed_nodes = [r for r in results if r.get("status") == "failed"]
|
failed_nodes = [r for r in results if r.get("status") == "failed"]
|
||||||
|
|
||||||
# Construct final response
|
# Construct final response
|
||||||
|
|||||||
@ -88,9 +88,7 @@ class GNS3WaitTool(BaseTool):
|
|||||||
if not isinstance(seconds, int) or isinstance(seconds, bool):
|
if not isinstance(seconds, int) or isinstance(seconds, bool):
|
||||||
return {"error": "seconds must be an integer (1-600)."}
|
return {"error": "seconds must be an integer (1-600)."}
|
||||||
if not 1 <= seconds <= MAX_WAIT_SECONDS:
|
if not 1 <= seconds <= MAX_WAIT_SECONDS:
|
||||||
return {
|
return {"error": f"seconds must be between 1 and {MAX_WAIT_SECONDS}."}
|
||||||
"error": f"seconds must be between 1 and {MAX_WAIT_SECONDS}."
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info("Waiting %d seconds...", seconds)
|
logger.info("Waiting %d seconds...", seconds)
|
||||||
waited = 0
|
waited = 0
|
||||||
|
|||||||
@ -123,19 +123,25 @@ class PacketAnalysisTool(BaseTool):
|
|||||||
|
|
||||||
query = query.strip()
|
query = query.strip()
|
||||||
if len(query.split()) > 1:
|
if len(query.split()) > 1:
|
||||||
return json.dumps({
|
return json.dumps(
|
||||||
"error": "Only one keyword allowed",
|
{
|
||||||
"hint": 'Use a single keyword like "ospf.lsa" or "bgp", not multiple words',
|
"error": "Only one keyword allowed",
|
||||||
})
|
"hint": 'Use a single keyword like "ospf.lsa" or "bgp", not multiple words',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
tshark = subprocess.Popen(
|
tshark = subprocess.Popen(
|
||||||
["tshark", "-G", "fields"],
|
["tshark", "-G", "fields"],
|
||||||
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True,
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
text=True,
|
||||||
)
|
)
|
||||||
grep = subprocess.Popen(
|
grep = subprocess.Popen(
|
||||||
["grep", "-iF", query, "-"],
|
["grep", "-iF", query, "-"],
|
||||||
stdin=tshark.stdout, stdout=subprocess.PIPE,
|
stdin=tshark.stdout,
|
||||||
stderr=subprocess.DEVNULL, text=True,
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
text=True,
|
||||||
)
|
)
|
||||||
tshark.stdout.close()
|
tshark.stdout.close()
|
||||||
stdout, _ = grep.communicate(timeout=30)
|
stdout, _ = grep.communicate(timeout=30)
|
||||||
@ -151,21 +157,27 @@ class PacketAnalysisTool(BaseTool):
|
|||||||
name = parts[2]
|
name = parts[2]
|
||||||
if name not in seen:
|
if name not in seen:
|
||||||
seen.add(name)
|
seen.add(name)
|
||||||
results.append({
|
results.append(
|
||||||
"field": name,
|
{
|
||||||
"type": parts[3] if len(parts) > 3 else "",
|
"field": name,
|
||||||
"protocol": parts[4] if len(parts) > 4 else "",
|
"type": parts[3] if len(parts) > 3 else "",
|
||||||
"description": parts[7] if len(parts) > 7 else "",
|
"protocol": parts[4] if len(parts) > 4 else "",
|
||||||
})
|
"description": parts[7] if len(parts) > 7 else "",
|
||||||
|
}
|
||||||
|
)
|
||||||
if len(results) >= 40:
|
if len(results) >= 40:
|
||||||
break
|
break
|
||||||
|
|
||||||
return json.dumps({
|
return json.dumps(
|
||||||
"query": query,
|
{
|
||||||
"count": len(results),
|
"query": query,
|
||||||
"fields": results,
|
"count": len(results),
|
||||||
"hint": "Use the 'field' values in tshark_args with -e",
|
"fields": results,
|
||||||
}, ensure_ascii=False, indent=2)
|
"hint": "Use the 'field' values in tshark_args with -e",
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _load_valid_tshark_fields(cls) -> set:
|
def _load_valid_tshark_fields(cls) -> set:
|
||||||
@ -235,11 +247,13 @@ class PacketAnalysisTool(BaseTool):
|
|||||||
i += 1
|
i += 1
|
||||||
|
|
||||||
if invalid_fields:
|
if invalid_fields:
|
||||||
return json.dumps({
|
return json.dumps(
|
||||||
"error": f"Invalid tshark field names: {', '.join(invalid_fields)}",
|
{
|
||||||
"hint": 'Use {"action": "search_fields", "query": "<protocol>"} to look up valid field names',
|
"error": f"Invalid tshark field names: {', '.join(invalid_fields)}",
|
||||||
"invalid_fields": invalid_fields,
|
"hint": 'Use {"action": "search_fields", "query": "<protocol>"} to look up valid field names',
|
||||||
})
|
"invalid_fields": invalid_fields,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@ -271,8 +285,7 @@ class PacketAnalysisTool(BaseTool):
|
|||||||
return self._search_fields(query or "")
|
return self._search_fields(query or "")
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"PacketAnalysisTool invoked: project_id={project_id}, "
|
f"PacketAnalysisTool invoked: project_id={project_id}, link_id={link_id}, tshark_args={tshark_args}"
|
||||||
f"link_id={link_id}, tshark_args={tshark_args}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validate inputs
|
# Validate inputs
|
||||||
@ -429,6 +442,7 @@ class PacketAnalysisTool(BaseTool):
|
|||||||
"""
|
"""
|
||||||
# Build command: tshark -r <file> <user_args>
|
# Build command: tshark -r <file> <user_args>
|
||||||
import shlex
|
import shlex
|
||||||
|
|
||||||
cmd = ["tshark", "-r", pcap_file] + shlex.split(tshark_args)
|
cmd = ["tshark", "-r", pcap_file] + shlex.split(tshark_args)
|
||||||
|
|
||||||
logger.info(f"Running tshark: {' '.join(cmd)}")
|
logger.info(f"Running tshark: {' '.join(cmd)}")
|
||||||
@ -451,10 +465,7 @@ class PacketAnalysisTool(BaseTool):
|
|||||||
f"tshark reported: {stderr_clean}",
|
f"tshark reported: {stderr_clean}",
|
||||||
]
|
]
|
||||||
if "is not a valid protocol" in stderr.lower() or "aren't valid" in stderr.lower():
|
if "is not a valid protocol" in stderr.lower() or "aren't valid" in stderr.lower():
|
||||||
hints.append(
|
hints.append('Use {"action": "search_fields", "query": "<protocol>"} to find correct field names.')
|
||||||
'Use {"action": "search_fields", "query": "<protocol>"} '
|
|
||||||
"to find correct field names."
|
|
||||||
)
|
|
||||||
logger.warning(f"tshark stderr: {stderr}")
|
logger.warning(f"tshark stderr: {stderr}")
|
||||||
return json.dumps({"error": "tshark argument error", "hints": hints})
|
return json.dumps({"error": "tshark argument error", "hints": hints})
|
||||||
|
|
||||||
|
|||||||
@ -179,11 +179,7 @@ class VPCSCommands(BaseTool):
|
|||||||
|
|
||||||
# Validate input
|
# Validate input
|
||||||
device_configs_list, project_id = self._validate_tool_input(tool_input)
|
device_configs_list, project_id = self._validate_tool_input(tool_input)
|
||||||
if (
|
if isinstance(device_configs_list, list) and len(device_configs_list) > 0 and "error" in device_configs_list[0]:
|
||||||
isinstance(device_configs_list, list)
|
|
||||||
and len(device_configs_list) > 0
|
|
||||||
and "error" in device_configs_list[0]
|
|
||||||
):
|
|
||||||
return device_configs_list
|
return device_configs_list
|
||||||
|
|
||||||
# Create a mapping of device names to their commands
|
# Create a mapping of device names to their commands
|
||||||
@ -191,30 +187,17 @@ class VPCSCommands(BaseTool):
|
|||||||
|
|
||||||
# Prepare device hosts data
|
# Prepare device hosts data
|
||||||
try:
|
try:
|
||||||
hosts_data = self._prepare_device_hosts_data(
|
hosts_data = self._prepare_device_hosts_data(device_configs_list, project_id, jwt_token=jwt_token, url=url)
|
||||||
device_configs_list, project_id, jwt_token=jwt_token, url=url
|
|
||||||
)
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
logger.error("Failed to prepare device hosts data: %s", e)
|
logger.error("Failed to prepare device hosts data: %s", e)
|
||||||
return [{"status": "failed", "error": str(e)}]
|
return [{"status": "failed", "error": str(e)}]
|
||||||
|
|
||||||
# Check if any devices have errors (e.g., missing device)
|
# Check if any devices have errors (e.g., missing device)
|
||||||
error_devices = {
|
error_devices = {name: data for name, data in hosts_data.items() if "error" in data}
|
||||||
name: data
|
|
||||||
for name, data in hosts_data.items()
|
|
||||||
if "error" in data
|
|
||||||
}
|
|
||||||
if error_devices:
|
if error_devices:
|
||||||
logger.error(
|
logger.error("Devices with configuration errors: %s", list(error_devices.keys()))
|
||||||
"Devices with configuration errors: %s",
|
|
||||||
list(error_devices.keys())
|
|
||||||
)
|
|
||||||
return [
|
return [
|
||||||
{
|
{"device_name": name, "status": "failed", "error": data["error"]}
|
||||||
"device_name": name,
|
|
||||||
"status": "failed",
|
|
||||||
"error": data["error"]
|
|
||||||
}
|
|
||||||
for name, data in error_devices.items()
|
for name, data in error_devices.items()
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -253,17 +236,13 @@ class VPCSCommands(BaseTool):
|
|||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
def _run_vpcs_commands(
|
def _run_vpcs_commands(self, task: Task, device_configs_map: dict[str, list[str]]) -> Result:
|
||||||
self, task: Task, device_configs_map: dict[str, list[str]]
|
|
||||||
) -> Result:
|
|
||||||
"""Execute VPCS commands with single retry."""
|
"""Execute VPCS commands with single retry."""
|
||||||
device_name = task.host.name
|
device_name = task.host.name
|
||||||
commands = device_configs_map.get(device_name, [])
|
commands = device_configs_map.get(device_name, [])
|
||||||
|
|
||||||
if not commands:
|
if not commands:
|
||||||
return Result(
|
return Result(host=task.host, result="No commands to execute")
|
||||||
host=task.host, result="No commands to execute"
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Use netmiko_multiline for VPCS commands
|
# Use netmiko_multiline for VPCS commands
|
||||||
@ -334,9 +313,7 @@ class VPCSCommands(BaseTool):
|
|||||||
parsed_input = json.loads(tool_input)
|
parsed_input = json.loads(tool_input)
|
||||||
logger.info("Successfully parsed tool input from JSON string.")
|
logger.info("Successfully parsed tool input from JSON string.")
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
logger.error(
|
logger.error("Invalid JSON string received as tool input: %s", e)
|
||||||
"Invalid JSON string received as tool input: %s", e
|
|
||||||
)
|
|
||||||
return (
|
return (
|
||||||
[{"status": "failed", "error": f"Invalid JSON string input from model: {e}"}],
|
[{"status": "failed", "error": f"Invalid JSON string input from model: {e}"}],
|
||||||
None,
|
None,
|
||||||
@ -377,10 +354,7 @@ class VPCSCommands(BaseTool):
|
|||||||
return device_configs, project_id
|
return device_configs, project_id
|
||||||
|
|
||||||
else:
|
else:
|
||||||
error_msg = (
|
error_msg = f"Tool input must be JSON with project_id and device_configs, got {type(parsed_input).__name__}"
|
||||||
"Tool input must be JSON with project_id and device_configs, "
|
|
||||||
f"got {type(parsed_input).__name__}"
|
|
||||||
)
|
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
return ([{"status": "failed", "error": error_msg}], None)
|
return ([{"status": "failed", "error": error_msg}], None)
|
||||||
|
|
||||||
@ -394,14 +368,10 @@ class VPCSCommands(BaseTool):
|
|||||||
Returns:
|
Returns:
|
||||||
True if valid UUID format, False otherwise
|
True if valid UUID format, False otherwise
|
||||||
"""
|
"""
|
||||||
uuid_pattern = (
|
uuid_pattern = r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
|
||||||
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
|
|
||||||
)
|
|
||||||
return bool(re.match(uuid_pattern, project_id, re.IGNORECASE))
|
return bool(re.match(uuid_pattern, project_id, re.IGNORECASE))
|
||||||
|
|
||||||
def _configs_map(
|
def _configs_map(self, device_config_list: list[dict[str, Any]]) -> dict[str, list[str]]:
|
||||||
self, device_config_list: list[dict[str, Any]]
|
|
||||||
) -> dict[str, list[str]]:
|
|
||||||
"""
|
"""
|
||||||
Create a mapping of device names to their command lists.
|
Create a mapping of device names to their command lists.
|
||||||
|
|
||||||
@ -411,10 +381,7 @@ class VPCSCommands(BaseTool):
|
|||||||
Returns:
|
Returns:
|
||||||
Dictionary mapping device names to command lists
|
Dictionary mapping device names to command lists
|
||||||
"""
|
"""
|
||||||
return {
|
return {config["device_name"]: config["commands"] for config in device_config_list}
|
||||||
config["device_name"]: config["commands"]
|
|
||||||
for config in device_config_list
|
|
||||||
}
|
|
||||||
|
|
||||||
def _prepare_device_hosts_data(
|
def _prepare_device_hosts_data(
|
||||||
self,
|
self,
|
||||||
@ -445,18 +412,14 @@ class VPCSCommands(BaseTool):
|
|||||||
device_names = [config["device_name"] for config in device_configs_list]
|
device_names = [config["device_name"] for config in device_configs_list]
|
||||||
|
|
||||||
# Get device port mappings from topology
|
# Get device port mappings from topology
|
||||||
device_ports = get_device_ports_from_topology(
|
device_ports = get_device_ports_from_topology(device_names, project_id=project_id, jwt_token=jwt_token, url=url)
|
||||||
device_names, project_id=project_id, jwt_token=jwt_token, url=url
|
|
||||||
)
|
|
||||||
|
|
||||||
# Build Nornir inventory hosts data
|
# Build Nornir inventory hosts data
|
||||||
hosts_data = {}
|
hosts_data = {}
|
||||||
for device_name in device_names:
|
for device_name in device_names:
|
||||||
if device_name not in device_ports:
|
if device_name not in device_ports:
|
||||||
logger.error("Device '%s' not found in topology", device_name)
|
logger.error("Device '%s' not found in topology", device_name)
|
||||||
hosts_data[device_name] = {
|
hosts_data[device_name] = {"error": f"Device '{device_name}' not found in topology"}
|
||||||
"error": f"Device '{device_name}' not found in topology"
|
|
||||||
}
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
port = device_ports[device_name]["port"]
|
port = device_ports[device_name]["port"]
|
||||||
@ -498,9 +461,7 @@ class VPCSCommands(BaseTool):
|
|||||||
|
|
||||||
return hosts_data
|
return hosts_data
|
||||||
|
|
||||||
def _initialize_nornir(
|
def _initialize_nornir(self, hosts_data: dict[str, dict[str, Any]]) -> "Nornir":
|
||||||
self, hosts_data: dict[str, dict[str, Any]]
|
|
||||||
) -> "Nornir":
|
|
||||||
"""
|
"""
|
||||||
Initialize Nornir with VPCS device inventory.
|
Initialize Nornir with VPCS device inventory.
|
||||||
|
|
||||||
@ -576,12 +537,14 @@ class VPCSCommands(BaseTool):
|
|||||||
|
|
||||||
# Check if device had an error during preparation
|
# Check if device had an error during preparation
|
||||||
if device_name in hosts_data and "error" in hosts_data[device_name]:
|
if device_name in hosts_data and "error" in hosts_data[device_name]:
|
||||||
results.append({
|
results.append(
|
||||||
"device_name": device_name,
|
{
|
||||||
"status": "failed",
|
"device_name": device_name,
|
||||||
"error": hosts_data[device_name]["error"],
|
"status": "failed",
|
||||||
"commands": device_config["commands"],
|
"error": hosts_data[device_name]["error"],
|
||||||
})
|
"commands": device_config["commands"],
|
||||||
|
}
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Get result from Nornir task
|
# Get result from Nornir task
|
||||||
@ -591,28 +554,34 @@ class VPCSCommands(BaseTool):
|
|||||||
if host_result.failed:
|
if host_result.failed:
|
||||||
# Task failed
|
# Task failed
|
||||||
error_msg = str(host_result.result) if host_result.result else "Unknown error"
|
error_msg = str(host_result.result) if host_result.result else "Unknown error"
|
||||||
results.append({
|
results.append(
|
||||||
"device_name": device_name,
|
{
|
||||||
"status": "failed",
|
"device_name": device_name,
|
||||||
"error": error_msg,
|
"status": "failed",
|
||||||
"commands": device_config["commands"],
|
"error": error_msg,
|
||||||
})
|
"commands": device_config["commands"],
|
||||||
|
}
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
# Task succeeded
|
# Task succeeded
|
||||||
results.append({
|
results.append(
|
||||||
"device_name": device_name,
|
{
|
||||||
"status": "success",
|
"device_name": device_name,
|
||||||
"output": host_result.result,
|
"status": "success",
|
||||||
"commands": device_config["commands"],
|
"output": host_result.result,
|
||||||
})
|
"commands": device_config["commands"],
|
||||||
|
}
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
# Device not in task result (shouldn't happen)
|
# Device not in task result (shouldn't happen)
|
||||||
results.append({
|
results.append(
|
||||||
"device_name": device_name,
|
{
|
||||||
"status": "failed",
|
"device_name": device_name,
|
||||||
"error": f"Device '{device_name}' not in task results",
|
"status": "failed",
|
||||||
"commands": device_config["commands"],
|
"error": f"Device '{device_name}' not in task results",
|
||||||
})
|
"commands": device_config["commands"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|||||||
@ -73,14 +73,14 @@ def _load_forbidden_commands() -> list[str]:
|
|||||||
# Try to load from skills repository
|
# Try to load from skills repository
|
||||||
manager = get_skills_manager()
|
manager = get_skills_manager()
|
||||||
if manager is not None:
|
if manager is not None:
|
||||||
commands = manager.load_forbidden_commands()
|
commands = manager.load_forbidden_commands()
|
||||||
if commands:
|
if commands:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Loaded %d forbidden command patterns from skills repository",
|
"Loaded %d forbidden command patterns from skills repository",
|
||||||
len(commands),
|
len(commands),
|
||||||
)
|
)
|
||||||
_forbidden_commands_cache = commands
|
_forbidden_commands_cache = commands
|
||||||
return _forbidden_commands_cache
|
return _forbidden_commands_cache
|
||||||
|
|
||||||
# Fallback to defaults
|
# Fallback to defaults
|
||||||
logger.warning("Using default forbidden commands list")
|
logger.warning("Using default forbidden commands list")
|
||||||
|
|||||||
@ -69,11 +69,9 @@ The drivers are auto-registered when the tools are imported:
|
|||||||
from gns3server.agent.gns3_copilot.tools_v2 import DisplayToolNornir
|
from gns3server.agent.gns3_copilot.tools_v2 import DisplayToolNornir
|
||||||
|
|
||||||
tool = DisplayToolNornir()
|
tool = DisplayToolNornir()
|
||||||
result = tool._run(json.dumps({
|
result = tool._run(
|
||||||
"device_names": ["huawei-sw1"],
|
json.dumps({"device_names": ["huawei-sw1"], "commands": ["display version"], "project_id": "project-uuid"})
|
||||||
"commands": ["display version"],
|
)
|
||||||
"project_id": "project-uuid"
|
|
||||||
}))
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Running Tests
|
## Running Tests
|
||||||
@ -145,11 +143,14 @@ Create `tests/test_cisco.py`:
|
|||||||
import unittest
|
import unittest
|
||||||
from gns3server.agent.gns3_copilot.utils.custom_netmiko import cisco
|
from gns3server.agent.gns3_copilot.utils.custom_netmiko import cisco
|
||||||
|
|
||||||
|
|
||||||
class TestCustomCiscoDriver(unittest.TestCase):
|
class TestCustomCiscoDriver(unittest.TestCase):
|
||||||
def test_device_type_registered(self):
|
def test_device_type_registered(self):
|
||||||
from netmiko.ssh_dispatcher import CLASS_MAPPER
|
from netmiko.ssh_dispatcher import CLASS_MAPPER
|
||||||
|
|
||||||
self.assertIn("cisco_custom", CLASS_MAPPER)
|
self.assertIn("cisco_custom", CLASS_MAPPER)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
```
|
```
|
||||||
|
|||||||
@ -209,7 +209,7 @@ class GNS3HuaweiTelnetCE(HuaweiBase):
|
|||||||
|
|
||||||
# Ensure we're in a clean state
|
# Ensure we're in a clean state
|
||||||
try:
|
try:
|
||||||
if hasattr(self, 'base_prompt') and self.base_prompt:
|
if hasattr(self, "base_prompt") and self.base_prompt:
|
||||||
self._test_channel_read(pattern=self.base_prompt)
|
self._test_channel_read(pattern=self.base_prompt)
|
||||||
else:
|
else:
|
||||||
# If base_prompt is not set yet, just read to clear buffer
|
# If base_prompt is not set yet, just read to clear buffer
|
||||||
@ -288,9 +288,7 @@ class GNS3HuaweiTelnetCE(HuaweiBase):
|
|||||||
# This method keeps reading until there is no new data for
|
# This method keeps reading until there is no new data for
|
||||||
# 'last_read' seconds. This is the proper Netmiko way to handle
|
# 'last_read' seconds. This is the proper Netmiko way to handle
|
||||||
# command output
|
# command output
|
||||||
output += self.read_channel_timing(
|
output += self.read_channel_timing(read_timeout=read_timeout, last_read=2.0)
|
||||||
read_timeout=read_timeout, last_read=2.0
|
|
||||||
)
|
|
||||||
|
|
||||||
# Exit config mode if requested
|
# Exit config mode if requested
|
||||||
if exit_config_mode:
|
if exit_config_mode:
|
||||||
@ -318,9 +316,7 @@ class GNS3HuaweiTelnetCE(HuaweiBase):
|
|||||||
|
|
||||||
return output
|
return output
|
||||||
|
|
||||||
def exit_config_mode(
|
def exit_config_mode(self, exit_config: str = "return", pattern: str = r"<\S+>|>\s*$") -> str:
|
||||||
self, exit_config: str = "return", pattern: str = r"<\S+>|>\s*$"
|
|
||||||
) -> str:
|
|
||||||
r"""
|
r"""
|
||||||
Exit configuration mode for Huawei devices.
|
Exit configuration mode for Huawei devices.
|
||||||
|
|
||||||
@ -377,6 +373,7 @@ class GNS3HuaweiTelnetCE(HuaweiBase):
|
|||||||
# Register the custom device type with Netmiko
|
# Register the custom device type with Netmiko
|
||||||
_registered = False # Flag to prevent duplicate registration
|
_registered = False # Flag to prevent duplicate registration
|
||||||
|
|
||||||
|
|
||||||
def register_custom_device_type() -> None:
|
def register_custom_device_type() -> None:
|
||||||
"""
|
"""
|
||||||
Register the custom GNS3HuaweiTelnetCE device type with Netmiko.
|
Register the custom GNS3HuaweiTelnetCE device type with Netmiko.
|
||||||
@ -451,7 +448,4 @@ try:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Log but don't fail on import
|
# Log but don't fail on import
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
logger.warning(
|
logger.warning(f"Failed to register custom device type: {e}", exc_info=True)
|
||||||
f"Failed to register custom device type: {e}",
|
|
||||||
exc_info=True
|
|
||||||
)
|
|
||||||
|
|||||||
@ -60,12 +60,12 @@ class RuijieTelnetEnhanced(RuijieOSBase):
|
|||||||
# Interactive command patterns that trigger [yes/no] prompts
|
# Interactive command patterns that trigger [yes/no] prompts
|
||||||
# These are commands that commonly require confirmation
|
# These are commands that commonly require confirmation
|
||||||
INTERACTIVE_PATTERNS = [
|
INTERACTIVE_PATTERNS = [
|
||||||
re.compile(r'^router-id\s+', re.IGNORECASE), # OSPF router-id
|
re.compile(r"^router-id\s+", re.IGNORECASE), # OSPF router-id
|
||||||
re.compile(r'^erase\s+', re.IGNORECASE), # erase startup-config
|
re.compile(r"^erase\s+", re.IGNORECASE), # erase startup-config
|
||||||
re.compile(r'^delete\s+', re.IGNORECASE), # delete files
|
re.compile(r"^delete\s+", re.IGNORECASE), # delete files
|
||||||
re.compile(r'^format\s+', re.IGNORECASE), # format filesystem
|
re.compile(r"^format\s+", re.IGNORECASE), # format filesystem
|
||||||
re.compile(r'^reload\b', re.IGNORECASE), # reload/reboot
|
re.compile(r"^reload\b", re.IGNORECASE), # reload/reboot
|
||||||
re.compile(r'^boot\s+system\s+', re.IGNORECASE), # change boot image
|
re.compile(r"^boot\s+system\s+", re.IGNORECASE), # change boot image
|
||||||
]
|
]
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@ -80,9 +80,7 @@ class RuijieTelnetEnhanced(RuijieOSBase):
|
|||||||
kwargs["default_enter"] = "\r\n"
|
kwargs["default_enter"] = "\r\n"
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
def _preprocess_interactive_commands(
|
def _preprocess_interactive_commands(self, config_commands: list[str]) -> list[str]:
|
||||||
self, config_commands: list[str]
|
|
||||||
) -> list[str]:
|
|
||||||
"""
|
"""
|
||||||
Preprocess commands to insert 'yes' after interactive commands.
|
Preprocess commands to insert 'yes' after interactive commands.
|
||||||
|
|
||||||
@ -105,8 +103,7 @@ class RuijieTelnetEnhanced(RuijieOSBase):
|
|||||||
for pattern in self.INTERACTIVE_PATTERNS:
|
for pattern in self.INTERACTIVE_PATTERNS:
|
||||||
if pattern.match(cmd.strip()):
|
if pattern.match(cmd.strip()):
|
||||||
logging.info(
|
logging.info(
|
||||||
"Ruijie device: Detected interactive command '%s', "
|
"Ruijie device: Detected interactive command '%s', inserting 'yes'",
|
||||||
"inserting 'yes'",
|
|
||||||
cmd.strip(),
|
cmd.strip(),
|
||||||
)
|
)
|
||||||
# Insert 'yes' after this command
|
# Insert 'yes' after this command
|
||||||
@ -153,25 +150,18 @@ class RuijieTelnetEnhanced(RuijieOSBase):
|
|||||||
output += self.config_mode()
|
output += self.config_mode()
|
||||||
|
|
||||||
# Preprocess: Insert 'yes' after known interactive commands
|
# Preprocess: Insert 'yes' after known interactive commands
|
||||||
processed_commands = self._preprocess_interactive_commands(
|
processed_commands = self._preprocess_interactive_commands(config_commands)
|
||||||
config_commands
|
|
||||||
)
|
|
||||||
|
|
||||||
# Try batch send first (fast path)
|
# Try batch send first (fast path)
|
||||||
try:
|
try:
|
||||||
output += self._send_config_batch(
|
output += self._send_config_batch(processed_commands, read_timeout, delay_factor)
|
||||||
processed_commands, read_timeout, delay_factor
|
|
||||||
)
|
|
||||||
except Exception as batch_error:
|
except Exception as batch_error:
|
||||||
logging.warning(
|
logging.warning(
|
||||||
"Ruijie device: Batch send failed, "
|
"Ruijie device: Batch send failed, falling back to one-by-one: %s",
|
||||||
"falling back to one-by-one: %s",
|
|
||||||
batch_error,
|
batch_error,
|
||||||
)
|
)
|
||||||
# Fallback to one-by-one send with real-time detection
|
# Fallback to one-by-one send with real-time detection
|
||||||
output += self._send_config_one_by_one(
|
output += self._send_config_one_by_one(config_commands, read_timeout, delay_factor)
|
||||||
config_commands, read_timeout, delay_factor
|
|
||||||
)
|
|
||||||
|
|
||||||
# Exit config mode if requested
|
# Exit config mode if requested
|
||||||
if exit_config_mode:
|
if exit_config_mode:
|
||||||
@ -179,9 +169,7 @@ class RuijieTelnetEnhanced(RuijieOSBase):
|
|||||||
|
|
||||||
return output
|
return output
|
||||||
|
|
||||||
def _send_config_batch(
|
def _send_config_batch(self, commands: list[str], read_timeout: int, delay_factor: float) -> str:
|
||||||
self, commands: list[str], read_timeout: int, delay_factor: float
|
|
||||||
) -> str:
|
|
||||||
"""
|
"""
|
||||||
Send configuration commands in batch (fast).
|
Send configuration commands in batch (fast).
|
||||||
|
|
||||||
@ -197,15 +185,11 @@ class RuijieTelnetEnhanced(RuijieOSBase):
|
|||||||
|
|
||||||
# Read all output at once
|
# Read all output at once
|
||||||
# Use same default as Netmiko (2.0 seconds) to ensure complete output
|
# Use same default as Netmiko (2.0 seconds) to ensure complete output
|
||||||
output += self.read_channel_timing(
|
output += self.read_channel_timing(read_timeout=read_timeout, last_read=2.0)
|
||||||
read_timeout=read_timeout, last_read=2.0
|
|
||||||
)
|
|
||||||
|
|
||||||
return output
|
return output
|
||||||
|
|
||||||
def _send_config_one_by_one(
|
def _send_config_one_by_one(self, commands: list[str], read_timeout: int, delay_factor: float) -> str:
|
||||||
self, commands: list[str], read_timeout: int, delay_factor: float
|
|
||||||
) -> str:
|
|
||||||
"""
|
"""
|
||||||
Send commands one-by-one with real-time prompt detection.
|
Send commands one-by-one with real-time prompt detection.
|
||||||
|
|
||||||
@ -225,26 +209,21 @@ class RuijieTelnetEnhanced(RuijieOSBase):
|
|||||||
time.sleep(0.05)
|
time.sleep(0.05)
|
||||||
|
|
||||||
# Read output after this command
|
# Read output after this command
|
||||||
new_output = self.read_channel_timing(
|
new_output = self.read_channel_timing(read_timeout=10, last_read=0.5)
|
||||||
read_timeout=10, last_read=0.5
|
|
||||||
)
|
|
||||||
output += new_output
|
output += new_output
|
||||||
|
|
||||||
# Check if interactive prompt appeared after this command
|
# Check if interactive prompt appeared after this command
|
||||||
for pattern in interactive_patterns:
|
for pattern in interactive_patterns:
|
||||||
if re.search(pattern, new_output, re.IGNORECASE):
|
if re.search(pattern, new_output, re.IGNORECASE):
|
||||||
logging.info(
|
logging.info(
|
||||||
"Ruijie device: Detected interactive prompt "
|
"Ruijie device: Detected interactive prompt after '%s', sending 'yes'",
|
||||||
"after '%s', sending 'yes'",
|
|
||||||
cmd,
|
cmd,
|
||||||
)
|
)
|
||||||
# Send 'yes' to confirm
|
# Send 'yes' to confirm
|
||||||
self.write_channel(f"yes{self.RETURN}")
|
self.write_channel(f"yes{self.RETURN}")
|
||||||
time.sleep(0.3)
|
time.sleep(0.3)
|
||||||
# Read the confirmation response
|
# Read the confirmation response
|
||||||
output += self.read_channel_timing(
|
output += self.read_channel_timing(read_timeout=30, last_read=0.5)
|
||||||
read_timeout=30, last_read=0.5
|
|
||||||
)
|
|
||||||
break
|
break
|
||||||
|
|
||||||
return output
|
return output
|
||||||
@ -253,6 +232,7 @@ class RuijieTelnetEnhanced(RuijieOSBase):
|
|||||||
# Register the custom device type with Netmiko
|
# Register the custom device type with Netmiko
|
||||||
_registered = False # Flag to prevent duplicate registration
|
_registered = False # Flag to prevent duplicate registration
|
||||||
|
|
||||||
|
|
||||||
def register_custom_device_type() -> None:
|
def register_custom_device_type() -> None:
|
||||||
"""
|
"""
|
||||||
Register the custom RuijieTelnetEnhanced device type with Netmiko.
|
Register the custom RuijieTelnetEnhanced device type with Netmiko.
|
||||||
@ -295,7 +275,4 @@ try:
|
|||||||
register_custom_device_type()
|
register_custom_device_type()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
logger.warning(
|
logger.warning(f"Failed to register Ruijie device type: {e}", exc_info=True)
|
||||||
f"Failed to register Ruijie device type: {e}",
|
|
||||||
exc_info=True
|
|
||||||
)
|
|
||||||
|
|||||||
@ -39,6 +39,7 @@ def get_netmiko_version() -> str:
|
|||||||
"""Get Netmiko library version."""
|
"""Get Netmiko library version."""
|
||||||
try:
|
try:
|
||||||
import netmiko
|
import netmiko
|
||||||
|
|
||||||
return netmiko.__version__
|
return netmiko.__version__
|
||||||
except Exception:
|
except Exception:
|
||||||
return "Unknown"
|
return "Unknown"
|
||||||
@ -68,47 +69,39 @@ def register_custom_drivers() -> set:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Import and register Huawei CE driver
|
# Import and register Huawei CE driver
|
||||||
from gns3server.agent.gns3_copilot.utils.custom_netmiko \
|
from gns3server.agent.gns3_copilot.utils.custom_netmiko import huawei_ce
|
||||||
import huawei_ce
|
|
||||||
|
|
||||||
# Store custom device types before registration
|
# Store custom device types before registration
|
||||||
custom_devices.add("gns3_huawei_telnet_ce")
|
custom_devices.add("gns3_huawei_telnet_ce")
|
||||||
|
|
||||||
# The driver auto-registers on import, but we can call it
|
# The driver auto-registers on import, but we can call it
|
||||||
# explicitly
|
# explicitly
|
||||||
if hasattr(huawei_ce, 'register_custom_device_type'):
|
if hasattr(huawei_ce, "register_custom_device_type"):
|
||||||
huawei_ce.register_custom_device_type()
|
huawei_ce.register_custom_device_type()
|
||||||
except ImportError:
|
except ImportError:
|
||||||
# Silently skip if custom drivers are not available
|
# Silently skip if custom drivers are not available
|
||||||
pass
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Log but don't fail
|
# Log but don't fail
|
||||||
print(
|
print(f"Warning: Failed to register Huawei CE driver: {e}", file=sys.stderr)
|
||||||
f"Warning: Failed to register Huawei CE driver: {e}",
|
|
||||||
file=sys.stderr
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Import and register Ruijie Telnet driver
|
# Import and register Ruijie Telnet driver
|
||||||
from gns3server.agent.gns3_copilot.utils.custom_netmiko \
|
from gns3server.agent.gns3_copilot.utils.custom_netmiko import ruijie_telnet
|
||||||
import ruijie_telnet
|
|
||||||
|
|
||||||
# Store custom device types before registration
|
# Store custom device types before registration
|
||||||
custom_devices.add("gns3_ruijie_telnet")
|
custom_devices.add("gns3_ruijie_telnet")
|
||||||
|
|
||||||
# The driver auto-registers on import, but we can call it
|
# The driver auto-registers on import, but we can call it
|
||||||
# explicitly
|
# explicitly
|
||||||
if hasattr(ruijie_telnet, 'register_custom_device_type'):
|
if hasattr(ruijie_telnet, "register_custom_device_type"):
|
||||||
ruijie_telnet.register_custom_device_type()
|
ruijie_telnet.register_custom_device_type()
|
||||||
except ImportError:
|
except ImportError:
|
||||||
# Silently skip if custom drivers are not available
|
# Silently skip if custom drivers are not available
|
||||||
pass
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Log but don't fail
|
# Log but don't fail
|
||||||
print(
|
print(f"Warning: Failed to register Ruijie driver: {e}", file=sys.stderr)
|
||||||
f"Warning: Failed to register Ruijie driver: {e}",
|
|
||||||
file=sys.stderr
|
|
||||||
)
|
|
||||||
|
|
||||||
return custom_devices
|
return custom_devices
|
||||||
|
|
||||||
@ -124,32 +117,32 @@ def extract_brand_name(device_type: str) -> str:
|
|||||||
Brand name in a standardized format
|
Brand name in a standardized format
|
||||||
"""
|
"""
|
||||||
# Remove common suffixes
|
# Remove common suffixes
|
||||||
name = device_type.replace('_telnet', '').replace('_ssh', '')
|
name = device_type.replace("_telnet", "").replace("_ssh", "")
|
||||||
name = name.replace('_serial', '')
|
name = name.replace("_serial", "")
|
||||||
|
|
||||||
# Map to standard brand names
|
# Map to standard brand names
|
||||||
brand_mapping = {
|
brand_mapping = {
|
||||||
'cisco': 'Cisco',
|
"cisco": "Cisco",
|
||||||
'huawei': 'Huawei',
|
"huawei": "Huawei",
|
||||||
'juniper': 'Juniper',
|
"juniper": "Juniper",
|
||||||
'arista': 'Arista',
|
"arista": "Arista",
|
||||||
'hp': 'HP',
|
"hp": "HP",
|
||||||
'aruba': 'Aruba',
|
"aruba": "Aruba",
|
||||||
'dell': 'Dell',
|
"dell": "Dell",
|
||||||
'brocade': 'Brocade',
|
"brocade": "Brocade",
|
||||||
'extreme': 'Extreme',
|
"extreme": "Extreme",
|
||||||
'ruckus': 'Ruckus',
|
"ruckus": "Ruckus",
|
||||||
'ruijie': 'Ruijie (锐捷)',
|
"ruijie": "Ruijie (锐捷)",
|
||||||
'zte': 'ZTE (中兴)',
|
"zte": "ZTE (中兴)",
|
||||||
'maipu': 'Maipu (迈普)',
|
"maipu": "Maipu (迈普)",
|
||||||
'h3c': 'H3C (华三)',
|
"h3c": "H3C (华三)",
|
||||||
'nokia': 'Nokia',
|
"nokia": "Nokia",
|
||||||
'paloalto': 'Palo Alto',
|
"paloalto": "Palo Alto",
|
||||||
'f5': 'F5',
|
"f5": "F5",
|
||||||
'checkpoint': 'Check Point',
|
"checkpoint": "Check Point",
|
||||||
'generic': 'Generic',
|
"generic": "Generic",
|
||||||
'huaweiyt': 'HuaweiYT',
|
"huaweiyt": "HuaweiYT",
|
||||||
'vsz': 'Ruckus',
|
"vsz": "Ruckus",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Check for exact matches first
|
# Check for exact matches first
|
||||||
@ -163,7 +156,7 @@ def extract_brand_name(device_type: str) -> str:
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
# Return capitalized version of the first part
|
# Return capitalized version of the first part
|
||||||
return name.split('_')[0].capitalize()
|
return name.split("_")[0].capitalize()
|
||||||
|
|
||||||
|
|
||||||
def group_devices_by_brand(device_types: List[str]) -> Dict[str, List[str]]:
|
def group_devices_by_brand(device_types: List[str]) -> Dict[str, List[str]]:
|
||||||
@ -205,8 +198,8 @@ def get_devices_by_protocol() -> Tuple[List[str], List[str]]:
|
|||||||
|
|
||||||
for device_type in CLASS_MAPPER.keys():
|
for device_type in CLASS_MAPPER.keys():
|
||||||
device_lower = device_type.lower()
|
device_lower = device_type.lower()
|
||||||
has_telnet = 'telnet' in device_lower
|
has_telnet = "telnet" in device_lower
|
||||||
has_ssh = 'ssh' in device_lower
|
has_ssh = "ssh" in device_lower
|
||||||
|
|
||||||
if has_telnet and has_ssh:
|
if has_telnet and has_ssh:
|
||||||
both_protocols.append(device_type)
|
both_protocols.append(device_type)
|
||||||
@ -230,17 +223,14 @@ def get_total_device_count() -> int:
|
|||||||
"""Get total number of device types in Netmiko."""
|
"""Get total number of device types in Netmiko."""
|
||||||
try:
|
try:
|
||||||
from netmiko.ssh_dispatcher import CLASS_MAPPER
|
from netmiko.ssh_dispatcher import CLASS_MAPPER
|
||||||
|
|
||||||
return len(CLASS_MAPPER)
|
return len(CLASS_MAPPER)
|
||||||
except ImportError:
|
except ImportError:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def generate_markdown(
|
def generate_markdown(
|
||||||
version: str,
|
version: str, ssh_devices: List[str], telnet_devices: List[str], total_count: int, custom_devices: set
|
||||||
ssh_devices: List[str],
|
|
||||||
telnet_devices: List[str],
|
|
||||||
total_count: int,
|
|
||||||
custom_devices: set
|
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Generate Markdown documentation with table format.
|
Generate Markdown documentation with table format.
|
||||||
@ -261,7 +251,7 @@ def generate_markdown(
|
|||||||
md_lines.append("# Netmiko Supported Devices")
|
md_lines.append("# Netmiko Supported Devices")
|
||||||
md_lines.append("")
|
md_lines.append("")
|
||||||
md_lines.append(f"**Netmiko Version:** {version}")
|
md_lines.append(f"**Netmiko Version:** {version}")
|
||||||
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
md_lines.append(f"**Generated:** {timestamp}")
|
md_lines.append(f"**Generated:** {timestamp}")
|
||||||
md_lines.append("")
|
md_lines.append("")
|
||||||
md_lines.append("---")
|
md_lines.append("---")
|
||||||
@ -313,8 +303,7 @@ def generate_markdown(
|
|||||||
# Footer
|
# Footer
|
||||||
md_lines.append("---")
|
md_lines.append("---")
|
||||||
md_lines.append("")
|
md_lines.append("")
|
||||||
md_lines.append("*This document was generated automatically by the "
|
md_lines.append("*This document was generated automatically by the Netmiko device list script.*")
|
||||||
"Netmiko device list script.*")
|
|
||||||
|
|
||||||
return "\n".join(md_lines)
|
return "\n".join(md_lines)
|
||||||
|
|
||||||
@ -332,10 +321,8 @@ def save_markdown(content: str, filename: str = "netmiko_devices.md") -> None:
|
|||||||
# Go up from: .../gns3server/agent/gns3_copilot/utils/
|
# Go up from: .../gns3server/agent/gns3_copilot/utils/
|
||||||
# custom_netmiko/scripts to project root
|
# custom_netmiko/scripts to project root
|
||||||
# That's 6 levels up
|
# That's 6 levels up
|
||||||
project_root = os.path.dirname(os.path.dirname(
|
project_root = os.path.dirname(
|
||||||
os.path.dirname(os.path.dirname(
|
os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(script_dir)))))
|
||||||
os.path.dirname(os.path.dirname(script_dir))))
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Target directory: docs/gns3-copilot
|
# Target directory: docs/gns3-copilot
|
||||||
@ -348,17 +335,15 @@ def save_markdown(content: str, filename: str = "netmiko_devices.md") -> None:
|
|||||||
output_path = os.path.join(target_dir, filename)
|
output_path = os.path.join(target_dir, filename)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(output_path, 'w', encoding='utf-8') as f:
|
with open(output_path, "w", encoding="utf-8") as f:
|
||||||
f.write(content)
|
f.write(content)
|
||||||
print(f"Markdown document saved to: {output_path}")
|
print(f"Markdown document saved to: {output_path}")
|
||||||
except IOError as e:
|
except IOError as e:
|
||||||
print(f"Error: Failed to save Markdown file: {e}",
|
print(f"Error: Failed to save Markdown file: {e}", file=sys.stderr)
|
||||||
file=sys.stderr)
|
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
def print_summary(version: str, ssh_count: int, telnet_count: int,
|
def print_summary(version: str, ssh_count: int, telnet_count: int, total_count: int, output_file: str) -> None:
|
||||||
total_count: int, output_file: str) -> None:
|
|
||||||
"""Print summary to console."""
|
"""Print summary to console."""
|
||||||
print("=" * 80)
|
print("=" * 80)
|
||||||
print("Netmiko Device List Generator")
|
print("Netmiko Device List Generator")
|
||||||
@ -402,7 +387,7 @@ def main() -> int:
|
|||||||
ssh_devices=ssh_devices,
|
ssh_devices=ssh_devices,
|
||||||
telnet_devices=telnet_devices,
|
telnet_devices=telnet_devices,
|
||||||
total_count=total_count,
|
total_count=total_count,
|
||||||
custom_devices=custom_devices
|
custom_devices=custom_devices,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Save to file
|
# Save to file
|
||||||
@ -415,7 +400,7 @@ def main() -> int:
|
|||||||
ssh_count=len(ssh_devices),
|
ssh_count=len(ssh_devices),
|
||||||
telnet_count=len(telnet_devices),
|
telnet_count=len(telnet_devices),
|
||||||
total_count=total_count,
|
total_count=total_count,
|
||||||
output_file=output_file
|
output_file=output_file,
|
||||||
)
|
)
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@ -25,9 +25,7 @@ import os
|
|||||||
|
|
||||||
# Add project root to path using relative path
|
# Add project root to path using relative path
|
||||||
test_dir = os.path.dirname(os.path.abspath(__file__))
|
test_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
project_root = os.path.dirname(
|
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(test_dir)))))
|
||||||
os.path.dirname(os.path.dirname(
|
|
||||||
os.path.dirname(os.path.dirname(test_dir)))))
|
|
||||||
sys.path.insert(0, project_root)
|
sys.path.insert(0, project_root)
|
||||||
|
|
||||||
|
|
||||||
@ -103,13 +101,8 @@ class TestHuaweiTelnetCEDriver(unittest.TestCase):
|
|||||||
def test_initialization_parameters(self):
|
def test_initialization_parameters(self):
|
||||||
"""Test that initialization sets correct parameters."""
|
"""Test that initialization sets correct parameters."""
|
||||||
# Create a mock instance (without actual connection)
|
# Create a mock instance (without actual connection)
|
||||||
with patch.object(
|
with patch.object(self.HuaweiTelnetCE, "__init__", lambda self, *args, **kwargs: None):
|
||||||
self.HuaweiTelnetCE, "__init__",
|
instance = self.HuaweiTelnetCE.__new__(self.HuaweiTelnetCE)
|
||||||
lambda self, *args, **kwargs: None
|
|
||||||
):
|
|
||||||
instance = self.HuaweiTelnetCE.__new__(
|
|
||||||
self.HuaweiTelnetCE
|
|
||||||
)
|
|
||||||
|
|
||||||
# Mock the necessary attributes
|
# Mock the necessary attributes
|
||||||
instance.protocol = "telnet"
|
instance.protocol = "telnet"
|
||||||
@ -169,9 +162,7 @@ class TestHuaweiTelnetCEIntegration(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Create a mock instance
|
# Create a mock instance
|
||||||
instance = GNS3HuaweiTelnetCE.__new__(
|
instance = GNS3HuaweiTelnetCE.__new__(GNS3HuaweiTelnetCE)
|
||||||
GNS3HuaweiTelnetCE
|
|
||||||
)
|
|
||||||
|
|
||||||
# Mock the necessary attributes and methods
|
# Mock the necessary attributes and methods
|
||||||
instance.host = "127.0.0.1"
|
instance.host = "127.0.0.1"
|
||||||
@ -203,12 +194,8 @@ def run_tests():
|
|||||||
suite = unittest.TestSuite()
|
suite = unittest.TestSuite()
|
||||||
|
|
||||||
# Add test cases
|
# Add test cases
|
||||||
suite.addTests(
|
suite.addTests(loader.loadTestsFromTestCase(TestHuaweiTelnetCEDriver))
|
||||||
loader.loadTestsFromTestCase(TestHuaweiTelnetCEDriver)
|
suite.addTests(loader.loadTestsFromTestCase(TestHuaweiTelnetCEIntegration))
|
||||||
)
|
|
||||||
suite.addTests(
|
|
||||||
loader.loadTestsFromTestCase(TestHuaweiTelnetCEIntegration)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Run tests
|
# Run tests
|
||||||
runner = unittest.TextTestRunner(verbosity=2)
|
runner = unittest.TextTestRunner(verbosity=2)
|
||||||
@ -218,9 +205,7 @@ def run_tests():
|
|||||||
print("\n" + "=" * 100)
|
print("\n" + "=" * 100)
|
||||||
print("Test Summary:")
|
print("Test Summary:")
|
||||||
print(f" Run: {result.testsRun}")
|
print(f" Run: {result.testsRun}")
|
||||||
success_count = (
|
success_count = result.testsRun - len(result.failures) - len(result.errors)
|
||||||
result.testsRun - len(result.failures) - len(result.errors)
|
|
||||||
)
|
|
||||||
print(f" Success: {success_count}")
|
print(f" Success: {success_count}")
|
||||||
print(f" Failed: {len(result.failures)}")
|
print(f" Failed: {len(result.failures)}")
|
||||||
print(f" Errors: {len(result.errors)}")
|
print(f" Errors: {len(result.errors)}")
|
||||||
|
|||||||
@ -25,9 +25,7 @@ import os
|
|||||||
|
|
||||||
# Add project root to path using relative path
|
# Add project root to path using relative path
|
||||||
test_dir = os.path.dirname(os.path.abspath(__file__))
|
test_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
project_root = os.path.dirname(
|
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(test_dir)))))
|
||||||
os.path.dirname(os.path.dirname(
|
|
||||||
os.path.dirname(os.path.dirname(test_dir)))))
|
|
||||||
sys.path.insert(0, project_root)
|
sys.path.insert(0, project_root)
|
||||||
|
|
||||||
|
|
||||||
@ -92,9 +90,7 @@ class TestRuijieTelnetEnhancedDriver(unittest.TestCase):
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
# Should have INTERACTIVE_PATTERNS as a class attribute
|
# Should have INTERACTIVE_PATTERNS as a class attribute
|
||||||
self.assertTrue(
|
self.assertTrue(hasattr(self.RuijieTelnetEnhanced, "INTERACTIVE_PATTERNS"))
|
||||||
hasattr(self.RuijieTelnetEnhanced, "INTERACTIVE_PATTERNS")
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check it's a list
|
# Check it's a list
|
||||||
patterns = self.RuijieTelnetEnhanced.INTERACTIVE_PATTERNS
|
patterns = self.RuijieTelnetEnhanced.INTERACTIVE_PATTERNS
|
||||||
@ -108,18 +104,11 @@ class TestRuijieTelnetEnhancedDriver(unittest.TestCase):
|
|||||||
def test_preprocess_interactive_commands(self):
|
def test_preprocess_interactive_commands(self):
|
||||||
"""Test that interactive commands are preprocessed correctly."""
|
"""Test that interactive commands are preprocessed correctly."""
|
||||||
# Create a mock instance (without actual connection)
|
# Create a mock instance (without actual connection)
|
||||||
with patch.object(
|
with patch.object(self.RuijieTelnetEnhanced, "__init__", lambda self, *args, **kwargs: None):
|
||||||
self.RuijieTelnetEnhanced, "__init__",
|
instance = self.RuijieTelnetEnhanced.__new__(self.RuijieTelnetEnhanced)
|
||||||
lambda self, *args, **kwargs: None
|
|
||||||
):
|
|
||||||
instance = self.RuijieTelnetEnhanced.__new__(
|
|
||||||
self.RuijieTelnetEnhanced
|
|
||||||
)
|
|
||||||
|
|
||||||
# Set INTERACTIVE_PATTERNS from class
|
# Set INTERACTIVE_PATTERNS from class
|
||||||
instance.INTERACTIVE_PATTERNS = (
|
instance.INTERACTIVE_PATTERNS = self.RuijieTelnetEnhanced.INTERACTIVE_PATTERNS
|
||||||
self.RuijieTelnetEnhanced.INTERACTIVE_PATTERNS
|
|
||||||
)
|
|
||||||
|
|
||||||
# Test commands with router-id (should trigger 'yes')
|
# Test commands with router-id (should trigger 'yes')
|
||||||
commands = [
|
commands = [
|
||||||
@ -128,34 +117,23 @@ class TestRuijieTelnetEnhancedDriver(unittest.TestCase):
|
|||||||
"network 10.0.0.0 0.0.0.255 area 0",
|
"network 10.0.0.0 0.0.0.255 area 0",
|
||||||
]
|
]
|
||||||
|
|
||||||
processed = instance._preprocess_interactive_commands(
|
processed = instance._preprocess_interactive_commands(commands)
|
||||||
commands
|
|
||||||
)
|
|
||||||
|
|
||||||
# Should have inserted 'yes' after router-id command
|
# Should have inserted 'yes' after router-id command
|
||||||
self.assertEqual(len(processed), 4)
|
self.assertEqual(len(processed), 4)
|
||||||
self.assertEqual(processed[0], "router ospf 1")
|
self.assertEqual(processed[0], "router ospf 1")
|
||||||
self.assertEqual(processed[1], "router-id 1.1.1.1")
|
self.assertEqual(processed[1], "router-id 1.1.1.1")
|
||||||
self.assertEqual(processed[2], "yes")
|
self.assertEqual(processed[2], "yes")
|
||||||
self.assertEqual(
|
self.assertEqual(processed[3], "network 10.0.0.0 0.0.0.255 area 0")
|
||||||
processed[3], "network 10.0.0.0 0.0.0.255 area 0"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_preprocess_non_interactive_commands(self):
|
def test_preprocess_non_interactive_commands(self):
|
||||||
"""Test that non-interactive commands are not modified."""
|
"""Test that non-interactive commands are not modified."""
|
||||||
# Create a mock instance
|
# Create a mock instance
|
||||||
with patch.object(
|
with patch.object(self.RuijieTelnetEnhanced, "__init__", lambda self, *args, **kwargs: None):
|
||||||
self.RuijieTelnetEnhanced, "__init__",
|
instance = self.RuijieTelnetEnhanced.__new__(self.RuijieTelnetEnhanced)
|
||||||
lambda self, *args, **kwargs: None
|
|
||||||
):
|
|
||||||
instance = self.RuijieTelnetEnhanced.__new__(
|
|
||||||
self.RuijieTelnetEnhanced
|
|
||||||
)
|
|
||||||
|
|
||||||
# Set INTERACTIVE_PATTERNS from class
|
# Set INTERACTIVE_PATTERNS from class
|
||||||
instance.INTERACTIVE_PATTERNS = (
|
instance.INTERACTIVE_PATTERNS = self.RuijieTelnetEnhanced.INTERACTIVE_PATTERNS
|
||||||
self.RuijieTelnetEnhanced.INTERACTIVE_PATTERNS
|
|
||||||
)
|
|
||||||
|
|
||||||
# Test commands without interactive prompts
|
# Test commands without interactive prompts
|
||||||
commands = [
|
commands = [
|
||||||
@ -190,9 +168,7 @@ class TestRuijieTelnetEnhancedDriver(unittest.TestCase):
|
|||||||
|
|
||||||
# Check that default_enter is handled in __init__
|
# Check that default_enter is handled in __init__
|
||||||
# (The actual logic is in the __init__ method body)
|
# (The actual logic is in the __init__ method body)
|
||||||
init_source = inspect.getsource(
|
init_source = inspect.getsource(self.RuijieTelnetEnhanced.__init__)
|
||||||
self.RuijieTelnetEnhanced.__init__
|
|
||||||
)
|
|
||||||
self.assertIn("default_enter", init_source)
|
self.assertIn("default_enter", init_source)
|
||||||
self.assertIn("\\r\\n", init_source)
|
self.assertIn("\\r\\n", init_source)
|
||||||
|
|
||||||
@ -207,9 +183,7 @@ class TestRuijieTelnetEnhancedIntegration(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Create a mock instance
|
# Create a mock instance
|
||||||
instance = RuijieTelnetEnhanced.__new__(
|
instance = RuijieTelnetEnhanced.__new__(RuijieTelnetEnhanced)
|
||||||
RuijieTelnetEnhanced
|
|
||||||
)
|
|
||||||
|
|
||||||
# Mock the necessary attributes and methods
|
# Mock the necessary attributes and methods
|
||||||
instance.RETURN = "\r\n"
|
instance.RETURN = "\r\n"
|
||||||
@ -224,9 +198,7 @@ class TestRuijieTelnetEnhancedIntegration(unittest.TestCase):
|
|||||||
commands = ["interface GigabitEthernet 0/1", "description Test"]
|
commands = ["interface GigabitEthernet 0/1", "description Test"]
|
||||||
|
|
||||||
# Call send_config_set
|
# Call send_config_set
|
||||||
instance.send_config_set(
|
instance.send_config_set(commands, enter_config_mode=False, exit_config_mode=False)
|
||||||
commands, enter_config_mode=False, exit_config_mode=False
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify _send_config_batch was called
|
# Verify _send_config_batch was called
|
||||||
instance._send_config_batch.assert_called_once()
|
instance._send_config_batch.assert_called_once()
|
||||||
@ -238,17 +210,13 @@ class TestRuijieTelnetEnhancedIntegration(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Create a mock instance
|
# Create a mock instance
|
||||||
instance = RuijieTelnetEnhanced.__new__(
|
instance = RuijieTelnetEnhanced.__new__(RuijieTelnetEnhanced)
|
||||||
RuijieTelnetEnhanced
|
instance.INTERACTIVE_PATTERNS = RuijieTelnetEnhanced.INTERACTIVE_PATTERNS
|
||||||
)
|
|
||||||
instance.INTERACTIVE_PATTERNS = (
|
|
||||||
RuijieTelnetEnhanced.INTERACTIVE_PATTERNS
|
|
||||||
)
|
|
||||||
|
|
||||||
# Test commands with multiple interactive prompts
|
# Test commands with multiple interactive prompts
|
||||||
commands = [
|
commands = [
|
||||||
"router-id 1.1.1.1", # Should trigger 'yes'
|
"router-id 1.1.1.1", # Should trigger 'yes'
|
||||||
"erase startup-config", # Should trigger 'yes'
|
"erase startup-config", # Should trigger 'yes'
|
||||||
"interface GigabitEthernet 0/1", # Should NOT trigger
|
"interface GigabitEthernet 0/1", # Should NOT trigger
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -273,12 +241,8 @@ def run_tests():
|
|||||||
suite = unittest.TestSuite()
|
suite = unittest.TestSuite()
|
||||||
|
|
||||||
# Add test cases
|
# Add test cases
|
||||||
suite.addTests(
|
suite.addTests(loader.loadTestsFromTestCase(TestRuijieTelnetEnhancedDriver))
|
||||||
loader.loadTestsFromTestCase(TestRuijieTelnetEnhancedDriver)
|
suite.addTests(loader.loadTestsFromTestCase(TestRuijieTelnetEnhancedIntegration))
|
||||||
)
|
|
||||||
suite.addTests(
|
|
||||||
loader.loadTestsFromTestCase(TestRuijieTelnetEnhancedIntegration)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Run tests
|
# Run tests
|
||||||
runner = unittest.TextTestRunner(verbosity=2)
|
runner = unittest.TextTestRunner(verbosity=2)
|
||||||
@ -288,9 +252,7 @@ def run_tests():
|
|||||||
print("\n" + "=" * 100)
|
print("\n" + "=" * 100)
|
||||||
print("Test Summary:")
|
print("Test Summary:")
|
||||||
print(f" Run: {result.testsRun}")
|
print(f" Run: {result.testsRun}")
|
||||||
success_count = (
|
success_count = result.testsRun - len(result.failures) - len(result.errors)
|
||||||
result.testsRun - len(result.failures) - len(result.errors)
|
|
||||||
)
|
|
||||||
print(f" Success: {success_count}")
|
print(f" Success: {success_count}")
|
||||||
print(f" Failed: {len(result.failures)}")
|
print(f" Failed: {len(result.failures)}")
|
||||||
print(f" Errors: {len(result.errors)}")
|
print(f" Errors: {len(result.errors)}")
|
||||||
|
|||||||
@ -27,9 +27,7 @@ import os
|
|||||||
|
|
||||||
# Add project root to path using relative path
|
# Add project root to path using relative path
|
||||||
test_dir = os.path.dirname(os.path.abspath(__file__))
|
test_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
project_root = os.path.dirname(
|
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(test_dir)))))
|
||||||
os.path.dirname(os.path.dirname(
|
|
||||||
os.path.dirname(os.path.dirname(test_dir)))))
|
|
||||||
sys.path.insert(0, project_root)
|
sys.path.insert(0, project_root)
|
||||||
|
|
||||||
|
|
||||||
@ -121,6 +119,7 @@ class TestVPCSTelnetInit(unittest.TestCase):
|
|||||||
from gns3server.agent.gns3_copilot.utils.custom_netmiko.vpcs_telnet import ( # noqa: E501
|
from gns3server.agent.gns3_copilot.utils.custom_netmiko.vpcs_telnet import ( # noqa: E501
|
||||||
VPCSTelnet,
|
VPCSTelnet,
|
||||||
)
|
)
|
||||||
|
|
||||||
cls.VPCSTelnet = VPCSTelnet
|
cls.VPCSTelnet = VPCSTelnet
|
||||||
|
|
||||||
def test_default_enter_parameter(self):
|
def test_default_enter_parameter(self):
|
||||||
@ -134,6 +133,7 @@ class TestVPCSTelnetInit(unittest.TestCase):
|
|||||||
|
|
||||||
def test_default_parameters_when_none(self):
|
def test_default_parameters_when_none(self):
|
||||||
"""Test default parameters when not provided."""
|
"""Test default parameters when not provided."""
|
||||||
|
|
||||||
# Mock the BaseConnection.__init__ to avoid actual connection
|
# Mock the BaseConnection.__init__ to avoid actual connection
|
||||||
# But also set device_type since VPCSTelnet.__init__ sets it
|
# But also set device_type since VPCSTelnet.__init__ sets it
|
||||||
def mock_init(self, *args, **kwargs):
|
def mock_init(self, *args, **kwargs):
|
||||||
@ -141,23 +141,20 @@ class TestVPCSTelnetInit(unittest.TestCase):
|
|||||||
kwargs.setdefault("device_type", "gns3_vpcs_telnet")
|
kwargs.setdefault("device_type", "gns3_vpcs_telnet")
|
||||||
self.device_type = kwargs.get("device_type")
|
self.device_type = kwargs.get("device_type")
|
||||||
|
|
||||||
with patch.object(
|
with patch.object(self.VPCSTelnet, "__init__", mock_init):
|
||||||
self.VPCSTelnet, "__init__", mock_init
|
|
||||||
):
|
|
||||||
instance = self.VPCSTelnet(host="127.0.0.1")
|
instance = self.VPCSTelnet(host="127.0.0.1")
|
||||||
self.assertEqual(instance.device_type, "gns3_vpcs_telnet")
|
self.assertEqual(instance.device_type, "gns3_vpcs_telnet")
|
||||||
|
|
||||||
def test_device_type_set(self):
|
def test_device_type_set(self):
|
||||||
"""Test that device_type is set to gns3_vpcs_telnet."""
|
"""Test that device_type is set to gns3_vpcs_telnet."""
|
||||||
|
|
||||||
# Mock the __init__ to set device_type
|
# Mock the __init__ to set device_type
|
||||||
def mock_init(self, *args, **kwargs):
|
def mock_init(self, *args, **kwargs):
|
||||||
# Simulate what VPCSTelnet.__init__ does
|
# Simulate what VPCSTelnet.__init__ does
|
||||||
kwargs.setdefault("device_type", "gns3_vpcs_telnet")
|
kwargs.setdefault("device_type", "gns3_vpcs_telnet")
|
||||||
self.device_type = kwargs.get("device_type")
|
self.device_type = kwargs.get("device_type")
|
||||||
|
|
||||||
with patch.object(
|
with patch.object(self.VPCSTelnet, "__init__", mock_init):
|
||||||
self.VPCSTelnet, "__init__", mock_init
|
|
||||||
):
|
|
||||||
instance = self.VPCSTelnet(host="127.0.0.1")
|
instance = self.VPCSTelnet(host="127.0.0.1")
|
||||||
self.assertEqual(instance.device_type, "gns3_vpcs_telnet")
|
self.assertEqual(instance.device_type, "gns3_vpcs_telnet")
|
||||||
|
|
||||||
@ -171,6 +168,7 @@ class TestVPCSTelnetMethods(unittest.TestCase):
|
|||||||
from gns3server.agent.gns3_copilot.utils.custom_netmiko.vpcs_telnet import ( # noqa: E501
|
from gns3server.agent.gns3_copilot.utils.custom_netmiko.vpcs_telnet import ( # noqa: E501
|
||||||
VPCSTelnet,
|
VPCSTelnet,
|
||||||
)
|
)
|
||||||
|
|
||||||
cls.VPCSTelnet = VPCSTelnet
|
cls.VPCSTelnet = VPCSTelnet
|
||||||
|
|
||||||
def test_check_config_mode_always_false(self):
|
def test_check_config_mode_always_false(self):
|
||||||
@ -213,6 +211,7 @@ class TestVPCSTelnetSendCommand(unittest.TestCase):
|
|||||||
from gns3server.agent.gns3_copilot.utils.custom_netmiko.vpcs_telnet import ( # noqa: E501
|
from gns3server.agent.gns3_copilot.utils.custom_netmiko.vpcs_telnet import ( # noqa: E501
|
||||||
VPCSTelnet,
|
VPCSTelnet,
|
||||||
)
|
)
|
||||||
|
|
||||||
cls.VPCSTelnet = VPCSTelnet
|
cls.VPCSTelnet = VPCSTelnet
|
||||||
|
|
||||||
def test_send_command_writes_bytes(self):
|
def test_send_command_writes_bytes(self):
|
||||||
@ -243,7 +242,7 @@ class TestVPCSTelnetSendCommand(unittest.TestCase):
|
|||||||
|
|
||||||
# Check that expect_string defaults to r"PC\d+>"
|
# Check that expect_string defaults to r"PC\d+>"
|
||||||
source = inspect.getsource(self.VPCSTelnet.send_command)
|
source = inspect.getsource(self.VPCSTelnet.send_command)
|
||||||
self.assertIn("expect_string = r\"PC\\d+>\"", source)
|
self.assertIn('expect_string = r"PC\\d+>"', source)
|
||||||
|
|
||||||
def test_send_command_timing_calls_send_command(self):
|
def test_send_command_timing_calls_send_command(self):
|
||||||
"""Test that send_command_timing delegates to send_command."""
|
"""Test that send_command_timing delegates to send_command."""
|
||||||
@ -277,6 +276,7 @@ class TestVPCSTelnetTelnetLogin(unittest.TestCase):
|
|||||||
from gns3server.agent.gns3_copilot.utils.custom_netmiko.vpcs_telnet import ( # noqa: E501
|
from gns3server.agent.gns3_copilot.utils.custom_netmiko.vpcs_telnet import ( # noqa: E501
|
||||||
VPCSTelnet,
|
VPCSTelnet,
|
||||||
)
|
)
|
||||||
|
|
||||||
cls.VPCSTelnet = VPCSTelnet
|
cls.VPCSTelnet = VPCSTelnet
|
||||||
|
|
||||||
def test_telnet_login_default_prompt_pattern(self):
|
def test_telnet_login_default_prompt_pattern(self):
|
||||||
@ -335,19 +335,16 @@ class TestVPCSTelnetRegistration(unittest.TestCase):
|
|||||||
from gns3server.agent.gns3_copilot.utils.custom_netmiko import (
|
from gns3server.agent.gns3_copilot.utils.custom_netmiko import (
|
||||||
vpcs_telnet,
|
vpcs_telnet,
|
||||||
)
|
)
|
||||||
|
|
||||||
cls.vpcs_telnet = vpcs_telnet
|
cls.vpcs_telnet = vpcs_telnet
|
||||||
|
|
||||||
def test_register_function_exists(self):
|
def test_register_function_exists(self):
|
||||||
"""Test that register_custom_device_type function exists."""
|
"""Test that register_custom_device_type function exists."""
|
||||||
self.assertTrue(
|
self.assertTrue(hasattr(self.vpcs_telnet, "register_custom_device_type"))
|
||||||
hasattr(self.vpcs_telnet, "register_custom_device_type")
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_register_function_is_callable(self):
|
def test_register_function_is_callable(self):
|
||||||
"""Test that register_custom_device_type is callable."""
|
"""Test that register_custom_device_type is callable."""
|
||||||
self.assertTrue(
|
self.assertTrue(callable(self.vpcs_telnet.register_custom_device_type))
|
||||||
callable(self.vpcs_telnet.register_custom_device_type)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestVPCSTelnetAnsiStripping(unittest.TestCase):
|
class TestVPCSTelnetAnsiStripping(unittest.TestCase):
|
||||||
@ -359,6 +356,7 @@ class TestVPCSTelnetAnsiStripping(unittest.TestCase):
|
|||||||
from gns3server.agent.gns3_copilot.utils.custom_netmiko.vpcs_telnet import ( # noqa: E501
|
from gns3server.agent.gns3_copilot.utils.custom_netmiko.vpcs_telnet import ( # noqa: E501
|
||||||
VPCSTelnet,
|
VPCSTelnet,
|
||||||
)
|
)
|
||||||
|
|
||||||
cls.VPCSTelnet = VPCSTelnet
|
cls.VPCSTelnet = VPCSTelnet
|
||||||
|
|
||||||
def test_strip_ansi_codes_method_exists(self):
|
def test_strip_ansi_codes_method_exists(self):
|
||||||
@ -435,14 +433,10 @@ IP/MASK : 192.168.1.20/24"""
|
|||||||
# Mock necessary attributes
|
# Mock necessary attributes
|
||||||
instance.remote_conn = MagicMock()
|
instance.remote_conn = MagicMock()
|
||||||
instance.remote_conn.write = MagicMock()
|
instance.remote_conn.write = MagicMock()
|
||||||
instance.read_until_pattern = MagicMock(
|
instance.read_until_pattern = MagicMock(return_value="\x1b[1mNAME\x1b[0m : PC2\nPC2>")
|
||||||
return_value="\x1b[1mNAME\x1b[0m : PC2\nPC2>"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Mock _strip_ansi_codes to verify it's called
|
# Mock _strip_ansi_codes to verify it's called
|
||||||
instance._strip_ansi_codes = Mock(
|
instance._strip_ansi_codes = Mock(return_value="NAME : PC2\nPC2>")
|
||||||
return_value="NAME : PC2\nPC2>"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Call send_command
|
# Call send_command
|
||||||
instance.send_command("show ip")
|
instance.send_command("show ip")
|
||||||
@ -476,9 +470,7 @@ def run_tests():
|
|||||||
print("\n" + "=" * 100)
|
print("\n" + "=" * 100)
|
||||||
print("Test Summary:")
|
print("Test Summary:")
|
||||||
print(f" Run: {result.testsRun}")
|
print(f" Run: {result.testsRun}")
|
||||||
success_count = (
|
success_count = result.testsRun - len(result.failures) - len(result.errors)
|
||||||
result.testsRun - len(result.failures) - len(result.errors)
|
|
||||||
)
|
|
||||||
print(f" Success: {success_count}")
|
print(f" Success: {success_count}")
|
||||||
print(f" Failed: {len(result.failures)}")
|
print(f" Failed: {len(result.failures)}")
|
||||||
print(f" Errors: {len(result.errors)}")
|
print(f" Errors: {len(result.errors)}")
|
||||||
|
|||||||
@ -149,10 +149,7 @@ class VPCSTelnet(BaseConnection):
|
|||||||
# Step 3: Wait for VPCS prompt pattern
|
# Step 3: Wait for VPCS prompt pattern
|
||||||
try:
|
try:
|
||||||
# Read until we see the prompt
|
# Read until we see the prompt
|
||||||
output = self.read_until_pattern(
|
output = self.read_until_pattern(pattern=pri_prompt_terminator, read_timeout=10)
|
||||||
pattern=pri_prompt_terminator,
|
|
||||||
read_timeout=10
|
|
||||||
)
|
|
||||||
return_msg += output
|
return_msg += output
|
||||||
|
|
||||||
if re.search(pri_prompt_terminator, return_msg, flags=re.M):
|
if re.search(pri_prompt_terminator, return_msg, flags=re.M):
|
||||||
@ -291,9 +288,7 @@ class VPCSTelnet(BaseConnection):
|
|||||||
strip_command=strip_command,
|
strip_command=strip_command,
|
||||||
)
|
)
|
||||||
|
|
||||||
def check_config_mode(
|
def check_config_mode(self, check_string: str = "", pattern: str = "", force_regex: bool = False) -> bool:
|
||||||
self, check_string: str = "", pattern: str = "", force_regex: bool = False
|
|
||||||
) -> bool:
|
|
||||||
"""
|
"""
|
||||||
VPCS has no configuration mode.
|
VPCS has no configuration mode.
|
||||||
|
|
||||||
@ -326,9 +321,7 @@ class VPCSTelnet(BaseConnection):
|
|||||||
"""
|
"""
|
||||||
return ANSI_ESCAPE_PATTERN.sub("", text)
|
return ANSI_ESCAPE_PATTERN.sub("", text)
|
||||||
|
|
||||||
def config_mode(
|
def config_mode(self, config_command: str = "", pattern: str = "", re_flags: int = 0) -> str:
|
||||||
self, config_command: str = "", pattern: str = "", re_flags: int = 0
|
|
||||||
) -> str:
|
|
||||||
"""
|
"""
|
||||||
VPCS has no configuration mode.
|
VPCS has no configuration mode.
|
||||||
|
|
||||||
@ -370,6 +363,7 @@ class VPCSTelnet(BaseConnection):
|
|||||||
# Register the custom device type with Netmiko
|
# Register the custom device type with Netmiko
|
||||||
_registered = False # Flag to prevent duplicate registration
|
_registered = False # Flag to prevent duplicate registration
|
||||||
|
|
||||||
|
|
||||||
def register_custom_device_type() -> None:
|
def register_custom_device_type() -> None:
|
||||||
"""
|
"""
|
||||||
Register the custom VPCS Telnet device type with Netmiko.
|
Register the custom VPCS Telnet device type with Netmiko.
|
||||||
@ -431,8 +425,4 @@ try:
|
|||||||
register_custom_device_type()
|
register_custom_device_type()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Log but don't fail on import
|
# Log but don't fail on import
|
||||||
logger.warning(
|
logger.warning("Failed to register VPCS device type: %s", e, exc_info=True)
|
||||||
"Failed to register VPCS device type: %s",
|
|
||||||
e,
|
|
||||||
exc_info=True
|
|
||||||
)
|
|
||||||
|
|||||||
@ -61,10 +61,7 @@ def format_error_message(error: Exception) -> str:
|
|||||||
|
|
||||||
# Check if error message contains HTML
|
# Check if error message contains HTML
|
||||||
if _contains_html(error_str):
|
if _contains_html(error_str):
|
||||||
logger.warning(
|
logger.warning("Detected HTML in error message, likely due to incorrect API configuration or base URL")
|
||||||
"Detected HTML in error message, likely due to incorrect API "
|
|
||||||
"configuration or base URL"
|
|
||||||
)
|
|
||||||
return (
|
return (
|
||||||
"API request failed. The error response indicates an issue with "
|
"API request failed. The error response indicates an issue with "
|
||||||
"your API configuration. Please check:\n"
|
"your API configuration. Please check:\n"
|
||||||
|
|||||||
@ -71,9 +71,7 @@ def get_device_ports_from_topology(
|
|||||||
Devices that don't exist or missing console_port will not be included
|
Devices that don't exist or missing console_port will not be included
|
||||||
"""
|
"""
|
||||||
# Log received parameters
|
# Log received parameters
|
||||||
logger.info(
|
logger.info("Called with device_names=%s, project_id=%s", device_names, project_id)
|
||||||
"Called with device_names=%s, project_id=%s", device_names, project_id
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Lazy import to avoid circular dependency
|
# Lazy import to avoid circular dependency
|
||||||
@ -93,9 +91,7 @@ def get_device_ports_from_topology(
|
|||||||
for device_name in device_names:
|
for device_name in device_names:
|
||||||
# Check if device exists in topology
|
# Check if device exists in topology
|
||||||
if device_name not in topology.get("nodes", {}):
|
if device_name not in topology.get("nodes", {}):
|
||||||
logger.warning(
|
logger.warning("Device '%s' not found in topology", device_name)
|
||||||
"Device '%s' not found in topology", device_name
|
|
||||||
)
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
node_info = topology["nodes"][device_name]
|
node_info = topology["nodes"][device_name]
|
||||||
@ -133,9 +129,7 @@ def get_device_ports_from_topology(
|
|||||||
f"Current tags: {tags}"
|
f"Current tags: {tags}"
|
||||||
)
|
)
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
hosts_data[device_name] = {
|
hosts_data[device_name] = {"error": error_msg}
|
||||||
"error": error_msg
|
|
||||||
}
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@ -170,11 +164,7 @@ def get_device_ports_from_topology(
|
|||||||
"platform": platform,
|
"platform": platform,
|
||||||
"node_type": node_info.get("type"),
|
"node_type": node_info.get("type"),
|
||||||
"groups": ["network_devices"], # For inheriting hostname, timeout, etc.
|
"groups": ["network_devices"], # For inheriting hostname, timeout, etc.
|
||||||
"connection_options": {
|
"connection_options": {"netmiko": {"extras": {"device_type": device_type}}},
|
||||||
"netmiko": {
|
|
||||||
"extras": {"device_type": device_type}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Per-node default credentials (seeded from the template appliance
|
# Per-node default credentials (seeded from the template appliance
|
||||||
|
|||||||
@ -135,14 +135,9 @@ def calculate_two_node_shape(
|
|||||||
node2_center_x = node2["x"] + (node2_width / 2)
|
node2_center_x = node2["x"] + (node2_width / 2)
|
||||||
node2_center_y = node2["y"] + (node2_height / 2)
|
node2_center_y = node2["y"] + (node2_height / 2)
|
||||||
|
|
||||||
distance = math.sqrt(
|
distance = math.sqrt((node2_center_x - node1_center_x) ** 2 + (node2_center_y - node1_center_y) ** 2)
|
||||||
(node2_center_x - node1_center_x) ** 2
|
|
||||||
+ (node2_center_y - node1_center_y) ** 2
|
|
||||||
)
|
|
||||||
|
|
||||||
angle_rad = math.atan2(
|
angle_rad = math.atan2(node2_center_y - node1_center_y, node2_center_x - node1_center_x)
|
||||||
node2_center_y - node1_center_y, node2_center_x - node1_center_x
|
|
||||||
)
|
|
||||||
angle_deg = round(math.degrees(angle_rad))
|
angle_deg = round(math.degrees(angle_rad))
|
||||||
angle_rad = math.radians(angle_deg)
|
angle_rad = math.radians(angle_deg)
|
||||||
|
|
||||||
@ -162,16 +157,10 @@ def calculate_two_node_shape(
|
|||||||
shape_width = rx * 2
|
shape_width = rx * 2
|
||||||
shape_height = ry * 2
|
shape_height = ry * 2
|
||||||
|
|
||||||
svg_x = center_x - (
|
svg_x = center_x - (rx * math.cos(angle_rad) - ry * math.sin(angle_rad))
|
||||||
rx * math.cos(angle_rad) - ry * math.sin(angle_rad)
|
svg_y = center_y - (rx * math.sin(angle_rad) + ry * math.cos(angle_rad))
|
||||||
)
|
|
||||||
svg_y = center_y - (
|
|
||||||
rx * math.sin(angle_rad) + ry * math.cos(angle_rad)
|
|
||||||
)
|
|
||||||
|
|
||||||
shape_svg = generate_ellipse_svg(
|
shape_svg = generate_ellipse_svg(int(rx), int(ry), color_scheme, int(shape_width), int(shape_height))
|
||||||
int(rx), int(ry), color_scheme, int(shape_width), int(shape_height)
|
|
||||||
)
|
|
||||||
|
|
||||||
offset_distance = ry * text_offset_ratio
|
offset_distance = ry * text_offset_ratio
|
||||||
|
|
||||||
@ -188,22 +177,12 @@ def calculate_two_node_shape(
|
|||||||
|
|
||||||
else: # rectangle
|
else: # rectangle
|
||||||
shape_width = distance
|
shape_width = distance
|
||||||
shape_height = max(
|
shape_height = max(node1_width, node1_height, node2_width, node2_height)
|
||||||
node1_width, node1_height, node2_width, node2_height
|
|
||||||
)
|
|
||||||
|
|
||||||
svg_x = center_x - (
|
svg_x = center_x - ((shape_width / 2) * math.cos(angle_rad) - (shape_height / 2) * math.sin(angle_rad))
|
||||||
(shape_width / 2) * math.cos(angle_rad)
|
svg_y = center_y - ((shape_width / 2) * math.sin(angle_rad) + (shape_height / 2) * math.cos(angle_rad))
|
||||||
- (shape_height / 2) * math.sin(angle_rad)
|
|
||||||
)
|
|
||||||
svg_y = center_y - (
|
|
||||||
(shape_width / 2) * math.sin(angle_rad)
|
|
||||||
+ (shape_height / 2) * math.cos(angle_rad)
|
|
||||||
)
|
|
||||||
|
|
||||||
shape_svg = generate_rectangle_svg(
|
shape_svg = generate_rectangle_svg(int(shape_width), int(shape_height), color_scheme)
|
||||||
int(shape_width), int(shape_height), color_scheme
|
|
||||||
)
|
|
||||||
|
|
||||||
offset_distance = (shape_height / 2) * text_offset_ratio
|
offset_distance = (shape_height / 2) * text_offset_ratio
|
||||||
|
|
||||||
@ -354,33 +333,15 @@ def _get_color_scheme(area_name: str) -> dict[str, Any]:
|
|||||||
return COLOR_SCHEMES["NORMAL_AREA"]
|
return COLOR_SCHEMES["NORMAL_AREA"]
|
||||||
|
|
||||||
# 3. Logical Isolation
|
# 3. Logical Isolation
|
||||||
if (
|
if "VRF" in label or "VLAN" in label or "MSTP" in label or "VXLAN" in label or "MPLS" in label:
|
||||||
"VRF" in label
|
|
||||||
or "VLAN" in label
|
|
||||||
or "MSTP" in label
|
|
||||||
or "VXLAN" in label
|
|
||||||
or "MPLS" in label
|
|
||||||
):
|
|
||||||
return COLOR_SCHEMES["ISOLATION"]
|
return COLOR_SCHEMES["ISOLATION"]
|
||||||
|
|
||||||
# 4. High Availability
|
# 4. High Availability
|
||||||
if (
|
if "VRRP" in label or "HSRP" in label or "HA" in label or "STACK" in label or "M-LAG" in label:
|
||||||
"VRRP" in label
|
|
||||||
or "HSRP" in label
|
|
||||||
or "HA" in label
|
|
||||||
or "STACK" in label
|
|
||||||
or "M-LAG" in label
|
|
||||||
):
|
|
||||||
return COLOR_SCHEMES["HIGH_AVAILABILITY"]
|
return COLOR_SCHEMES["HIGH_AVAILABILITY"]
|
||||||
|
|
||||||
# 5. External/Internet
|
# 5. External/Internet
|
||||||
if (
|
if "INET" in label or "OUT" in label or "EXTERNAL" in label or "INTERNET" in label or "DMZ" in label:
|
||||||
"INET" in label
|
|
||||||
or "OUT" in label
|
|
||||||
or "EXTERNAL" in label
|
|
||||||
or "INTERNET" in label
|
|
||||||
or "DMZ" in label
|
|
||||||
):
|
|
||||||
return COLOR_SCHEMES["EXTERNAL"]
|
return COLOR_SCHEMES["EXTERNAL"]
|
||||||
|
|
||||||
# 6. Management
|
# 6. Management
|
||||||
@ -392,12 +353,7 @@ def _get_color_scheme(area_name: str) -> dict[str, Any]:
|
|||||||
return COLOR_SCHEMES["SECURITY_TRUSTED"]
|
return COLOR_SCHEMES["SECURITY_TRUSTED"]
|
||||||
|
|
||||||
# 8. Cloud/Tunnel
|
# 8. Cloud/Tunnel
|
||||||
if (
|
if "TUNNEL" in label or "CLOUD" in label or "GRE" in label or "IPSEC" in label:
|
||||||
"TUNNEL" in label
|
|
||||||
or "CLOUD" in label
|
|
||||||
or "GRE" in label
|
|
||||||
or "IPSEC" in label
|
|
||||||
):
|
|
||||||
return COLOR_SCHEMES["CLOUD_TUNNEL"]
|
return COLOR_SCHEMES["CLOUD_TUNNEL"]
|
||||||
|
|
||||||
# Legacy keyword matching for backward compatibility
|
# Legacy keyword matching for backward compatibility
|
||||||
@ -436,9 +392,7 @@ def calculate_two_node_ellipse(
|
|||||||
|
|
||||||
Wrapper around calculate_two_node_shape with shape_type="ellipse".
|
Wrapper around calculate_two_node_shape with shape_type="ellipse".
|
||||||
"""
|
"""
|
||||||
result = calculate_two_node_shape(
|
result = calculate_two_node_shape(node1, node2, area_name, "ellipse", text_offset_ratio)
|
||||||
node1, node2, area_name, "ellipse", text_offset_ratio
|
|
||||||
)
|
|
||||||
return {
|
return {
|
||||||
"ellipse": result["shape"],
|
"ellipse": result["shape"],
|
||||||
"text": result["text"],
|
"text": result["text"],
|
||||||
@ -457,9 +411,7 @@ def calculate_two_node_rectangle(
|
|||||||
|
|
||||||
Wrapper around calculate_two_node_shape with shape_type="rectangle".
|
Wrapper around calculate_two_node_shape with shape_type="rectangle".
|
||||||
"""
|
"""
|
||||||
result = calculate_two_node_shape(
|
result = calculate_two_node_shape(node1, node2, area_name, "rectangle", text_offset_ratio)
|
||||||
node1, node2, area_name, "rectangle", text_offset_ratio
|
|
||||||
)
|
|
||||||
metadata = result["metadata"]
|
metadata = result["metadata"]
|
||||||
return {
|
return {
|
||||||
"rectangle": result["shape"],
|
"rectangle": result["shape"],
|
||||||
|
|||||||
@ -53,9 +53,7 @@ from fastapi import FastAPI
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def get_user_llm_config_with_app(
|
async def get_user_llm_config_with_app(user_id: UUID, app: FastAPI) -> Optional[Dict[str, Any]]:
|
||||||
user_id: UUID, app: FastAPI
|
|
||||||
) -> Optional[Dict[str, Any]]:
|
|
||||||
"""
|
"""
|
||||||
Get user's default LLM model configuration with decrypted API key.
|
Get user's default LLM model configuration with decrypted API key.
|
||||||
|
|
||||||
|
|||||||
@ -160,9 +160,7 @@ def parse_tool_content(
|
|||||||
# Empty string handling
|
# Empty string handling
|
||||||
if not content.strip():
|
if not content.strip():
|
||||||
result = {}
|
result = {}
|
||||||
logger.info(
|
logger.info("Content is empty or whitespace, returning: %s", result)
|
||||||
"Content is empty or whitespace, returning: %s", result
|
|
||||||
)
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
s = content.strip()
|
s = content.strip()
|
||||||
@ -170,9 +168,7 @@ def parse_tool_content(
|
|||||||
# Handle empty dictionary case
|
# Handle empty dictionary case
|
||||||
if s == "{}":
|
if s == "{}":
|
||||||
result = {}
|
result = {}
|
||||||
logger.info(
|
logger.info("Content is empty dictionary, returning: %s", result)
|
||||||
"Content is empty dictionary, returning: %s", result
|
|
||||||
)
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# Try to parse as Python literal
|
# Try to parse as Python literal
|
||||||
@ -190,9 +186,7 @@ def parse_tool_content(
|
|||||||
# Try to parse as JSON
|
# Try to parse as JSON
|
||||||
try:
|
try:
|
||||||
result = json.loads(s)
|
result = json.loads(s)
|
||||||
logger.info(
|
logger.info("Successfully parsed as JSON, returning: %s", result)
|
||||||
"Successfully parsed as JSON, returning: %s", result
|
|
||||||
)
|
|
||||||
return result
|
return result
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
pass
|
pass
|
||||||
@ -221,8 +215,7 @@ def parse_tool_content(
|
|||||||
|
|
||||||
# Handle unsupported types
|
# Handle unsupported types
|
||||||
error_msg = ( # type: ignore[unreachable]
|
error_msg = ( # type: ignore[unreachable]
|
||||||
"Content must be str, dict, list, int, float, bool, or None, got "
|
f"Content must be str, dict, list, int, float, bool, or None, got {type(content).__name__}"
|
||||||
f"{type(content).__name__}"
|
|
||||||
)
|
)
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
|
|
||||||
@ -238,9 +231,7 @@ def parse_tool_content(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def format_tool_response(
|
def format_tool_response(content: str | dict | list | int | float | bool | None, indent: int = 2) -> str:
|
||||||
content: str | dict | list | int | float | bool | None, indent: int = 2
|
|
||||||
) -> str:
|
|
||||||
"""
|
"""
|
||||||
Format tool response as a beautiful JSON string for UI display.
|
Format tool response as a beautiful JSON string for UI display.
|
||||||
|
|
||||||
@ -259,9 +250,7 @@ def format_tool_response(
|
|||||||
logger.info("format_tool_response parameter indent: %s", indent)
|
logger.info("format_tool_response parameter indent: %s", indent)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
parsed = parse_tool_content(
|
parsed = parse_tool_content(content, fallback_to_raw=True, strict_mode=False)
|
||||||
content, fallback_to_raw=True, strict_mode=False
|
|
||||||
)
|
|
||||||
# Ensure the result can be serialized to JSON
|
# Ensure the result can be serialized to JSON
|
||||||
result = json.dumps(parsed, ensure_ascii=False, indent=indent)
|
result = json.dumps(parsed, ensure_ascii=False, indent=indent)
|
||||||
logger.info("format_tool_response returning: %s", result)
|
logger.info("format_tool_response returning: %s", result)
|
||||||
@ -270,9 +259,7 @@ def format_tool_response(
|
|||||||
# If the parsed result cannot be serialized, convert to string and wrap
|
# If the parsed result cannot be serialized, convert to string and wrap
|
||||||
logger.error("Cannot serialize parsed result to JSON: %s", e)
|
logger.error("Cannot serialize parsed result to JSON: %s", e)
|
||||||
try:
|
try:
|
||||||
result = json.dumps(
|
result = json.dumps({"raw": str(content)}, ensure_ascii=False, indent=indent)
|
||||||
{"raw": str(content)}, ensure_ascii=False, indent=indent
|
|
||||||
)
|
|
||||||
logger.info("format_tool_response returning fallback: %s", result)
|
logger.info("format_tool_response returning fallback: %s", result)
|
||||||
return result
|
return result
|
||||||
except Exception:
|
except Exception:
|
||||||
@ -286,16 +273,12 @@ def format_tool_response(
|
|||||||
return result
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error formatting tool response: %s", e)
|
logger.error("Error formatting tool response: %s", e)
|
||||||
result = json.dumps(
|
result = json.dumps({"error": str(e)}, ensure_ascii=False, indent=indent)
|
||||||
{"error": str(e)}, ensure_ascii=False, indent=indent
|
|
||||||
)
|
|
||||||
logger.info("format_tool_response returning error: %s", result)
|
logger.info("format_tool_response returning error: %s", result)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def normalize_tool_response(
|
def normalize_tool_response(response: dict | list | str, tool_name: str = "unknown") -> dict:
|
||||||
response: dict | list | str, tool_name: str = "unknown"
|
|
||||||
) -> dict:
|
|
||||||
"""
|
"""
|
||||||
Normalize tool response to standard format for consistent frontend display.
|
Normalize tool response to standard format for consistent frontend display.
|
||||||
|
|
||||||
@ -334,11 +317,7 @@ def normalize_tool_response(
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Handle error responses
|
# Handle error responses
|
||||||
if (
|
if isinstance(response, dict) and "error" in response and len(response) == 1:
|
||||||
isinstance(response, dict)
|
|
||||||
and "error" in response
|
|
||||||
and len(response) == 1
|
|
||||||
):
|
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"total": 0,
|
"total": 0,
|
||||||
@ -362,34 +341,21 @@ def normalize_tool_response(
|
|||||||
|
|
||||||
# Handle list responses (most tools return list of device results)
|
# Handle list responses (most tools return list of device results)
|
||||||
if isinstance(response, list):
|
if isinstance(response, list):
|
||||||
successful = sum(
|
successful = sum(1 for item in response if isinstance(item, dict) and item.get("status") == "success")
|
||||||
1
|
|
||||||
for item in response
|
|
||||||
if isinstance(item, dict) and item.get("status") == "success"
|
|
||||||
)
|
|
||||||
failed = len(response) - successful
|
failed = len(response) - successful
|
||||||
|
|
||||||
normalized_data = []
|
normalized_data = []
|
||||||
for item in response:
|
for item in response:
|
||||||
if isinstance(item, dict):
|
if isinstance(item, dict):
|
||||||
normalized_item = {
|
normalized_item = {
|
||||||
"id": item.get("device_id")
|
"id": item.get("device_id") or item.get("node_id") or item.get("id") or "",
|
||||||
or item.get("node_id")
|
|
||||||
or item.get("id")
|
|
||||||
or "",
|
|
||||||
"name": item.get("device_name") or item.get("name") or "",
|
"name": item.get("device_name") or item.get("name") or "",
|
||||||
"status": item.get("status", "unknown"),
|
"status": item.get("status", "unknown"),
|
||||||
}
|
}
|
||||||
if normalized_item["status"] == "success":
|
if normalized_item["status"] == "success":
|
||||||
normalized_item["result"] = (
|
normalized_item["result"] = item.get("output") or item.get("result") or ""
|
||||||
item.get("output") or item.get("result") or ""
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
normalized_item["error"] = (
|
normalized_item["error"] = item.get("error") or item.get("output") or "Unknown error"
|
||||||
item.get("error")
|
|
||||||
or item.get("output")
|
|
||||||
or "Unknown error"
|
|
||||||
)
|
|
||||||
normalized_data.append(normalized_item)
|
normalized_data.append(normalized_item)
|
||||||
else:
|
else:
|
||||||
# Non-dict items in list
|
# Non-dict items in list
|
||||||
@ -426,14 +392,8 @@ def normalize_tool_response(
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Legacy format: extract common fields
|
# Legacy format: extract common fields
|
||||||
total = (
|
total = response.get("total_nodes") or response.get("total") or response.get("count", 1)
|
||||||
response.get("total_nodes")
|
successful = response.get("successful_nodes") or response.get("successful") or 0
|
||||||
or response.get("total")
|
|
||||||
or response.get("count", 1)
|
|
||||||
)
|
|
||||||
successful = (
|
|
||||||
response.get("successful_nodes") or response.get("successful") or 0
|
|
||||||
)
|
|
||||||
failed = response.get("failed_nodes") or response.get("failed") or 0
|
failed = response.get("failed_nodes") or response.get("failed") or 0
|
||||||
|
|
||||||
# Extract data from various possible locations
|
# Extract data from various possible locations
|
||||||
@ -458,13 +418,9 @@ def normalize_tool_response(
|
|||||||
if not data and "status" in response:
|
if not data and "status" in response:
|
||||||
data = [
|
data = [
|
||||||
{
|
{
|
||||||
"name": response.get("device_name")
|
"name": response.get("device_name") or response.get("name") or "",
|
||||||
or response.get("name")
|
|
||||||
or "",
|
|
||||||
"status": response["status"],
|
"status": response["status"],
|
||||||
"result": response.get("output")
|
"result": response.get("output") or response.get("result") or "",
|
||||||
or response.get("result")
|
|
||||||
or "",
|
|
||||||
"error": response.get("error") or "",
|
"error": response.get("error") or "",
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@ -564,9 +520,7 @@ def _test_parse_tool_content() -> None:
|
|||||||
valid = "✓"
|
valid = "✓"
|
||||||
except Exception:
|
except Exception:
|
||||||
valid = "✗"
|
valid = "✗"
|
||||||
print(
|
print(f"Format Test {i + 1}: {valid} Input: {repr(input_data)} -> {result}")
|
||||||
f"Format Test {i + 1}: {valid} Input: {repr(input_data)} -> {result}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@ -98,9 +98,7 @@ class ToolCallStreamAccumulator:
|
|||||||
"id": tc_id,
|
"id": tc_id,
|
||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": self._current_tool_call[
|
"name": self._current_tool_call["name"],
|
||||||
"name"
|
|
||||||
],
|
|
||||||
"arguments": "",
|
"arguments": "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -122,9 +120,7 @@ class ToolCallStreamAccumulator:
|
|||||||
|
|
||||||
# Core: string concatenation
|
# Core: string concatenation
|
||||||
if isinstance(args_chunk, str):
|
if isinstance(args_chunk, str):
|
||||||
tool_data[
|
tool_data["args_string"] += args_chunk
|
||||||
"args_string"
|
|
||||||
] += args_chunk
|
|
||||||
|
|
||||||
# Send updated tool_call event with accumulated args
|
# Send updated tool_call event with accumulated args
|
||||||
chunks.append(
|
chunks.append(
|
||||||
@ -135,9 +131,7 @@ class ToolCallStreamAccumulator:
|
|||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": tool_data["name"],
|
"name": tool_data["name"],
|
||||||
"arguments": tool_data[
|
"arguments": tool_data["args_string"],
|
||||||
"args_string"
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@ -147,15 +141,9 @@ class ToolCallStreamAccumulator:
|
|||||||
# complete ==========
|
# complete ==========
|
||||||
# Check finish_reason == "tool_calls" or "STOP"
|
# Check finish_reason == "tool_calls" or "STOP"
|
||||||
response_metadata = getattr(chunk, "response_metadata", {})
|
response_metadata = getattr(chunk, "response_metadata", {})
|
||||||
finish_reason = (
|
finish_reason = response_metadata.get("finish_reason") if isinstance(response_metadata, dict) else None
|
||||||
response_metadata.get("finish_reason")
|
|
||||||
if isinstance(response_metadata, dict)
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
|
|
||||||
if (finish_reason == "tool_calls") or (
|
if (finish_reason == "tool_calls") or (finish_reason == "stop" and self._current_tool_call is not None):
|
||||||
finish_reason == "stop" and self._current_tool_call is not None
|
|
||||||
):
|
|
||||||
if self._current_tool_call:
|
if self._current_tool_call:
|
||||||
tool_data = self._current_tool_call
|
tool_data = self._current_tool_call
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -28,8 +28,10 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
# ── Helper ─────────────────────────────────────────────────────────────────
|
# ── Helper ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def _get_connector(gns3_ctx: dict[str, Any]):
|
def _get_connector(gns3_ctx: dict[str, Any]):
|
||||||
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
|
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
|
||||||
|
|
||||||
return Gns3Connector(
|
return Gns3Connector(
|
||||||
url=gns3_ctx["server_url"],
|
url=gns3_ctx["server_url"],
|
||||||
jwt_token=gns3_ctx["jwt_token"],
|
jwt_token=gns3_ctx["jwt_token"],
|
||||||
@ -41,12 +43,32 @@ def _get_connector(gns3_ctx: dict[str, Any]):
|
|||||||
# ── Tool handlers ──────────────────────────────────────────────────────────
|
# ── Tool handlers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
VALID_APPLIANCE_FIELDS = {
|
VALID_APPLIANCE_FIELDS = {
|
||||||
"appliance_id", "name", "category", "description", "vendor_name",
|
"appliance_id",
|
||||||
"vendor_url", "product_name", "product_url", "documentation_url",
|
"name",
|
||||||
"status", "availability", "maintainer", "usage", "symbol",
|
"category",
|
||||||
"images", "versions", "tags", "builtin",
|
"description",
|
||||||
"first_port_name", "port_name_format", "port_segment_size",
|
"vendor_name",
|
||||||
"linked_clone", "docker", "iou", "dynamips", "qemu",
|
"vendor_url",
|
||||||
|
"product_name",
|
||||||
|
"product_url",
|
||||||
|
"documentation_url",
|
||||||
|
"status",
|
||||||
|
"availability",
|
||||||
|
"maintainer",
|
||||||
|
"usage",
|
||||||
|
"symbol",
|
||||||
|
"images",
|
||||||
|
"versions",
|
||||||
|
"tags",
|
||||||
|
"builtin",
|
||||||
|
"first_port_name",
|
||||||
|
"port_name_format",
|
||||||
|
"port_segment_size",
|
||||||
|
"linked_clone",
|
||||||
|
"docker",
|
||||||
|
"iou",
|
||||||
|
"dynamips",
|
||||||
|
"qemu",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -56,7 +78,7 @@ def get_appliances_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) ->
|
|||||||
fields = params.get("fields")
|
fields = params.get("fields")
|
||||||
if fields:
|
if fields:
|
||||||
if not isinstance(fields, list):
|
if not isinstance(fields, list):
|
||||||
return {"error": "fields must be a list, e.g. [\"name\", \"category\"]"}
|
return {"error": 'fields must be a list, e.g. ["name", "category"]'}
|
||||||
invalid = [f for f in fields if f not in VALID_APPLIANCE_FIELDS]
|
invalid = [f for f in fields if f not in VALID_APPLIANCE_FIELDS]
|
||||||
if invalid:
|
if invalid:
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -27,6 +27,7 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
def _get_connector(gns3_ctx: dict[str, Any]):
|
def _get_connector(gns3_ctx: dict[str, Any]):
|
||||||
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
|
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
|
||||||
|
|
||||||
return Gns3Connector(
|
return Gns3Connector(
|
||||||
url=gns3_ctx["server_url"],
|
url=gns3_ctx["server_url"],
|
||||||
jwt_token=gns3_ctx["jwt_token"],
|
jwt_token=gns3_ctx["jwt_token"],
|
||||||
|
|||||||
@ -81,6 +81,7 @@ def _render_template(template: str, device_configs: list[dict], commands_field:
|
|||||||
|
|
||||||
# ── Tool handlers ──────────────────────────────────────────────────────────
|
# ── Tool handlers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def device_config_send_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> list[dict[str, Any]]:
|
def device_config_send_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
"""Send configuration commands to network devices via console."""
|
"""Send configuration commands to network devices via console."""
|
||||||
project_id = params.get("project_id")
|
project_id = params.get("project_id")
|
||||||
@ -97,10 +98,12 @@ def device_config_send_handler(params: dict[str, Any], gns3_ctx: dict[str, Any])
|
|||||||
from gns3server.agent.gns3_copilot.tools_v2.config_tools_nornir import ExecuteMultipleDeviceConfigCommands
|
from gns3server.agent.gns3_copilot.tools_v2.config_tools_nornir import ExecuteMultipleDeviceConfigCommands
|
||||||
|
|
||||||
tool = ExecuteMultipleDeviceConfigCommands()
|
tool = ExecuteMultipleDeviceConfigCommands()
|
||||||
input_data = json.dumps({
|
input_data = json.dumps(
|
||||||
"project_id": project_id,
|
{
|
||||||
"device_configs": device_configs,
|
"project_id": project_id,
|
||||||
})
|
"device_configs": device_configs,
|
||||||
|
}
|
||||||
|
)
|
||||||
return tool._run(
|
return tool._run(
|
||||||
input_data,
|
input_data,
|
||||||
jwt_token=gns3_ctx["jwt_token"],
|
jwt_token=gns3_ctx["jwt_token"],
|
||||||
@ -114,7 +117,12 @@ def device_show_run_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) ->
|
|||||||
device_configs = params.get("device_configs")
|
device_configs = params.get("device_configs")
|
||||||
template = params.get("template")
|
template = params.get("template")
|
||||||
if not project_id or not device_configs:
|
if not project_id or not device_configs:
|
||||||
return [{"status": "failed", "error": "project_id and device_configs (list of {device_name, commands}) are required"}]
|
return [
|
||||||
|
{
|
||||||
|
"status": "failed",
|
||||||
|
"error": "project_id and device_configs (list of {device_name, commands}) are required",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
if template:
|
if template:
|
||||||
device_configs = _render_template(template, device_configs, commands_field="commands")
|
device_configs = _render_template(template, device_configs, commands_field="commands")
|
||||||
@ -124,10 +132,12 @@ def device_show_run_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) ->
|
|||||||
from gns3server.agent.gns3_copilot.tools_v2.display_tools_nornir import ExecuteMultipleDeviceCommands
|
from gns3server.agent.gns3_copilot.tools_v2.display_tools_nornir import ExecuteMultipleDeviceCommands
|
||||||
|
|
||||||
tool = ExecuteMultipleDeviceCommands()
|
tool = ExecuteMultipleDeviceCommands()
|
||||||
input_data = json.dumps({
|
input_data = json.dumps(
|
||||||
"project_id": project_id,
|
{
|
||||||
"device_configs": device_configs,
|
"project_id": project_id,
|
||||||
})
|
"device_configs": device_configs,
|
||||||
|
}
|
||||||
|
)
|
||||||
return tool._run(
|
return tool._run(
|
||||||
input_data,
|
input_data,
|
||||||
jwt_token=gns3_ctx["jwt_token"],
|
jwt_token=gns3_ctx["jwt_token"],
|
||||||
@ -145,10 +155,12 @@ def vpcs_config_set_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) ->
|
|||||||
from gns3server.agent.gns3_copilot.tools_v2.vpcs_tools_netmiko import VPCSCommands
|
from gns3server.agent.gns3_copilot.tools_v2.vpcs_tools_netmiko import VPCSCommands
|
||||||
|
|
||||||
tool = VPCSCommands()
|
tool = VPCSCommands()
|
||||||
input_data = json.dumps({
|
input_data = json.dumps(
|
||||||
"project_id": project_id,
|
{
|
||||||
"device_configs": device_configs,
|
"project_id": project_id,
|
||||||
})
|
"device_configs": device_configs,
|
||||||
|
}
|
||||||
|
)
|
||||||
return tool._run(
|
return tool._run(
|
||||||
input_data,
|
input_data,
|
||||||
jwt_token=gns3_ctx["jwt_token"],
|
jwt_token=gns3_ctx["jwt_token"],
|
||||||
|
|||||||
@ -28,8 +28,10 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
# ── Helper ─────────────────────────────────────────────────────────────────
|
# ── Helper ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def _get_connector(gns3_ctx: dict[str, Any]):
|
def _get_connector(gns3_ctx: dict[str, Any]):
|
||||||
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
|
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
|
||||||
|
|
||||||
return Gns3Connector(
|
return Gns3Connector(
|
||||||
url=gns3_ctx["server_url"],
|
url=gns3_ctx["server_url"],
|
||||||
jwt_token=gns3_ctx["jwt_token"],
|
jwt_token=gns3_ctx["jwt_token"],
|
||||||
@ -40,6 +42,7 @@ def _get_connector(gns3_ctx: dict[str, Any]):
|
|||||||
|
|
||||||
# ── Tool handlers ──────────────────────────────────────────────────────────
|
# ── Tool handlers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def get_drawings_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
def get_drawings_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||||
project_id = params.get("project_id")
|
project_id = params.get("project_id")
|
||||||
if not project_id:
|
if not project_id:
|
||||||
|
|||||||
@ -28,8 +28,10 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
# ── Helper ─────────────────────────────────────────────────────────────────
|
# ── Helper ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def _get_connector(gns3_ctx: dict[str, Any]):
|
def _get_connector(gns3_ctx: dict[str, Any]):
|
||||||
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
|
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
|
||||||
|
|
||||||
return Gns3Connector(
|
return Gns3Connector(
|
||||||
url=gns3_ctx["server_url"],
|
url=gns3_ctx["server_url"],
|
||||||
jwt_token=gns3_ctx["jwt_token"],
|
jwt_token=gns3_ctx["jwt_token"],
|
||||||
@ -40,6 +42,7 @@ def _get_connector(gns3_ctx: dict[str, Any]):
|
|||||||
|
|
||||||
# ── Tool handlers ──────────────────────────────────────────────────────────
|
# ── Tool handlers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def get_images_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
def get_images_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||||
conn = _get_connector(gns3_ctx)
|
conn = _get_connector(gns3_ctx)
|
||||||
images = conn.http_call("get", f"{conn.base_url}/images").json()
|
images = conn.http_call("get", f"{conn.base_url}/images").json()
|
||||||
|
|||||||
@ -31,9 +31,11 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
# ── Helper ─────────────────────────────────────────────────────────────────
|
# ── Helper ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def _get_connector(gns3_ctx: dict[str, Any]):
|
def _get_connector(gns3_ctx: dict[str, Any]):
|
||||||
"""Create a Gns3Connector from the GNS3 context dict."""
|
"""Create a Gns3Connector from the GNS3 context dict."""
|
||||||
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
|
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
|
||||||
|
|
||||||
return Gns3Connector(
|
return Gns3Connector(
|
||||||
url=gns3_ctx["server_url"],
|
url=gns3_ctx["server_url"],
|
||||||
jwt_token=gns3_ctx["jwt_token"],
|
jwt_token=gns3_ctx["jwt_token"],
|
||||||
@ -44,6 +46,7 @@ def _get_connector(gns3_ctx: dict[str, Any]):
|
|||||||
|
|
||||||
# ── Tool handlers ──────────────────────────────────────────────────────────
|
# ── Tool handlers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def list_projects_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
def list_projects_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||||
conn = _get_connector(gns3_ctx)
|
conn = _get_connector(gns3_ctx)
|
||||||
projects = conn.http_call("get", f"{conn.base_url}/projects").json()
|
projects = conn.http_call("get", f"{conn.base_url}/projects").json()
|
||||||
@ -141,7 +144,12 @@ def get_project_readme_handler(params: dict[str, Any], gns3_ctx: dict[str, Any])
|
|||||||
return {"project_id": project_id, "file": "README.txt", "content": content}
|
return {"project_id": project_id, "file": "README.txt", "content": content}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if "404" in str(e):
|
if "404" in str(e):
|
||||||
return {"project_id": project_id, "file": "README.txt", "content": None, "message": "README.txt does not exist yet"}
|
return {
|
||||||
|
"project_id": project_id,
|
||||||
|
"file": "README.txt",
|
||||||
|
"content": None,
|
||||||
|
"message": "README.txt does not exist yet",
|
||||||
|
}
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
@ -286,7 +294,10 @@ PROJECT_TOOLS = [
|
|||||||
"show_grid": {"type": "boolean", "description": "Show the grid on the drawing area"},
|
"show_grid": {"type": "boolean", "description": "Show the grid on the drawing area"},
|
||||||
"grid_size": {"type": "integer", "description": "Grid size for the drawing area for nodes"},
|
"grid_size": {"type": "integer", "description": "Grid size for the drawing area for nodes"},
|
||||||
"drawing_grid_size": {"type": "integer", "description": "Grid size for the drawing area for drawings"},
|
"drawing_grid_size": {"type": "integer", "description": "Grid size for the drawing area for drawings"},
|
||||||
"show_interface_labels": {"type": "boolean", "description": "Show interface labels on the drawing area"},
|
"show_interface_labels": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Show interface labels on the drawing area",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"required": ["project_id"],
|
"required": ["project_id"],
|
||||||
},
|
},
|
||||||
|
|||||||
@ -28,8 +28,10 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
# ── Helper ─────────────────────────────────────────────────────────────────
|
# ── Helper ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def _get_connector(gns3_ctx: dict[str, Any]):
|
def _get_connector(gns3_ctx: dict[str, Any]):
|
||||||
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
|
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
|
||||||
|
|
||||||
return Gns3Connector(
|
return Gns3Connector(
|
||||||
url=gns3_ctx["server_url"],
|
url=gns3_ctx["server_url"],
|
||||||
jwt_token=gns3_ctx["jwt_token"],
|
jwt_token=gns3_ctx["jwt_token"],
|
||||||
@ -40,6 +42,7 @@ def _get_connector(gns3_ctx: dict[str, Any]):
|
|||||||
|
|
||||||
# ── Tool handlers ──────────────────────────────────────────────────────────
|
# ── Tool handlers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def get_version_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
def get_version_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||||
conn = _get_connector(gns3_ctx)
|
conn = _get_connector(gns3_ctx)
|
||||||
return conn.http_call("get", f"{conn.base_url}/version").json()
|
return conn.http_call("get", f"{conn.base_url}/version").json()
|
||||||
|
|||||||
@ -28,8 +28,10 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
# ── Helper ─────────────────────────────────────────────────────────────────
|
# ── Helper ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def _get_connector(gns3_ctx: dict[str, Any]):
|
def _get_connector(gns3_ctx: dict[str, Any]):
|
||||||
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
|
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
|
||||||
|
|
||||||
return Gns3Connector(
|
return Gns3Connector(
|
||||||
url=gns3_ctx["server_url"],
|
url=gns3_ctx["server_url"],
|
||||||
jwt_token=gns3_ctx["jwt_token"],
|
jwt_token=gns3_ctx["jwt_token"],
|
||||||
@ -40,6 +42,7 @@ def _get_connector(gns3_ctx: dict[str, Any]):
|
|||||||
|
|
||||||
# ── Tool handlers ──────────────────────────────────────────────────────────
|
# ── Tool handlers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def get_snapshots_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
def get_snapshots_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||||
project_id = params.get("project_id")
|
project_id = params.get("project_id")
|
||||||
if not project_id:
|
if not project_id:
|
||||||
|
|||||||
@ -30,8 +30,10 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
# ── Helper ─────────────────────────────────────────────────────────────────
|
# ── Helper ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def _get_connector(gns3_ctx: dict[str, Any]):
|
def _get_connector(gns3_ctx: dict[str, Any]):
|
||||||
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
|
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
|
||||||
|
|
||||||
return Gns3Connector(
|
return Gns3Connector(
|
||||||
url=gns3_ctx["server_url"],
|
url=gns3_ctx["server_url"],
|
||||||
jwt_token=gns3_ctx["jwt_token"],
|
jwt_token=gns3_ctx["jwt_token"],
|
||||||
@ -42,6 +44,7 @@ def _get_connector(gns3_ctx: dict[str, Any]):
|
|||||||
|
|
||||||
# ── Tool handlers ──────────────────────────────────────────────────────────
|
# ── Tool handlers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def get_symbols_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
def get_symbols_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||||
conn = _get_connector(gns3_ctx)
|
conn = _get_connector(gns3_ctx)
|
||||||
symbols = conn.http_call("get", f"{conn.base_url}/symbols").json()
|
symbols = conn.http_call("get", f"{conn.base_url}/symbols").json()
|
||||||
@ -57,9 +60,11 @@ def get_symbol_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict
|
|||||||
username = gns3_ctx.get("jwt_username")
|
username = gns3_ctx.get("jwt_username")
|
||||||
# short-lived ticket bound to this exact path — LLM clients retyping curl
|
# short-lived ticket bound to this exact path — LLM clients retyping curl
|
||||||
# commands corrupted the long Bearer JWT this used to embed
|
# commands corrupted the long Bearer JWT this used to embed
|
||||||
ticket = access_ticket_service.mint(
|
ticket = (
|
||||||
username, token_version=gns3_ctx.get("jwt_token_version", 0), path=path
|
access_ticket_service.mint(username, token_version=gns3_ctx.get("jwt_token_version", 0), path=path)
|
||||||
) if username else None
|
if username
|
||||||
|
else None
|
||||||
|
)
|
||||||
if ticket:
|
if ticket:
|
||||||
download_url += f"?token={ticket}"
|
download_url += f"?token={ticket}"
|
||||||
result = {
|
result = {
|
||||||
@ -68,7 +73,7 @@ def get_symbol_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict
|
|||||||
"note": "Symbol files are SVG images.",
|
"note": "Symbol files are SVG images.",
|
||||||
}
|
}
|
||||||
if ticket:
|
if ticket:
|
||||||
safe_name = symbol_id.replace(':', '').replace('/', '_')
|
safe_name = symbol_id.replace(":", "").replace("/", "_")
|
||||||
result["curl_command"] = f"curl -L -o '{safe_name}.svg' '{download_url}'"
|
result["curl_command"] = f"curl -L -o '{safe_name}.svg' '{download_url}'"
|
||||||
result["note"] += " The download URL includes a 10-minute ticket."
|
result["note"] += " The download URL includes a 10-minute ticket."
|
||||||
return result
|
return result
|
||||||
|
|||||||
@ -31,8 +31,10 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
# ── Helper ─────────────────────────────────────────────────────────────────
|
# ── Helper ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def _get_connector(gns3_ctx: dict[str, Any]):
|
def _get_connector(gns3_ctx: dict[str, Any]):
|
||||||
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
|
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
|
||||||
|
|
||||||
return Gns3Connector(
|
return Gns3Connector(
|
||||||
url=gns3_ctx["server_url"],
|
url=gns3_ctx["server_url"],
|
||||||
jwt_token=gns3_ctx["jwt_token"],
|
jwt_token=gns3_ctx["jwt_token"],
|
||||||
@ -44,9 +46,19 @@ def _get_connector(gns3_ctx: dict[str, Any]):
|
|||||||
# ── Tool handlers ──────────────────────────────────────────────────────────
|
# ── Tool handlers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
VALID_TEMPLATE_FIELDS = {
|
VALID_TEMPLATE_FIELDS = {
|
||||||
"template_id", "name", "version", "category", "default_name_format",
|
"template_id",
|
||||||
"symbol", "template_type", "compute_id", "usage", "tags", "builtin",
|
"name",
|
||||||
"created_at", "updated_at",
|
"version",
|
||||||
|
"category",
|
||||||
|
"default_name_format",
|
||||||
|
"symbol",
|
||||||
|
"template_type",
|
||||||
|
"compute_id",
|
||||||
|
"usage",
|
||||||
|
"tags",
|
||||||
|
"builtin",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
}
|
}
|
||||||
|
|
||||||
TEMPLATE_DEFAULT_FIELDS = ["template_id", "name", "template_type", "category", "default_name_format"]
|
TEMPLATE_DEFAULT_FIELDS = ["template_id", "name", "template_type", "category", "default_name_format"]
|
||||||
@ -67,7 +79,7 @@ def list_templates_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) ->
|
|||||||
fields = params.get("fields")
|
fields = params.get("fields")
|
||||||
if fields:
|
if fields:
|
||||||
if not isinstance(fields, list):
|
if not isinstance(fields, list):
|
||||||
return {"error": "fields must be a list, e.g. [\"template_id\", \"name\"]"}
|
return {"error": 'fields must be a list, e.g. ["template_id", "name"]'}
|
||||||
invalid = [f for f in fields if f not in VALID_TEMPLATE_FIELDS]
|
invalid = [f for f in fields if f not in VALID_TEMPLATE_FIELDS]
|
||||||
if invalid:
|
if invalid:
|
||||||
return {
|
return {
|
||||||
|
|||||||
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
|
self._connected = True
|
||||||
|
|
||||||
# Parse API version
|
# Parse API version
|
||||||
api_version = parse_version(docker_info['ApiVersion'])
|
api_version = parse_version(docker_info["ApiVersion"])
|
||||||
docker_version = docker_info["Version"]
|
docker_version = docker_info["Version"]
|
||||||
|
|
||||||
logger.info(f"Connected to Docker {docker_version}, API {api_version}")
|
logger.info(f"Connected to Docker {docker_version}, API {api_version}")
|
||||||
@ -102,7 +102,7 @@ class DockerHTTPClient:
|
|||||||
logger.info(f"Using Docker API version {self._api_version}")
|
logger.info(f"Using Docker API version {self._api_version}")
|
||||||
else:
|
else:
|
||||||
# Use Docker daemon's actual API version
|
# Use Docker daemon's actual API version
|
||||||
self._api_version = docker_info['ApiVersion']
|
self._api_version = docker_info["ApiVersion"]
|
||||||
logger.info(f"Using Docker API version {self._api_version} (daemon native)")
|
logger.info(f"Using Docker API version {self._api_version} (daemon native)")
|
||||||
|
|
||||||
except (aiohttp.ClientError, FileNotFoundError) as e:
|
except (aiohttp.ClientError, FileNotFoundError) as e:
|
||||||
@ -124,7 +124,7 @@ class DockerHTTPClient:
|
|||||||
Response JSON data
|
Response JSON data
|
||||||
"""
|
"""
|
||||||
# Check connection and version on first request
|
# Check connection and version on first request
|
||||||
check_connection = kwargs.pop('check_connection', True)
|
check_connection = kwargs.pop("check_connection", True)
|
||||||
if check_connection and not self._connected:
|
if check_connection and not self._connected:
|
||||||
await self._check_connection()
|
await self._check_connection()
|
||||||
|
|
||||||
@ -147,22 +147,21 @@ class DockerHTTPClient:
|
|||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
# Retry with lower API version if Docker daemon doesn't support current version
|
# Retry with lower API version if Docker daemon doesn't support current version
|
||||||
error_msg = str(e)
|
error_msg = str(e)
|
||||||
if ("400" in error_msg or "not found" in error_msg.lower()) and self._api_version == DOCKER_PREFERRED_API_VERSION:
|
if (
|
||||||
logger.warning(f"Docker daemon doesn't support API version {self._api_version}, falling back to {DOCKER_MINIMUM_API_VERSION}")
|
"400" in error_msg or "not found" in error_msg.lower()
|
||||||
|
) and self._api_version == DOCKER_PREFERRED_API_VERSION:
|
||||||
|
logger.warning(
|
||||||
|
f"Docker daemon doesn't support API version {self._api_version}, falling back to {DOCKER_MINIMUM_API_VERSION}"
|
||||||
|
)
|
||||||
self._api_version = DOCKER_MINIMUM_API_VERSION
|
self._api_version = DOCKER_MINIMUM_API_VERSION
|
||||||
return await self._request(method, endpoint, check_connection=False, **kwargs)
|
return await self._request(method, endpoint, check_connection=False, **kwargs)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
async def create_network(self, name: str, driver: str = "bridge", subnet: str = None):
|
async def create_network(self, name: str, driver: str = "bridge", subnet: str = None):
|
||||||
"""Create Docker network."""
|
"""Create Docker network."""
|
||||||
data = {
|
data = {"Name": name, "Driver": driver}
|
||||||
"Name": name,
|
|
||||||
"Driver": driver
|
|
||||||
}
|
|
||||||
if subnet:
|
if subnet:
|
||||||
data["IPAM"] = {
|
data["IPAM"] = {"Config": [{"Subnet": subnet}]}
|
||||||
"Config": [{"Subnet": subnet}]
|
|
||||||
}
|
|
||||||
await self._request("POST", "networks/create", json=data)
|
await self._request("POST", "networks/create", json=data)
|
||||||
|
|
||||||
async def get_network(self, name: str):
|
async def get_network(self, name: str):
|
||||||
@ -176,19 +175,12 @@ class DockerHTTPClient:
|
|||||||
|
|
||||||
async def create_container(self, name: str, image: str, **kwargs):
|
async def create_container(self, name: str, image: str, **kwargs):
|
||||||
"""Create container."""
|
"""Create container."""
|
||||||
data = {
|
data = {"Image": image, "name": name, "HostConfig": {}, "NetworkingConfig": {}}
|
||||||
"Image": image,
|
|
||||||
"name": name,
|
|
||||||
"HostConfig": {},
|
|
||||||
"NetworkingConfig": {}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Handle network config
|
# Handle network config
|
||||||
if "network" in kwargs:
|
if "network" in kwargs:
|
||||||
network_name = kwargs.pop("network")
|
network_name = kwargs.pop("network")
|
||||||
data["NetworkingConfig"]["EndpointsConfig"] = {
|
data["NetworkingConfig"]["EndpointsConfig"] = {network_name: {}}
|
||||||
network_name: {}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Handle environment variables
|
# Handle environment variables
|
||||||
if "environment" in kwargs:
|
if "environment" in kwargs:
|
||||||
|
|||||||
@ -92,10 +92,7 @@ logger = logging.getLogger(__name__)
|
|||||||
def setup_logging(verbose: bool = False):
|
def setup_logging(verbose: bool = False):
|
||||||
"""Setup logging configuration."""
|
"""Setup logging configuration."""
|
||||||
level = logging.DEBUG if verbose else logging.INFO
|
level = logging.DEBUG if verbose else logging.INFO
|
||||||
logging.basicConfig(
|
logging.basicConfig(level=level, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
||||||
level=level,
|
|
||||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def cmd_start(args) -> int:
|
async def cmd_start(args) -> int:
|
||||||
@ -119,7 +116,7 @@ async def cmd_start(args) -> int:
|
|||||||
image=args.image,
|
image=args.image,
|
||||||
memory=args.memory,
|
memory=args.memory,
|
||||||
cpus=args.cpus,
|
cpus=args.cpus,
|
||||||
pids_limit=args.pids_limit
|
pids_limit=args.pids_limit,
|
||||||
)
|
)
|
||||||
|
|
||||||
print(json.dumps(result, indent=2))
|
print(json.dumps(result, indent=2))
|
||||||
@ -143,10 +140,7 @@ async def cmd_stop(args) -> int:
|
|||||||
"""
|
"""
|
||||||
manager = WebWiresharkManager()
|
manager = WebWiresharkManager()
|
||||||
try:
|
try:
|
||||||
await manager.stop_wireshark_session(
|
await manager.stop_wireshark_session(project_id=args.project_id, link_id=args.link_id)
|
||||||
project_id=args.project_id,
|
|
||||||
link_id=args.link_id
|
|
||||||
)
|
|
||||||
print(json.dumps({"status": "stopped"}))
|
print(json.dumps({"status": "stopped"}))
|
||||||
return 0
|
return 0
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -172,7 +166,7 @@ async def cmd_restart(args) -> int:
|
|||||||
project_id=args.project_id,
|
project_id=args.project_id,
|
||||||
link_id=args.link_id,
|
link_id=args.link_id,
|
||||||
jwt_token=args.jwt_token,
|
jwt_token=args.jwt_token,
|
||||||
capture_stream_url=args.capture_url
|
capture_stream_url=args.capture_url,
|
||||||
)
|
)
|
||||||
print(json.dumps(result, indent=2))
|
print(json.dumps(result, indent=2))
|
||||||
return 0
|
return 0
|
||||||
@ -274,14 +268,8 @@ async def cmd_delete(args) -> int:
|
|||||||
|
|
||||||
def create_parser() -> argparse.ArgumentParser:
|
def create_parser() -> argparse.ArgumentParser:
|
||||||
"""Create command line argument parser."""
|
"""Create command line argument parser."""
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(description="Web Wireshark container management")
|
||||||
description="Web Wireshark container management"
|
parser.add_argument("--verbose", "-v", action="store_true", help="Enable verbose logging")
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--verbose", "-v",
|
|
||||||
action="store_true",
|
|
||||||
help="Enable verbose logging"
|
|
||||||
)
|
|
||||||
|
|
||||||
subparsers = parser.add_subparsers(dest="command", help="Commands")
|
subparsers = parser.add_subparsers(dest="command", help="Commands")
|
||||||
|
|
||||||
@ -290,32 +278,13 @@ def create_parser() -> argparse.ArgumentParser:
|
|||||||
start_parser.add_argument("--project-id", required=True, type=validate_uuid, help="Project ID")
|
start_parser.add_argument("--project-id", required=True, type=validate_uuid, help="Project ID")
|
||||||
start_parser.add_argument("--link-id", required=True, type=validate_uuid, help="Link ID")
|
start_parser.add_argument("--link-id", required=True, type=validate_uuid, help="Link ID")
|
||||||
start_parser.add_argument("--jwt-token", required=True, help="JWT token")
|
start_parser.add_argument("--jwt-token", required=True, help="JWT token")
|
||||||
|
start_parser.add_argument("--capture-url", help="Capture stream URL (auto-detected if not provided)")
|
||||||
start_parser.add_argument(
|
start_parser.add_argument(
|
||||||
"--capture-url",
|
"--image", default="gns3/web-wireshark:latest", help="Docker image (default: gns3/web-wireshark:latest)"
|
||||||
help="Capture stream URL (auto-detected if not provided)"
|
|
||||||
)
|
|
||||||
start_parser.add_argument(
|
|
||||||
"--image",
|
|
||||||
default="gns3/web-wireshark:latest",
|
|
||||||
help="Docker image (default: gns3/web-wireshark:latest)"
|
|
||||||
)
|
|
||||||
start_parser.add_argument(
|
|
||||||
"--memory",
|
|
||||||
default="2g",
|
|
||||||
help="Memory limit (default: 2g)"
|
|
||||||
)
|
|
||||||
start_parser.add_argument(
|
|
||||||
"--cpus",
|
|
||||||
type=float,
|
|
||||||
default=1.0,
|
|
||||||
help="CPU cores (default: 1.0)"
|
|
||||||
)
|
|
||||||
start_parser.add_argument(
|
|
||||||
"--pids-limit",
|
|
||||||
type=int,
|
|
||||||
default=1000,
|
|
||||||
help="Process limit (default: 1000)"
|
|
||||||
)
|
)
|
||||||
|
start_parser.add_argument("--memory", default="2g", help="Memory limit (default: 2g)")
|
||||||
|
start_parser.add_argument("--cpus", type=float, default=1.0, help="CPU cores (default: 1.0)")
|
||||||
|
start_parser.add_argument("--pids-limit", type=int, default=1000, help="Process limit (default: 1000)")
|
||||||
|
|
||||||
# Stop command
|
# Stop command
|
||||||
stop_parser = subparsers.add_parser("stop", help="Stop Web Wireshark session")
|
stop_parser = subparsers.add_parser("stop", help="Stop Web Wireshark session")
|
||||||
@ -327,10 +296,7 @@ def create_parser() -> argparse.ArgumentParser:
|
|||||||
restart_parser.add_argument("--project-id", required=True, type=validate_uuid, help="Project ID")
|
restart_parser.add_argument("--project-id", required=True, type=validate_uuid, help="Project ID")
|
||||||
restart_parser.add_argument("--link-id", required=True, type=validate_uuid, help="Link ID")
|
restart_parser.add_argument("--link-id", required=True, type=validate_uuid, help="Link ID")
|
||||||
restart_parser.add_argument("--jwt-token", required=True, help="JWT token")
|
restart_parser.add_argument("--jwt-token", required=True, help="JWT token")
|
||||||
restart_parser.add_argument(
|
restart_parser.add_argument("--capture-url", help="Capture stream URL (auto-detected if not provided)")
|
||||||
"--capture-url",
|
|
||||||
help="Capture stream URL (auto-detected if not provided)"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Stop all command
|
# Stop all command
|
||||||
stop_all_parser = subparsers.add_parser("stop-all", help="Stop all sessions")
|
stop_all_parser = subparsers.add_parser("stop-all", help="Stop all sessions")
|
||||||
|
|||||||
@ -63,15 +63,16 @@ class WebWiresharkManager:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
"docker", "exec", container_id,
|
"docker",
|
||||||
"bash", "-c", "echo 'ping'",
|
"exec",
|
||||||
|
container_id,
|
||||||
|
"bash",
|
||||||
|
"-c",
|
||||||
|
"echo 'ping'",
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.PIPE
|
stderr=asyncio.subprocess.PIPE,
|
||||||
)
|
|
||||||
stdout, _ = await asyncio.wait_for(
|
|
||||||
proc.communicate(),
|
|
||||||
timeout=self.CONTAINER_EXEC_TIMEOUT
|
|
||||||
)
|
)
|
||||||
|
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=self.CONTAINER_EXEC_TIMEOUT)
|
||||||
return proc.returncode == 0 and b"ping" in stdout
|
return proc.returncode == 0 and b"ping" in stdout
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
logger.warning(f"Container {container_id[:12]} health check timeout")
|
logger.warning(f"Container {container_id[:12]} health check timeout")
|
||||||
@ -99,16 +100,17 @@ class WebWiresharkManager:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
"docker", "exec", container_id,
|
"docker",
|
||||||
"bash", "-c", command,
|
"exec",
|
||||||
|
container_id,
|
||||||
|
"bash",
|
||||||
|
"-c",
|
||||||
|
command,
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.PIPE
|
stderr=asyncio.subprocess.PIPE,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
stdout, stderr = await asyncio.wait_for(
|
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
||||||
proc.communicate(),
|
|
||||||
timeout=timeout
|
|
||||||
)
|
|
||||||
return (proc.returncode, stdout.decode().strip(), stderr.decode().strip())
|
return (proc.returncode, stdout.decode().strip(), stderr.decode().strip())
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
logger.error(f"Command timeout after {timeout}s: {command}")
|
logger.error(f"Command timeout after {timeout}s: {command}")
|
||||||
@ -142,7 +144,7 @@ class WebWiresharkManager:
|
|||||||
logger.debug(f"No processes found matching pattern '{pattern}'")
|
logger.debug(f"No processes found matching pattern '{pattern}'")
|
||||||
return
|
return
|
||||||
|
|
||||||
pids = pids_str.replace('\n', ' ')
|
pids = pids_str.replace("\n", " ")
|
||||||
logger.info(f"Found matching processes for '{pattern}': PIDs={pids}")
|
logger.info(f"Found matching processes for '{pattern}': PIDs={pids}")
|
||||||
|
|
||||||
# Kill the processes using their container-local PIDs
|
# Kill the processes using their container-local PIDs
|
||||||
@ -170,10 +172,13 @@ class WebWiresharkManager:
|
|||||||
try:
|
try:
|
||||||
# Get container init PID from host perspective
|
# Get container init PID from host perspective
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
"docker", "inspect", container_id,
|
"docker",
|
||||||
"--format", "{{.State.Pid}}",
|
"inspect",
|
||||||
|
container_id,
|
||||||
|
"--format",
|
||||||
|
"{{.State.Pid}}",
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.PIPE
|
stderr=asyncio.subprocess.PIPE,
|
||||||
)
|
)
|
||||||
stdout, _ = await proc.communicate()
|
stdout, _ = await proc.communicate()
|
||||||
container_init_pid = stdout.decode().strip()
|
container_init_pid = stdout.decode().strip()
|
||||||
@ -187,15 +192,13 @@ class WebWiresharkManager:
|
|||||||
|
|
||||||
# Check for residual processes from host perspective
|
# Check for residual processes from host perspective
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
"ps", "-eo", "pid,ppid,args",
|
"ps", "-eo", "pid,ppid,args", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
||||||
stdout=asyncio.subprocess.PIPE,
|
|
||||||
stderr=asyncio.subprocess.PIPE
|
|
||||||
)
|
)
|
||||||
stdout, _ = await proc.communicate()
|
stdout, _ = await proc.communicate()
|
||||||
|
|
||||||
# Build parent->children mapping
|
# Build parent->children mapping
|
||||||
children_map = {}
|
children_map = {}
|
||||||
for line in stdout.decode().strip().split('\n'):
|
for line in stdout.decode().strip().split("\n"):
|
||||||
parts = line.split(None, 2)
|
parts = line.split(None, 2)
|
||||||
if len(parts) < 3:
|
if len(parts) < 3:
|
||||||
continue
|
continue
|
||||||
@ -218,14 +221,15 @@ class WebWiresharkManager:
|
|||||||
|
|
||||||
# Check if any descendant processes match display patterns
|
# Check if any descendant processes match display patterns
|
||||||
import re
|
import re
|
||||||
|
|
||||||
patterns = [
|
patterns = [
|
||||||
f'xpra.*:{display}',
|
f"xpra.*:{display}",
|
||||||
f'Xvfb.*:{display}',
|
f"Xvfb.*:{display}",
|
||||||
f'wireshark.*:{display}',
|
f"wireshark.*:{display}",
|
||||||
f'pulseaudio.*display=:{display}'
|
f"pulseaudio.*display=:{display}",
|
||||||
]
|
]
|
||||||
|
|
||||||
for line in stdout.decode().strip().split('\n'):
|
for line in stdout.decode().strip().split("\n"):
|
||||||
parts = line.split(None, 2)
|
parts = line.split(None, 2)
|
||||||
if len(parts) < 3:
|
if len(parts) < 3:
|
||||||
continue
|
continue
|
||||||
@ -248,13 +252,11 @@ class WebWiresharkManager:
|
|||||||
# /proc/<pid>/root points to container filesystem
|
# /proc/<pid>/root points to container filesystem
|
||||||
lock_paths = [
|
lock_paths = [
|
||||||
f"/proc/{container_init_pid}/root/tmp/.X{display}-lock",
|
f"/proc/{container_init_pid}/root/tmp/.X{display}-lock",
|
||||||
f"/proc/{container_init_pid}/root/tmp/.X11-unix/X{display}"
|
f"/proc/{container_init_pid}/root/tmp/.X11-unix/X{display}",
|
||||||
]
|
]
|
||||||
for lock_path in lock_paths:
|
for lock_path in lock_paths:
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
"test", "-e", lock_path,
|
"test", "-e", lock_path, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
||||||
stdout=asyncio.subprocess.PIPE,
|
|
||||||
stderr=asyncio.subprocess.PIPE
|
|
||||||
)
|
)
|
||||||
await proc.communicate()
|
await proc.communicate()
|
||||||
if proc.returncode == 0:
|
if proc.returncode == 0:
|
||||||
@ -264,9 +266,7 @@ class WebWiresharkManager:
|
|||||||
# Check for xpra socket files
|
# Check for xpra socket files
|
||||||
socket_path = f"/proc/{container_init_pid}/root/run/user/1000/xpra/{display}/socket"
|
socket_path = f"/proc/{container_init_pid}/root/run/user/1000/xpra/{display}/socket"
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
"test", "-e", socket_path,
|
"test", "-e", socket_path, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
||||||
stdout=asyncio.subprocess.PIPE,
|
|
||||||
stderr=asyncio.subprocess.PIPE
|
|
||||||
)
|
)
|
||||||
await proc.communicate()
|
await proc.communicate()
|
||||||
if proc.returncode == 0:
|
if proc.returncode == 0:
|
||||||
@ -298,10 +298,13 @@ class WebWiresharkManager:
|
|||||||
try:
|
try:
|
||||||
# Get container init PID from host perspective
|
# Get container init PID from host perspective
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
"docker", "inspect", container_id,
|
"docker",
|
||||||
"--format", "{{.State.Pid}}",
|
"inspect",
|
||||||
|
container_id,
|
||||||
|
"--format",
|
||||||
|
"{{.State.Pid}}",
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.PIPE
|
stderr=asyncio.subprocess.PIPE,
|
||||||
)
|
)
|
||||||
stdout, _ = await proc.communicate()
|
stdout, _ = await proc.communicate()
|
||||||
container_init_pid = stdout.decode().strip()
|
container_init_pid = stdout.decode().strip()
|
||||||
@ -314,9 +317,7 @@ class WebWiresharkManager:
|
|||||||
# List all processes from host perspective with PID, PPID, and command
|
# List all processes from host perspective with PID, PPID, and command
|
||||||
# This is faster than docker exec and allows us to walk the process tree
|
# This is faster than docker exec and allows us to walk the process tree
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
"ps", "-eo", "pid,ppid,args",
|
"ps", "-eo", "pid,ppid,args", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
||||||
stdout=asyncio.subprocess.PIPE,
|
|
||||||
stderr=asyncio.subprocess.PIPE
|
|
||||||
)
|
)
|
||||||
stdout, _ = await proc.communicate()
|
stdout, _ = await proc.communicate()
|
||||||
|
|
||||||
@ -328,7 +329,7 @@ class WebWiresharkManager:
|
|||||||
children_map = {} # ppid -> [pid]
|
children_map = {} # ppid -> [pid]
|
||||||
process_info = {} # pid -> (ppid, command)
|
process_info = {} # pid -> (ppid, command)
|
||||||
|
|
||||||
for line in stdout.decode().strip().split('\n'):
|
for line in stdout.decode().strip().split("\n"):
|
||||||
parts = line.split(None, 2)
|
parts = line.split(None, 2)
|
||||||
if len(parts) < 3:
|
if len(parts) < 3:
|
||||||
continue
|
continue
|
||||||
@ -371,9 +372,7 @@ class WebWiresharkManager:
|
|||||||
logger.info(f"Killing {len(matching_pids)} processes: {matching_pids}")
|
logger.info(f"Killing {len(matching_pids)} processes: {matching_pids}")
|
||||||
# Kill all matching PIDs from host perspective
|
# Kill all matching PIDs from host perspective
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
"kill", "-9", *matching_pids,
|
"kill", "-9", *matching_pids, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
||||||
stdout=asyncio.subprocess.PIPE,
|
|
||||||
stderr=asyncio.subprocess.PIPE
|
|
||||||
)
|
)
|
||||||
await proc.communicate()
|
await proc.communicate()
|
||||||
logger.debug("Kill completed from host perspective")
|
logger.debug("Kill completed from host perspective")
|
||||||
@ -396,16 +395,12 @@ class WebWiresharkManager:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Combine all patterns into a single regex using OR operator
|
# Combine all patterns into a single regex using OR operator
|
||||||
combined_pattern = '|'.join(f'({pattern})' for pattern in patterns)
|
combined_pattern = "|".join(f"({pattern})" for pattern in patterns)
|
||||||
|
|
||||||
# Single pgrep to find all matching processes
|
# Single pgrep to find all matching processes
|
||||||
pgrep_cmd = f'pids=$(pgrep -f "{combined_pattern}" 2>/dev/null || true); if [ -n "$pids" ]; then echo "Found processes: $pids"; kill -9 $pids 2>/dev/null || true; fi'
|
pgrep_cmd = f'pids=$(pgrep -f "{combined_pattern}" 2>/dev/null || true); if [ -n "$pids" ]; then echo "Found processes: $pids"; kill -9 $pids 2>/dev/null || true; fi'
|
||||||
|
|
||||||
returncode, stdout, stderr = await self._exec_in_container(
|
returncode, stdout, stderr = await self._exec_in_container(container_id, pgrep_cmd, timeout=5)
|
||||||
container_id,
|
|
||||||
pgrep_cmd,
|
|
||||||
timeout=5
|
|
||||||
)
|
|
||||||
|
|
||||||
if "Found processes:" in stdout:
|
if "Found processes:" in stdout:
|
||||||
logger.info(stdout.strip())
|
logger.info(stdout.strip())
|
||||||
@ -422,10 +417,12 @@ class WebWiresharkManager:
|
|||||||
"""
|
"""
|
||||||
# Clean up X lock files and xpra socket files in a single docker exec
|
# Clean up X lock files and xpra socket files in a single docker exec
|
||||||
# This reduces docker exec calls from 2 to 1, improving stop performance
|
# This reduces docker exec calls from 2 to 1, improving stop performance
|
||||||
cmd = (f"rm -f /tmp/.X{display}-lock /tmp/.X11-unix/X{display} "
|
cmd = (
|
||||||
f"/run/user/1000/xpra/{display}/socket "
|
f"rm -f /tmp/.X{display}-lock /tmp/.X11-unix/X{display} "
|
||||||
f"/run/user/1000/xpra/*-{display} "
|
f"/run/user/1000/xpra/{display}/socket "
|
||||||
f"/home/gns3/.xpra/*-{display} 2>/dev/null || true")
|
f"/run/user/1000/xpra/*-{display} "
|
||||||
|
f"/home/gns3/.xpra/*-{display} 2>/dev/null || true"
|
||||||
|
)
|
||||||
returncode, stdout, stderr = await self._exec_in_container(container_id, cmd)
|
returncode, stdout, stderr = await self._exec_in_container(container_id, cmd)
|
||||||
logger.debug(f"Cleanup X locks and xpra sockets: returncode={returncode}")
|
logger.debug(f"Cleanup X locks and xpra sockets: returncode={returncode}")
|
||||||
|
|
||||||
@ -436,9 +433,11 @@ class WebWiresharkManager:
|
|||||||
container_id: Container ID
|
container_id: Container ID
|
||||||
display: Display number (e.g., 10210)
|
display: Display number (e.g., 10210)
|
||||||
"""
|
"""
|
||||||
cmd = (f"rm -f /run/user/1000/xpra/{display}/socket "
|
cmd = (
|
||||||
f"/run/user/1000/xpra/*-{display} "
|
f"rm -f /run/user/1000/xpra/{display}/socket "
|
||||||
f"/home/gns3/.xpra/*-{display} 2>/dev/null || true")
|
f"/run/user/1000/xpra/*-{display} "
|
||||||
|
f"/home/gns3/.xpra/*-{display} 2>/dev/null || true"
|
||||||
|
)
|
||||||
returncode, stdout, stderr = await self._exec_in_container(container_id, cmd)
|
returncode, stdout, stderr = await self._exec_in_container(container_id, cmd)
|
||||||
logger.debug(f"Cleanup socket files: returncode={returncode}")
|
logger.debug(f"Cleanup socket files: returncode={returncode}")
|
||||||
|
|
||||||
@ -457,20 +456,15 @@ class WebWiresharkManager:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
# Extract number and unit
|
# Extract number and unit
|
||||||
match = re.match(r'(\d+(?:\.\d+)?)\s*([kmg]b?)?', memory_str)
|
match = re.match(r"(\d+(?:\.\d+)?)\s*([kmg]b?)?", memory_str)
|
||||||
if not match:
|
if not match:
|
||||||
raise ValueError(f"Invalid memory format: {memory_str}")
|
raise ValueError(f"Invalid memory format: {memory_str}")
|
||||||
|
|
||||||
value = float(match.group(1))
|
value = float(match.group(1))
|
||||||
unit = match.group(2) or 'b'
|
unit = match.group(2) or "b"
|
||||||
|
|
||||||
# Convert to bytes
|
# Convert to bytes
|
||||||
multipliers = {
|
multipliers = {"b": 1, "k": 1024, "m": 1024 * 1024, "g": 1024 * 1024 * 1024}
|
||||||
'b': 1,
|
|
||||||
'k': 1024,
|
|
||||||
'm': 1024 * 1024,
|
|
||||||
'g': 1024 * 1024 * 1024
|
|
||||||
}
|
|
||||||
|
|
||||||
unit = unit[0] # Take first character
|
unit = unit[0] # Take first character
|
||||||
return int(value * multipliers.get(unit, 1))
|
return int(value * multipliers.get(unit, 1))
|
||||||
@ -511,8 +505,7 @@ class WebWiresharkManager:
|
|||||||
if container_id:
|
if container_id:
|
||||||
try:
|
try:
|
||||||
returncode, stdout, stderr = await self._exec_in_container(
|
returncode, stdout, stderr = await self._exec_in_container(
|
||||||
container_id,
|
container_id, "cat /proc/net/route | grep -E '^eth0\\s+00000000' | awk '{print $3}' | head -1"
|
||||||
"cat /proc/net/route | grep -E '^eth0\\s+00000000' | awk '{print $3}' | head -1"
|
|
||||||
)
|
)
|
||||||
logger.info(f"Gateway detection - fallback method: returncode={returncode}, stdout='{stdout}'")
|
logger.info(f"Gateway detection - fallback method: returncode={returncode}, stdout='{stdout}'")
|
||||||
if returncode == 0 and stdout.strip():
|
if returncode == 0 and stdout.strip():
|
||||||
@ -557,8 +550,7 @@ class WebWiresharkManager:
|
|||||||
# Use 'hostname -I' to get all IP addresses and take the first one
|
# Use 'hostname -I' to get all IP addresses and take the first one
|
||||||
# This works on most Linux systems and is more portable than 'ip' command
|
# This works on most Linux systems and is more portable than 'ip' command
|
||||||
returncode, stdout, stderr = await self._exec_in_container(
|
returncode, stdout, stderr = await self._exec_in_container(
|
||||||
container_id,
|
container_id, "hostname -I 2>/dev/null | awk '{print $1}'"
|
||||||
"hostname -I 2>/dev/null | awk '{print $1}'"
|
|
||||||
)
|
)
|
||||||
if returncode == 0 and stdout.strip():
|
if returncode == 0 and stdout.strip():
|
||||||
container_ip = stdout.strip()
|
container_ip = stdout.strip()
|
||||||
@ -585,7 +577,7 @@ class WebWiresharkManager:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
parsed = urlparse(url)
|
parsed = urlparse(url)
|
||||||
if parsed.hostname in ('localhost', '127.0.0.1', '0.0.0.0'):
|
if parsed.hostname in ("localhost", "127.0.0.1", "0.0.0.0"):
|
||||||
gateway = await self._get_container_gateway_ip(container_id)
|
gateway = await self._get_container_gateway_ip(container_id)
|
||||||
if gateway:
|
if gateway:
|
||||||
fixed_url = f"{parsed.scheme}://{gateway}:{parsed.port or 3080}{parsed.path}"
|
fixed_url = f"{parsed.scheme}://{gateway}:{parsed.port or 3080}{parsed.path}"
|
||||||
@ -627,11 +619,7 @@ class WebWiresharkManager:
|
|||||||
config = Config.instance()
|
config = Config.instance()
|
||||||
subnet = getattr(config.settings.WebWireshark, "network_subnet", "172.31.0.0/22")
|
subnet = getattr(config.settings.WebWireshark, "network_subnet", "172.31.0.0/22")
|
||||||
logger.info(f"Creating network {self.network_name} with subnet {subnet}")
|
logger.info(f"Creating network {self.network_name} with subnet {subnet}")
|
||||||
await self.docker.create_network(
|
await self.docker.create_network(self.network_name, driver="bridge", subnet=subnet)
|
||||||
self.network_name,
|
|
||||||
driver="bridge",
|
|
||||||
subnet=subnet
|
|
||||||
)
|
|
||||||
|
|
||||||
async def get_or_create_container(
|
async def get_or_create_container(
|
||||||
self,
|
self,
|
||||||
@ -640,7 +628,7 @@ class WebWiresharkManager:
|
|||||||
memory: str = "2g",
|
memory: str = "2g",
|
||||||
memory_swap: str = None,
|
memory_swap: str = None,
|
||||||
cpus: float = 1.0,
|
cpus: float = 1.0,
|
||||||
pids_limit: int = 1000
|
pids_limit: int = 1000,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Get or create project's Web Wireshark container.
|
"""Get or create project's Web Wireshark container.
|
||||||
|
|
||||||
@ -716,21 +704,15 @@ class WebWiresharkManager:
|
|||||||
"PidsLimit": pids_limit,
|
"PidsLimit": pids_limit,
|
||||||
"RestartPolicy": {"Name": "unless-stopped"},
|
"RestartPolicy": {"Name": "unless-stopped"},
|
||||||
"Init": True, # Use init system (tini) as PID 1 to reap zombie processes
|
"Init": True, # Use init system (tini) as PID 1 to reap zombie processes
|
||||||
"LogConfig": {
|
"LogConfig": {"Type": "json-file", "Config": {"max-size": "10m", "max-file": "3"}},
|
||||||
"Type": "json-file",
|
|
||||||
"Config": {
|
|
||||||
"max-size": "10m",
|
|
||||||
"max-file": "3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Health check configuration
|
# Health check configuration
|
||||||
health_config = {
|
health_config = {
|
||||||
"Test": ["CMD-SHELL", "xpra list"],
|
"Test": ["CMD-SHELL", "xpra list"],
|
||||||
"Interval": 30000000000, # 30 seconds (nanoseconds)
|
"Interval": 30000000000, # 30 seconds (nanoseconds)
|
||||||
"Timeout": 10000000000, # 10 seconds
|
"Timeout": 10000000000, # 10 seconds
|
||||||
"Retries": 3
|
"Retries": 3,
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -740,11 +722,7 @@ class WebWiresharkManager:
|
|||||||
network=self.network_name,
|
network=self.network_name,
|
||||||
host_config=host_config,
|
host_config=host_config,
|
||||||
health_config=health_config,
|
health_config=health_config,
|
||||||
environment={
|
environment={"XDG_RUNTIME_DIR": "/run/user/1000", "LANG": "C.UTF-8", "LC_ALL": "C.UTF-8"},
|
||||||
"XDG_RUNTIME_DIR": "/run/user/1000",
|
|
||||||
"LANG": "C.UTF-8",
|
|
||||||
"LC_ALL": "C.UTF-8"
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
error_msg = str(e)
|
error_msg = str(e)
|
||||||
@ -773,7 +751,7 @@ class WebWiresharkManager:
|
|||||||
memory: str = "2g",
|
memory: str = "2g",
|
||||||
memory_swap: Optional[str] = None,
|
memory_swap: Optional[str] = None,
|
||||||
cpus: float = 1.0,
|
cpus: float = 1.0,
|
||||||
pids_limit: int = 1000
|
pids_limit: int = 1000,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Start Web Wireshark session.
|
"""Start Web Wireshark session.
|
||||||
|
|
||||||
@ -799,20 +777,10 @@ class WebWiresharkManager:
|
|||||||
# Auto-detect capture_stream_url if not provided
|
# Auto-detect capture_stream_url if not provided
|
||||||
if not capture_stream_url:
|
if not capture_stream_url:
|
||||||
gns3_url = self.detect_gns3_url()
|
gns3_url = self.detect_gns3_url()
|
||||||
capture_stream_url = (
|
capture_stream_url = f"{gns3_url}/v3/projects/{project_id}/links/{link_id}/capture/stream"
|
||||||
f"{gns3_url}/v3/projects/{project_id}/links/"
|
|
||||||
f"{link_id}/capture/stream"
|
|
||||||
)
|
|
||||||
logger.info(f"Auto-detected capture stream URL: {capture_stream_url}")
|
logger.info(f"Auto-detected capture stream URL: {capture_stream_url}")
|
||||||
|
|
||||||
container_id = await self.get_or_create_container(
|
container_id = await self.get_or_create_container(project_id, image, memory, memory_swap, cpus, pids_limit)
|
||||||
project_id,
|
|
||||||
image,
|
|
||||||
memory,
|
|
||||||
memory_swap,
|
|
||||||
cpus,
|
|
||||||
pids_limit
|
|
||||||
)
|
|
||||||
container_name = f"gns3-wireshark-{project_id}"
|
container_name = f"gns3-wireshark-{project_id}"
|
||||||
|
|
||||||
# Fix localhost URL after we have container_id
|
# Fix localhost URL after we have container_id
|
||||||
@ -828,13 +796,15 @@ class WebWiresharkManager:
|
|||||||
has_process_residuals, has_socket_residuals = await self._check_residuals_exist(container_id, display)
|
has_process_residuals, has_socket_residuals = await self._check_residuals_exist(container_id, display)
|
||||||
|
|
||||||
if has_process_residuals or has_socket_residuals:
|
if has_process_residuals or has_socket_residuals:
|
||||||
logger.info(f"Found residual processes={has_process_residuals} sockets={has_socket_residuals} on display :{display}, cleaning up...")
|
logger.info(
|
||||||
|
f"Found residual processes={has_process_residuals} sockets={has_socket_residuals} on display :{display}, cleaning up..."
|
||||||
|
)
|
||||||
if has_process_residuals:
|
if has_process_residuals:
|
||||||
patterns = [
|
patterns = [
|
||||||
f'xpra.*:{display}',
|
f"xpra.*:{display}",
|
||||||
f'Xvfb.*:{display}',
|
f"Xvfb.*:{display}",
|
||||||
f'wireshark.*:{display}',
|
f"wireshark.*:{display}",
|
||||||
f'pulseaudio.*display=:{display}'
|
f"pulseaudio.*display=:{display}",
|
||||||
]
|
]
|
||||||
await self._kill_process_tree_batch(container_id, patterns)
|
await self._kill_process_tree_batch(container_id, patterns)
|
||||||
if has_socket_residuals:
|
if has_socket_residuals:
|
||||||
@ -848,29 +818,24 @@ class WebWiresharkManager:
|
|||||||
|
|
||||||
xpra_cmd = [
|
xpra_cmd = [
|
||||||
"XPRA_CLIENT_CAN_SHUTDOWN=false",
|
"XPRA_CLIENT_CAN_SHUTDOWN=false",
|
||||||
"xpra", "start", f":{display}",
|
"xpra",
|
||||||
|
"start",
|
||||||
|
f":{display}",
|
||||||
'--xvfb="Xvfb -screen 0 1920x1080x24 +extension RANDR"',
|
'--xvfb="Xvfb -screen 0 1920x1080x24 +extension RANDR"',
|
||||||
"--html=off",
|
"--html=off",
|
||||||
f"--bind-ws=0.0.0.0:{port}",
|
f"--bind-ws=0.0.0.0:{port}",
|
||||||
f"--session-name={session_name}",
|
f"--session-name={session_name}",
|
||||||
"--daemon=yes",
|
"--daemon=yes",
|
||||||
"--dbus-launch=no",
|
"--dbus-launch=no",
|
||||||
"--resize-display=yes"
|
"--resize-display=yes",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Parallel execution: get container info + start xpra
|
# Parallel execution: get container info + start xpra
|
||||||
container_info_task = asyncio.create_task(
|
container_info_task = asyncio.create_task(self.docker.get_container(container_name))
|
||||||
self.docker.get_container(container_name)
|
xpra_start_task = asyncio.create_task(self._exec_in_container(container_id, " ".join(xpra_cmd)))
|
||||||
)
|
|
||||||
xpra_start_task = asyncio.create_task(
|
|
||||||
self._exec_in_container(container_id, " ".join(xpra_cmd))
|
|
||||||
)
|
|
||||||
|
|
||||||
# Wait for both tasks to complete
|
# Wait for both tasks to complete
|
||||||
container, (returncode, stdout, stderr) = await asyncio.gather(
|
container, (returncode, stdout, stderr) = await asyncio.gather(container_info_task, xpra_start_task)
|
||||||
container_info_task,
|
|
||||||
xpra_start_task
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check xpra start result
|
# Check xpra start result
|
||||||
if returncode != 0:
|
if returncode != 0:
|
||||||
@ -901,10 +866,14 @@ class WebWiresharkManager:
|
|||||||
|
|
||||||
# Execute Wireshark command without waiting for completion
|
# Execute Wireshark command without waiting for completion
|
||||||
await asyncio.create_subprocess_exec(
|
await asyncio.create_subprocess_exec(
|
||||||
"docker", "exec", container_id,
|
"docker",
|
||||||
"bash", "-c", wireshark_cmd,
|
"exec",
|
||||||
|
container_id,
|
||||||
|
"bash",
|
||||||
|
"-c",
|
||||||
|
wireshark_cmd,
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.PIPE
|
stderr=asyncio.subprocess.PIPE,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Don't wait - Wireshark runs in background
|
# Don't wait - Wireshark runs in background
|
||||||
@ -918,7 +887,7 @@ class WebWiresharkManager:
|
|||||||
"container_name": container_name,
|
"container_name": container_name,
|
||||||
"container_id": container_id,
|
"container_id": container_id,
|
||||||
"session_name": session_name,
|
"session_name": session_name,
|
||||||
"capture_stream_url": capture_stream_url
|
"capture_stream_url": capture_stream_url,
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(f"Web Wireshark session started successfully: {result['ws_url']}")
|
logger.info(f"Web Wireshark session started successfully: {result['ws_url']}")
|
||||||
@ -947,11 +916,11 @@ class WebWiresharkManager:
|
|||||||
# This ensures child processes are properly terminated, not left as zombies
|
# This ensures child processes are properly terminated, not left as zombies
|
||||||
logger.info(f"Stopping all processes on display :{display}")
|
logger.info(f"Stopping all processes on display :{display}")
|
||||||
patterns = [
|
patterns = [
|
||||||
f'xpra.*:{display}',
|
f"xpra.*:{display}",
|
||||||
f'Xvfb.*:{display}',
|
f"Xvfb.*:{display}",
|
||||||
f'Xvfb-for-Xpra-{display}',
|
f"Xvfb-for-Xpra-{display}",
|
||||||
f'wireshark.*:{display}',
|
f"wireshark.*:{display}",
|
||||||
f'pulseaudio.*display=:{display}'
|
f"pulseaudio.*display=:{display}",
|
||||||
]
|
]
|
||||||
await self._kill_process_tree_batch(container["Id"], patterns)
|
await self._kill_process_tree_batch(container["Id"], patterns)
|
||||||
await self._cleanup_x_lock(container["Id"], display)
|
await self._cleanup_x_lock(container["Id"], display)
|
||||||
@ -971,7 +940,7 @@ class WebWiresharkManager:
|
|||||||
memory: str = "2g",
|
memory: str = "2g",
|
||||||
memory_swap: str = None,
|
memory_swap: str = None,
|
||||||
cpus: float = 1.0,
|
cpus: float = 1.0,
|
||||||
pids_limit: int = 1000
|
pids_limit: int = 1000,
|
||||||
):
|
):
|
||||||
"""Restart Web Wireshark session.
|
"""Restart Web Wireshark session.
|
||||||
|
|
||||||
@ -1001,7 +970,7 @@ class WebWiresharkManager:
|
|||||||
memory=memory,
|
memory=memory,
|
||||||
memory_swap=memory_swap,
|
memory_swap=memory_swap,
|
||||||
cpus=cpus,
|
cpus=cpus,
|
||||||
pids_limit=pids_limit
|
pids_limit=pids_limit,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def stop_all_sessions(self, project_id: str):
|
async def stop_all_sessions(self, project_id: str):
|
||||||
@ -1021,12 +990,7 @@ class WebWiresharkManager:
|
|||||||
|
|
||||||
# Kill all wireshark, xpra and Xvfb processes for link sessions
|
# Kill all wireshark, xpra and Xvfb processes for link sessions
|
||||||
# This ensures clean removal of all session processes
|
# This ensures clean removal of all session processes
|
||||||
patterns = [
|
patterns = ["xpra.*--session-name=link-", "Xvfb-for-Xpra-", "wireshark.*display :", "pulseaudio.*display :"]
|
||||||
"xpra.*--session-name=link-",
|
|
||||||
"Xvfb-for-Xpra-",
|
|
||||||
"wireshark.*display :",
|
|
||||||
"pulseaudio.*display :"
|
|
||||||
]
|
|
||||||
await self._kill_process_tree_batch(container["Id"], patterns)
|
await self._kill_process_tree_batch(container["Id"], patterns)
|
||||||
|
|
||||||
logger.info(f"All Web Wireshark sessions stopped for project {project_id}")
|
logger.info(f"All Web Wireshark sessions stopped for project {project_id}")
|
||||||
|
|||||||
@ -62,11 +62,7 @@ def check_docker():
|
|||||||
|
|
||||||
# Check if Docker daemon is running
|
# Check if Docker daemon is running
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(["docker", "info"], capture_output=True, text=True)
|
||||||
["docker", "info"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
print("Error: Docker daemon is not running", file=sys.stderr)
|
print("Error: Docker daemon is not running", file=sys.stderr)
|
||||||
print("Please start Docker and try again", file=sys.stderr)
|
print("Please start Docker and try again", file=sys.stderr)
|
||||||
@ -82,11 +78,7 @@ def image_exists():
|
|||||||
"""Check if the Docker image already exists."""
|
"""Check if the Docker image already exists."""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(["docker", "image", "inspect", DOCKER_IMAGE], capture_output=True, text=True)
|
||||||
["docker", "image", "inspect", DOCKER_IMAGE],
|
|
||||||
capture_output=True,
|
|
||||||
text=True
|
|
||||||
)
|
|
||||||
return result.returncode == 0
|
return result.returncode == 0
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
@ -115,11 +107,7 @@ def pull_image():
|
|||||||
print(f"Pulling Docker image: {DOCKER_IMAGE}")
|
print(f"Pulling Docker image: {DOCKER_IMAGE}")
|
||||||
print("-" * 60)
|
print("-" * 60)
|
||||||
|
|
||||||
result = subprocess.run(
|
result = subprocess.run(["docker", "pull", DOCKER_IMAGE], capture_output=True, text=True)
|
||||||
["docker", "pull", DOCKER_IMAGE],
|
|
||||||
capture_output=True,
|
|
||||||
text=True
|
|
||||||
)
|
|
||||||
|
|
||||||
# Store output for network error detection
|
# Store output for network error detection
|
||||||
pull_output = result.stdout + result.stderr
|
pull_output = result.stdout + result.stderr
|
||||||
@ -140,33 +128,17 @@ def build_image(dockerfile_path):
|
|||||||
print("-" * 60)
|
print("-" * 60)
|
||||||
|
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["docker", "build", "-t", DOCKER_IMAGE, "-f", dockerfile_path, "."],
|
["docker", "build", "-t", DOCKER_IMAGE, "-f", dockerfile_path, "."], cwd=dockerfile_dir, pass_fds=(1, 2)
|
||||||
cwd=dockerfile_dir,
|
|
||||||
pass_fds=(1, 2)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return result.returncode == 0
|
return result.returncode == 0
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(description="Setup Web Wireshark Docker image for GNS3")
|
||||||
description="Setup Web Wireshark Docker image for GNS3"
|
parser.add_argument("--force", action="store_true", help="Force rebuild even if image exists")
|
||||||
)
|
parser.add_argument("--build-only", action="store_true", help="Only build locally, skip pull")
|
||||||
parser.add_argument(
|
parser.add_argument("--pull-only", action="store_true", help="Only pull from registry, skip build")
|
||||||
"--force",
|
|
||||||
action="store_true",
|
|
||||||
help="Force rebuild even if image exists"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--build-only",
|
|
||||||
action="store_true",
|
|
||||||
help="Only build locally, skip pull"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--pull-only",
|
|
||||||
action="store_true",
|
|
||||||
help="Only pull from registry, skip build"
|
|
||||||
)
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
@ -183,7 +155,7 @@ def main():
|
|||||||
if image_exists() and not args.force:
|
if image_exists() and not args.force:
|
||||||
print(f"Image {DOCKER_IMAGE} already exists.")
|
print(f"Image {DOCKER_IMAGE} already exists.")
|
||||||
response = input("Do you want to rebuild it? [y/N]: ").strip().lower()
|
response = input("Do you want to rebuild it? [y/N]: ").strip().lower()
|
||||||
if response != 'y':
|
if response != "y":
|
||||||
print("Setup cancelled.")
|
print("Setup cancelled.")
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
print()
|
print()
|
||||||
|
|||||||
@ -30,12 +30,7 @@ async def collect_webwireshark_stats(projects: List) -> Dict:
|
|||||||
"""
|
"""
|
||||||
from .manager import WebWiresharkManager
|
from .manager import WebWiresharkManager
|
||||||
|
|
||||||
stats = {
|
stats = {"total_containers": 0, "running_containers": 0, "active_sessions": 0, "containers": []}
|
||||||
"total_containers": 0,
|
|
||||||
"running_containers": 0,
|
|
||||||
"active_sessions": 0,
|
|
||||||
"containers": []
|
|
||||||
}
|
|
||||||
|
|
||||||
# Create a single manager instance and reuse it
|
# Create a single manager instance and reuse it
|
||||||
manager = WebWiresharkManager()
|
manager = WebWiresharkManager()
|
||||||
@ -70,15 +65,14 @@ async def collect_webwireshark_stats(projects: List) -> Dict:
|
|||||||
pids_limit = host_config.get("PidsLimit", 0)
|
pids_limit = host_config.get("PidsLimit", 0)
|
||||||
|
|
||||||
# Count active capture sessions
|
# Count active capture sessions
|
||||||
active_sessions = sum(
|
active_sessions = sum(1 for link in project.links.values() if getattr(link, "capturing", False))
|
||||||
1 for link in project.links.values()
|
|
||||||
if getattr(link, "capturing", False)
|
|
||||||
)
|
|
||||||
stats["active_sessions"] += active_sessions
|
stats["active_sessions"] += active_sessions
|
||||||
container_info["active_sessions"] = active_sessions
|
container_info["active_sessions"] = active_sessions
|
||||||
|
|
||||||
# Add resource limits
|
# Add resource limits
|
||||||
container_info["memory_limit"] = f"{memory_limit / (1024**3):.1f} GB" if memory_limit > 0 else "unlimited"
|
container_info["memory_limit"] = (
|
||||||
|
f"{memory_limit / (1024**3):.1f} GB" if memory_limit > 0 else "unlimited"
|
||||||
|
)
|
||||||
container_info["cpu_limit"] = f"{cpu_quota / 1000000000:.1f}" if cpu_quota > 0 else "unlimited"
|
container_info["cpu_limit"] = f"{cpu_quota / 1000000000:.1f}" if cpu_quota > 0 else "unlimited"
|
||||||
container_info["pids_limit"] = pids_limit if pids_limit > 0 else "unlimited"
|
container_info["pids_limit"] = pids_limit if pids_limit > 0 else "unlimited"
|
||||||
|
|
||||||
@ -111,21 +105,15 @@ async def _get_container_resource_stats(container_id: str) -> Optional[Dict]:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["docker", "stats", "--no-stream", "--format",
|
["docker", "stats", "--no-stream", "--format", "{{.MemUsage}}\t{{.CPUPerc}}\t{{.PIDs}}", container_id],
|
||||||
"{{.MemUsage}}\t{{.CPUPerc}}\t{{.PIDs}}",
|
|
||||||
container_id],
|
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=2
|
timeout=2,
|
||||||
)
|
)
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
parts = result.stdout.strip().split("\t")
|
parts = result.stdout.strip().split("\t")
|
||||||
if len(parts) >= 3:
|
if len(parts) >= 3:
|
||||||
return {
|
return {"memory": parts[0], "cpu": parts[1], "pids": int(parts[2])}
|
||||||
"memory": parts[0],
|
|
||||||
"cpu": parts[1],
|
|
||||||
"pids": int(parts[2])
|
|
||||||
}
|
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
logger.debug(f"Docker stats timeout for container {container_id[:12]}")
|
logger.debug(f"Docker stats timeout for container {container_id[:12]}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@ -155,113 +155,71 @@ async def http_exception_handler(request: Request, exc: StarletteHTTPException):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
compute_api.include_router(
|
compute_api.include_router(capabilities.router, dependencies=[Depends(compute_authentication)], tags=["Capabilities"])
|
||||||
capabilities.router,
|
|
||||||
dependencies=[Depends(compute_authentication)],
|
|
||||||
tags=["Capabilities"]
|
|
||||||
)
|
|
||||||
|
|
||||||
compute_api.include_router(
|
compute_api.include_router(compute.router, dependencies=[Depends(compute_authentication)], tags=["Compute"])
|
||||||
compute.router,
|
|
||||||
dependencies=[Depends(compute_authentication)],
|
|
||||||
tags=["Compute"]
|
|
||||||
)
|
|
||||||
|
|
||||||
compute_api.include_router(
|
compute_api.include_router(notifications.router, tags=["Notifications"])
|
||||||
notifications.router,
|
|
||||||
tags=["Notifications"]
|
|
||||||
)
|
|
||||||
|
|
||||||
compute_api.include_router(
|
compute_api.include_router(projects.router, dependencies=[Depends(compute_authentication)], tags=["Projects"])
|
||||||
projects.router,
|
|
||||||
dependencies=[Depends(compute_authentication)],
|
|
||||||
tags=["Projects"]
|
|
||||||
)
|
|
||||||
|
|
||||||
compute_api.include_router(
|
compute_api.include_router(images.router, dependencies=[Depends(compute_authentication)], tags=["Images"])
|
||||||
images.router,
|
|
||||||
dependencies=[Depends(compute_authentication)],
|
|
||||||
tags=["Images"]
|
|
||||||
)
|
|
||||||
|
|
||||||
compute_api.include_router(
|
compute_api.include_router(
|
||||||
atm_switch_nodes.router,
|
atm_switch_nodes.router,
|
||||||
dependencies=[Depends(compute_authentication)],
|
dependencies=[Depends(compute_authentication)],
|
||||||
prefix="/projects/{project_id}/atm_switch/nodes",
|
prefix="/projects/{project_id}/atm_switch/nodes",
|
||||||
tags=["ATM switch"]
|
tags=["ATM switch"],
|
||||||
)
|
)
|
||||||
compute_api.include_router(
|
compute_api.include_router(
|
||||||
cloud_nodes.router,
|
cloud_nodes.router,
|
||||||
dependencies=[Depends(compute_authentication)],
|
dependencies=[Depends(compute_authentication)],
|
||||||
prefix="/projects/{project_id}/cloud/nodes",
|
prefix="/projects/{project_id}/cloud/nodes",
|
||||||
tags=["Cloud nodes"]
|
tags=["Cloud nodes"],
|
||||||
)
|
)
|
||||||
|
|
||||||
compute_api.include_router(
|
compute_api.include_router(docker_nodes.router, prefix="/projects/{project_id}/docker/nodes", tags=["Docker nodes"])
|
||||||
docker_nodes.router,
|
|
||||||
prefix="/projects/{project_id}/docker/nodes",
|
|
||||||
tags=["Docker nodes"]
|
|
||||||
)
|
|
||||||
|
|
||||||
compute_api.include_router(
|
compute_api.include_router(
|
||||||
dynamips_nodes.router,
|
dynamips_nodes.router, prefix="/projects/{project_id}/dynamips/nodes", tags=["Dynamips nodes"]
|
||||||
prefix="/projects/{project_id}/dynamips/nodes",
|
|
||||||
tags=["Dynamips nodes"]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
compute_api.include_router(
|
compute_api.include_router(
|
||||||
ethernet_hub_nodes.router,
|
ethernet_hub_nodes.router,
|
||||||
dependencies=[Depends(compute_authentication)],
|
dependencies=[Depends(compute_authentication)],
|
||||||
prefix="/projects/{project_id}/ethernet_hub/nodes",
|
prefix="/projects/{project_id}/ethernet_hub/nodes",
|
||||||
tags=["Ethernet hub nodes"]
|
tags=["Ethernet hub nodes"],
|
||||||
)
|
)
|
||||||
|
|
||||||
compute_api.include_router(
|
compute_api.include_router(
|
||||||
ethernet_switch_nodes.router,
|
ethernet_switch_nodes.router,
|
||||||
dependencies=[Depends(compute_authentication)],
|
dependencies=[Depends(compute_authentication)],
|
||||||
prefix="/projects/{project_id}/ethernet_switch/nodes",
|
prefix="/projects/{project_id}/ethernet_switch/nodes",
|
||||||
tags=["Ethernet switch nodes"]
|
tags=["Ethernet switch nodes"],
|
||||||
)
|
)
|
||||||
|
|
||||||
compute_api.include_router(
|
compute_api.include_router(
|
||||||
frame_relay_switch_nodes.router,
|
frame_relay_switch_nodes.router,
|
||||||
dependencies=[Depends(compute_authentication)],
|
dependencies=[Depends(compute_authentication)],
|
||||||
prefix="/projects/{project_id}/frame_relay_switch/nodes",
|
prefix="/projects/{project_id}/frame_relay_switch/nodes",
|
||||||
tags=["Frame Relay switch nodes"]
|
tags=["Frame Relay switch nodes"],
|
||||||
)
|
)
|
||||||
|
|
||||||
compute_api.include_router(
|
compute_api.include_router(iou_nodes.router, prefix="/projects/{project_id}/iou/nodes", tags=["IOU nodes"])
|
||||||
iou_nodes.router,
|
|
||||||
prefix="/projects/{project_id}/iou/nodes",
|
|
||||||
tags=["IOU nodes"])
|
|
||||||
|
|
||||||
compute_api.include_router(
|
compute_api.include_router(
|
||||||
nat_nodes.router,
|
nat_nodes.router,
|
||||||
dependencies=[Depends(compute_authentication)],
|
dependencies=[Depends(compute_authentication)],
|
||||||
prefix="/projects/{project_id}/nat/nodes",
|
prefix="/projects/{project_id}/nat/nodes",
|
||||||
tags=["NAT nodes"]
|
tags=["NAT nodes"],
|
||||||
)
|
)
|
||||||
|
|
||||||
compute_api.include_router(
|
compute_api.include_router(qemu_nodes.router, prefix="/projects/{project_id}/qemu/nodes", tags=["Qemu nodes"])
|
||||||
qemu_nodes.router,
|
|
||||||
prefix="/projects/{project_id}/qemu/nodes",
|
|
||||||
tags=["Qemu nodes"]
|
|
||||||
)
|
|
||||||
|
|
||||||
compute_api.include_router(
|
compute_api.include_router(
|
||||||
virtualbox_nodes.router,
|
virtualbox_nodes.router, prefix="/projects/{project_id}/virtualbox/nodes", tags=["VirtualBox nodes"]
|
||||||
prefix="/projects/{project_id}/virtualbox/nodes",
|
|
||||||
tags=["VirtualBox nodes"]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
compute_api.include_router(
|
compute_api.include_router(vmware_nodes.router, prefix="/projects/{project_id}/vmware/nodes", tags=["VMware nodes"])
|
||||||
vmware_nodes.router,
|
|
||||||
prefix="/projects/{project_id}/vmware/nodes",
|
|
||||||
tags=["VMware nodes"]
|
|
||||||
)
|
|
||||||
|
|
||||||
compute_api.include_router(
|
compute_api.include_router(vpcs_nodes.router, prefix="/projects/{project_id}/vpcs/nodes", tags=["VPCS nodes"])
|
||||||
vpcs_nodes.router,
|
|
||||||
prefix="/projects/{project_id}/vpcs/nodes",
|
|
||||||
tags=["VPCS nodes"]
|
|
||||||
)
|
|
||||||
|
|||||||
@ -79,8 +79,7 @@ def get_atm_switch(node: ATMSwitch = Depends(dep_node)) -> schemas.ATMSwitch:
|
|||||||
|
|
||||||
@router.post("/{node_id}/duplicate", response_model=schemas.ATMSwitch, status_code=status.HTTP_201_CREATED)
|
@router.post("/{node_id}/duplicate", response_model=schemas.ATMSwitch, status_code=status.HTTP_201_CREATED)
|
||||||
async def duplicate_atm_switch(
|
async def duplicate_atm_switch(
|
||||||
destination_node_id: UUID = Body(..., embed=True),
|
destination_node_id: UUID = Body(..., embed=True), node: ATMSwitch = Depends(dep_node)
|
||||||
node: ATMSwitch = Depends(dep_node)
|
|
||||||
) -> schemas.ATMSwitch:
|
) -> schemas.ATMSwitch:
|
||||||
"""
|
"""
|
||||||
Duplicate an ATM switch node.
|
Duplicate an ATM switch node.
|
||||||
@ -92,8 +91,7 @@ async def duplicate_atm_switch(
|
|||||||
|
|
||||||
@router.put("/{node_id}", response_model=schemas.ATMSwitch)
|
@router.put("/{node_id}", response_model=schemas.ATMSwitch)
|
||||||
async def update_atm_switch(
|
async def update_atm_switch(
|
||||||
node_data: schemas.ATMSwitchUpdate,
|
node_data: schemas.ATMSwitchUpdate, node: ATMSwitch = Depends(dep_node)
|
||||||
node: ATMSwitch = Depends(dep_node)
|
|
||||||
) -> schemas.ATMSwitch:
|
) -> schemas.ATMSwitch:
|
||||||
"""
|
"""
|
||||||
Update an ATM switch node.
|
Update an ATM switch node.
|
||||||
@ -124,8 +122,7 @@ def start_atm_switch(node: ATMSwitch = Depends(dep_node)) -> None:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
|
status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Start is not supported for ATM switches"
|
||||||
detail="Start is not supported for ATM switches"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -135,10 +132,7 @@ def stop_atm_switch(node: ATMSwitch = Depends(dep_node)) -> None:
|
|||||||
Stop an ATM switch node.
|
Stop an ATM switch node.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Stop is not supported for ATM switches")
|
||||||
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
|
|
||||||
detail="Stop is not supported for ATM switches"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{node_id}/suspend", status_code=status.HTTP_204_NO_CONTENT)
|
@router.post("/{node_id}/suspend", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
@ -149,8 +143,7 @@ def suspend_atm_switch(node: ATMSwitch = Depends(dep_node)) -> None:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
|
status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Suspend is not supported for ATM switches"
|
||||||
detail="Suspend is not supported for ATM switches"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -160,11 +153,11 @@ def suspend_atm_switch(node: ATMSwitch = Depends(dep_node)) -> None:
|
|||||||
response_model=schemas.UDPNIO,
|
response_model=schemas.UDPNIO,
|
||||||
)
|
)
|
||||||
async def create_nio(
|
async def create_nio(
|
||||||
*,
|
*,
|
||||||
adapter_number: int = Path(..., ge=0, le=0),
|
adapter_number: int = Path(..., ge=0, le=0),
|
||||||
port_number: int,
|
port_number: int,
|
||||||
nio_data: schemas.UDPNIO,
|
nio_data: schemas.UDPNIO,
|
||||||
node: ATMSwitch = Depends(dep_node)
|
node: ATMSwitch = Depends(dep_node),
|
||||||
) -> schemas.UDPNIO:
|
) -> schemas.UDPNIO:
|
||||||
"""
|
"""
|
||||||
Add a NIO (Network Input/Output) to the node.
|
Add a NIO (Network Input/Output) to the node.
|
||||||
@ -189,11 +182,11 @@ async def delete_nio(adapter_number: int, port_number: int, node: ATMSwitch = De
|
|||||||
|
|
||||||
@router.post("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start")
|
@router.post("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start")
|
||||||
async def start_capture(
|
async def start_capture(
|
||||||
*,
|
*,
|
||||||
adapter_number: int = Path(..., ge=0, le=0),
|
adapter_number: int = Path(..., ge=0, le=0),
|
||||||
port_number: int,
|
port_number: int,
|
||||||
node_capture_data: schemas.NodeCapture,
|
node_capture_data: schemas.NodeCapture,
|
||||||
node: ATMSwitch = Depends(dep_node)
|
node: ATMSwitch = Depends(dep_node),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Start a packet capture on the node.
|
Start a packet capture on the node.
|
||||||
@ -210,10 +203,7 @@ async def start_capture(
|
|||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
)
|
)
|
||||||
async def stop_capture(
|
async def stop_capture(
|
||||||
*,
|
*, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: ATMSwitch = Depends(dep_node)
|
||||||
adapter_number: int = Path(..., ge=0, le=0),
|
|
||||||
port_number: int,
|
|
||||||
node: ATMSwitch = Depends(dep_node)
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Stop a packet capture on the node.
|
Stop a packet capture on the node.
|
||||||
@ -225,10 +215,7 @@ async def stop_capture(
|
|||||||
|
|
||||||
@router.get("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream")
|
@router.get("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream")
|
||||||
async def stream_pcap_file(
|
async def stream_pcap_file(
|
||||||
*,
|
*, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: ATMSwitch = Depends(dep_node)
|
||||||
adapter_number: int = Path(..., ge=0, le=0),
|
|
||||||
port_number: int,
|
|
||||||
node: ATMSwitch = Depends(dep_node)
|
|
||||||
) -> StreamingResponse:
|
) -> StreamingResponse:
|
||||||
"""
|
"""
|
||||||
Stream the pcap capture file.
|
Stream the pcap capture file.
|
||||||
|
|||||||
@ -122,10 +122,7 @@ async def stop_cloud(node: Cloud = Depends(dep_node)) -> None:
|
|||||||
Stop a cloud node.
|
Stop a cloud node.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Stop is not supported for cloud nodes")
|
||||||
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
|
|
||||||
detail="Stop is not supported for cloud nodes"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{node_id}/suspend", status_code=status.HTTP_204_NO_CONTENT)
|
@router.post("/{node_id}/suspend", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
@ -136,8 +133,7 @@ async def suspend_cloud(node: Cloud = Depends(dep_node)) -> None:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
|
status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Suspend is not supported for cloud nodes"
|
||||||
detail="Suspend is not supported for cloud nodes"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -147,11 +143,11 @@ async def suspend_cloud(node: Cloud = Depends(dep_node)) -> None:
|
|||||||
response_model=Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO],
|
response_model=Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO],
|
||||||
)
|
)
|
||||||
async def create_cloud_nio(
|
async def create_cloud_nio(
|
||||||
*,
|
*,
|
||||||
adapter_number: int = Path(..., ge=0, le=0),
|
adapter_number: int = Path(..., ge=0, le=0),
|
||||||
port_number: int,
|
port_number: int,
|
||||||
nio_data: Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO],
|
nio_data: Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO],
|
||||||
node: Cloud = Depends(dep_node),
|
node: Cloud = Depends(dep_node),
|
||||||
) -> Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO]:
|
) -> Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO]:
|
||||||
"""
|
"""
|
||||||
Add a NIO (Network Input/Output) to the node.
|
Add a NIO (Network Input/Output) to the node.
|
||||||
@ -169,11 +165,11 @@ async def create_cloud_nio(
|
|||||||
response_model=Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO],
|
response_model=Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO],
|
||||||
)
|
)
|
||||||
async def update_cloud_nio(
|
async def update_cloud_nio(
|
||||||
*,
|
*,
|
||||||
adapter_number: int = Path(..., ge=0, le=0),
|
adapter_number: int = Path(..., ge=0, le=0),
|
||||||
port_number: int,
|
port_number: int,
|
||||||
nio_data: Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO],
|
nio_data: Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO],
|
||||||
node: Cloud = Depends(dep_node),
|
node: Cloud = Depends(dep_node),
|
||||||
) -> Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO]:
|
) -> Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO]:
|
||||||
"""
|
"""
|
||||||
Update a NIO (Network Input/Output) to the node.
|
Update a NIO (Network Input/Output) to the node.
|
||||||
@ -192,10 +188,7 @@ async def update_cloud_nio(
|
|||||||
|
|
||||||
@router.delete("/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def delete_cloud_nio(
|
async def delete_cloud_nio(
|
||||||
*,
|
*, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: Cloud = Depends(dep_node)
|
||||||
adapter_number: int = Path(..., ge=0, le=0),
|
|
||||||
port_number: int,
|
|
||||||
node: Cloud = Depends(dep_node)
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Remove a NIO (Network Input/Output) from the node.
|
Remove a NIO (Network Input/Output) from the node.
|
||||||
@ -207,11 +200,11 @@ async def delete_cloud_nio(
|
|||||||
|
|
||||||
@router.post("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start")
|
@router.post("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start")
|
||||||
async def start_cloud_capture(
|
async def start_cloud_capture(
|
||||||
*,
|
*,
|
||||||
adapter_number: int = Path(..., ge=0, le=0),
|
adapter_number: int = Path(..., ge=0, le=0),
|
||||||
port_number: int,
|
port_number: int,
|
||||||
node_capture_data: schemas.NodeCapture,
|
node_capture_data: schemas.NodeCapture,
|
||||||
node: Cloud = Depends(dep_node)
|
node: Cloud = Depends(dep_node),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Start a packet capture on the node.
|
Start a packet capture on the node.
|
||||||
@ -227,10 +220,7 @@ async def start_cloud_capture(
|
|||||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop", status_code=status.HTTP_204_NO_CONTENT
|
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop", status_code=status.HTTP_204_NO_CONTENT
|
||||||
)
|
)
|
||||||
async def stop_cloud_capture(
|
async def stop_cloud_capture(
|
||||||
*,
|
*, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: Cloud = Depends(dep_node)
|
||||||
adapter_number: int = Path(..., ge=0, le=0),
|
|
||||||
port_number: int,
|
|
||||||
node: Cloud = Depends(dep_node)
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Stop a packet capture on the node.
|
Stop a packet capture on the node.
|
||||||
@ -242,10 +232,7 @@ async def stop_cloud_capture(
|
|||||||
|
|
||||||
@router.get("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream")
|
@router.get("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream")
|
||||||
async def stream_pcap_file(
|
async def stream_pcap_file(
|
||||||
*,
|
*, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: Cloud = Depends(dep_node)
|
||||||
adapter_number: int = Path(..., ge=0, le=0),
|
|
||||||
port_number: int,
|
|
||||||
node: Cloud = Depends(dep_node)
|
|
||||||
) -> StreamingResponse:
|
) -> StreamingResponse:
|
||||||
"""
|
"""
|
||||||
Stream the pcap capture file.
|
Stream the pcap capture file.
|
||||||
@ -257,13 +244,9 @@ async def stream_pcap_file(
|
|||||||
return StreamingResponse(stream, media_type="application/vnd.tcpdump.pcap")
|
return StreamingResponse(stream, media_type="application/vnd.tcpdump.pcap")
|
||||||
|
|
||||||
|
|
||||||
@router.put(
|
@router.put("/{node_id}/markers/{marker_name}")
|
||||||
"/{node_id}/markers/{marker_name}"
|
|
||||||
)
|
|
||||||
async def toggle_cloud_marker(
|
async def toggle_cloud_marker(
|
||||||
marker_name: str,
|
marker_name: str, toggle_data: schemas.MarkerToggle, node: Cloud = Depends(dep_node)
|
||||||
toggle_data: schemas.MarkerToggle,
|
|
||||||
node: Cloud = Depends(dep_node)
|
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2).
|
Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2).
|
||||||
@ -278,19 +261,13 @@ async def toggle_cloud_marker(
|
|||||||
return {"marker_name": marker_name, "enabled": toggle_data.enabled}
|
return {"marker_name": marker_name, "enabled": toggle_data.enabled}
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post("/{node_id}/markers/pause", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
"/{node_id}/markers/pause",
|
|
||||||
status_code=status.HTTP_204_NO_CONTENT
|
|
||||||
)
|
|
||||||
async def pause_cloud_markers(node: Cloud = Depends(dep_node)) -> None:
|
async def pause_cloud_markers(node: Cloud = Depends(dep_node)) -> None:
|
||||||
|
|
||||||
await node._ubridge_marker_pause()
|
await node._ubridge_marker_pause()
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post("/{node_id}/markers/resume", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
"/{node_id}/markers/resume",
|
|
||||||
status_code=status.HTTP_204_NO_CONTENT
|
|
||||||
)
|
|
||||||
async def resume_cloud_markers(node: Cloud = Depends(dep_node)) -> None:
|
async def resume_cloud_markers(node: Cloud = Depends(dep_node)) -> None:
|
||||||
|
|
||||||
await node._ubridge_marker_resume()
|
await node._ubridge_marker_resume()
|
||||||
@ -298,7 +275,7 @@ async def resume_cloud_markers(node: Cloud = Depends(dep_node)) -> None:
|
|||||||
|
|
||||||
@router.delete(
|
@router.delete(
|
||||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/{marker_name}",
|
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/{marker_name}",
|
||||||
status_code=status.HTTP_204_NO_CONTENT
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
)
|
)
|
||||||
async def delete_cloud_marker_capture(
|
async def delete_cloud_marker_capture(
|
||||||
*,
|
*,
|
||||||
@ -306,7 +283,7 @@ async def delete_cloud_marker_capture(
|
|||||||
adapter_number: int = Path(..., ge=0, le=0),
|
adapter_number: int = Path(..., ge=0, le=0),
|
||||||
port_number: int,
|
port_number: int,
|
||||||
link_id: str = "",
|
link_id: str = "",
|
||||||
node: Cloud = Depends(dep_node)
|
node: Cloud = Depends(dep_node),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Delete a marker's capture pcap (called by the controller when the marker is
|
Delete a marker's capture pcap (called by the controller when the marker is
|
||||||
@ -321,9 +298,7 @@ async def delete_cloud_marker_capture(
|
|||||||
|
|
||||||
@router.put("/{node_id}/markers/{marker_name}/rebuild")
|
@router.put("/{node_id}/markers/{marker_name}/rebuild")
|
||||||
async def rebuild_cloud_marker(
|
async def rebuild_cloud_marker(
|
||||||
marker_name: str,
|
marker_name: str, rebuild_data: schemas.MarkerRebuild, node: Cloud = Depends(dep_node)
|
||||||
rebuild_data: schemas.MarkerRebuild,
|
|
||||||
node: Cloud = Depends(dep_node)
|
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Re-install a single marker filter with new BPF/tag/direction (delete + add,
|
Re-install a single marker filter with new BPF/tag/direction (delete + add,
|
||||||
@ -331,7 +306,11 @@ async def rebuild_cloud_marker(
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
await node.rebuild_marker_filter(
|
await node.rebuild_marker_filter(
|
||||||
marker_name, rebuild_data.link_id, rebuild_data.bpf,
|
marker_name,
|
||||||
rebuild_data.tag, rebuild_data.direction, rebuild_data.enabled,
|
rebuild_data.link_id,
|
||||||
|
rebuild_data.bpf,
|
||||||
|
rebuild_data.tag,
|
||||||
|
rebuild_data.direction,
|
||||||
|
rebuild_data.enabled,
|
||||||
)
|
)
|
||||||
return {"marker_name": marker_name}
|
return {"marker_name": marker_name}
|
||||||
|
|||||||
@ -32,7 +32,7 @@ security = HTTPBasic(auto_error=False)
|
|||||||
def compute_authentication(credentials: Optional[HTTPBasicCredentials] = Depends(security)) -> None:
|
def compute_authentication(credentials: Optional[HTTPBasicCredentials] = Depends(security)) -> None:
|
||||||
"""
|
"""
|
||||||
Authenticate compute requests.
|
Authenticate compute requests.
|
||||||
|
|
||||||
Returns None if authentication is disabled or if authentication succeeds
|
Returns None if authentication is disabled or if authentication succeeds
|
||||||
Raises HTTPException if authentication is required but credentials are invalid
|
Raises HTTPException if authentication is required but credentials are invalid
|
||||||
"""
|
"""
|
||||||
@ -58,9 +58,9 @@ def compute_authentication(credentials: Optional[HTTPBasicCredentials] = Depends
|
|||||||
headers={"WWW-Authenticate": "Basic"},
|
headers={"WWW-Authenticate": "Basic"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def ws_compute_authentication(websocket: WebSocket) -> Union[None, WebSocket]:
|
async def ws_compute_authentication(websocket: WebSocket) -> Union[None, WebSocket]:
|
||||||
"""
|
""" """
|
||||||
"""
|
|
||||||
|
|
||||||
server_settings = Config.instance().settings.Server
|
server_settings = Config.instance().settings.Server
|
||||||
|
|
||||||
|
|||||||
@ -51,7 +51,7 @@ def dep_node(project_id: UUID, node_id: UUID) -> DockerVM:
|
|||||||
response_model=schemas.Docker,
|
response_model=schemas.Docker,
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
responses={409: {"model": schemas.ErrorMessage, "description": "Could not create Docker node"}},
|
responses={409: {"model": schemas.ErrorMessage, "description": "Could not create Docker node"}},
|
||||||
dependencies=[Depends(compute_authentication)]
|
dependencies=[Depends(compute_authentication)],
|
||||||
)
|
)
|
||||||
async def create_docker_node(project_id: UUID, node_data: schemas.DockerCreate) -> schemas.Docker:
|
async def create_docker_node(project_id: UUID, node_data: schemas.DockerCreate) -> schemas.Docker:
|
||||||
"""
|
"""
|
||||||
@ -87,10 +87,23 @@ async def create_docker_node(project_id: UUID, node_data: schemas.DockerCreate)
|
|||||||
# fallback loop below only applies truly extra keys and does not
|
# fallback loop below only applies truly extra keys and does not
|
||||||
# re-trigger console/aux port setter logging.
|
# re-trigger console/aux port setter logging.
|
||||||
for key in (
|
for key in (
|
||||||
"console", "console_type", "console_resolution", "console_http_port",
|
"console",
|
||||||
"console_http_path", "aux", "aux_type", "start_command", "environment",
|
"console_type",
|
||||||
"adapters", "mac_address", "extra_hosts", "extra_volumes", "extra_configs",
|
"console_resolution",
|
||||||
"memory", "cpus", "image_digest",
|
"console_http_port",
|
||||||
|
"console_http_path",
|
||||||
|
"aux",
|
||||||
|
"aux_type",
|
||||||
|
"start_command",
|
||||||
|
"environment",
|
||||||
|
"adapters",
|
||||||
|
"mac_address",
|
||||||
|
"extra_hosts",
|
||||||
|
"extra_volumes",
|
||||||
|
"extra_configs",
|
||||||
|
"memory",
|
||||||
|
"cpus",
|
||||||
|
"image_digest",
|
||||||
):
|
):
|
||||||
node_data.pop(key, None)
|
node_data.pop(key, None)
|
||||||
for name, value in node_data.items():
|
for name, value in node_data.items():
|
||||||
@ -101,11 +114,7 @@ async def create_docker_node(project_id: UUID, node_data: schemas.DockerCreate)
|
|||||||
return container.asdict()
|
return container.asdict()
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get("/{node_id}", response_model=schemas.Docker, dependencies=[Depends(compute_authentication)])
|
||||||
"/{node_id}",
|
|
||||||
response_model=schemas.Docker,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
|
||||||
def get_docker_node(node: DockerVM = Depends(dep_node)) -> schemas.Docker:
|
def get_docker_node(node: DockerVM = Depends(dep_node)) -> schemas.Docker:
|
||||||
"""
|
"""
|
||||||
Return a Docker node.
|
Return a Docker node.
|
||||||
@ -114,11 +123,7 @@ def get_docker_node(node: DockerVM = Depends(dep_node)) -> schemas.Docker:
|
|||||||
return node.asdict()
|
return node.asdict()
|
||||||
|
|
||||||
|
|
||||||
@router.put(
|
@router.put("/{node_id}", response_model=schemas.Docker, dependencies=[Depends(compute_authentication)])
|
||||||
"/{node_id}",
|
|
||||||
response_model=schemas.Docker,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
|
||||||
async def update_docker_node(node_data: schemas.DockerUpdate, node: DockerVM = Depends(dep_node)) -> schemas.Docker:
|
async def update_docker_node(node_data: schemas.DockerUpdate, node: DockerVM = Depends(dep_node)) -> schemas.Docker:
|
||||||
"""
|
"""
|
||||||
Update a Docker node.
|
Update a Docker node.
|
||||||
@ -160,11 +165,7 @@ async def update_docker_node(node_data: schemas.DockerUpdate, node: DockerVM = D
|
|||||||
return node.asdict()
|
return node.asdict()
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post("/{node_id}/start", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
|
||||||
"/{node_id}/start",
|
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
|
||||||
async def start_docker_node(node: DockerVM = Depends(dep_node)) -> None:
|
async def start_docker_node(node: DockerVM = Depends(dep_node)) -> None:
|
||||||
"""
|
"""
|
||||||
Start a Docker node.
|
Start a Docker node.
|
||||||
@ -173,11 +174,7 @@ async def start_docker_node(node: DockerVM = Depends(dep_node)) -> None:
|
|||||||
await node.start()
|
await node.start()
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post("/{node_id}/stop", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
|
||||||
"/{node_id}/stop",
|
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
|
||||||
async def stop_docker_node(node: DockerVM = Depends(dep_node)) -> None:
|
async def stop_docker_node(node: DockerVM = Depends(dep_node)) -> None:
|
||||||
"""
|
"""
|
||||||
Stop a Docker node. This is the explicit user stop — the only path that
|
Stop a Docker node. This is the explicit user stop — the only path that
|
||||||
@ -189,9 +186,7 @@ async def stop_docker_node(node: DockerVM = Depends(dep_node)) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{node_id}/suspend",
|
"/{node_id}/suspend", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
)
|
||||||
async def suspend_docker_node(node: DockerVM = Depends(dep_node)) -> None:
|
async def suspend_docker_node(node: DockerVM = Depends(dep_node)) -> None:
|
||||||
"""
|
"""
|
||||||
@ -202,9 +197,7 @@ async def suspend_docker_node(node: DockerVM = Depends(dep_node)) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{node_id}/reload",
|
"/{node_id}/reload", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
)
|
||||||
async def reload_docker_node(node: DockerVM = Depends(dep_node)) -> None:
|
async def reload_docker_node(node: DockerVM = Depends(dep_node)) -> None:
|
||||||
"""
|
"""
|
||||||
@ -214,11 +207,7 @@ async def reload_docker_node(node: DockerVM = Depends(dep_node)) -> None:
|
|||||||
await node.restart()
|
await node.restart()
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post("/{node_id}/pause", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
|
||||||
"/{node_id}/pause",
|
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
|
||||||
async def pause_docker_node(node: DockerVM = Depends(dep_node)) -> None:
|
async def pause_docker_node(node: DockerVM = Depends(dep_node)) -> None:
|
||||||
"""
|
"""
|
||||||
Pause a Docker node.
|
Pause a Docker node.
|
||||||
@ -228,9 +217,7 @@ async def pause_docker_node(node: DockerVM = Depends(dep_node)) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{node_id}/unpause",
|
"/{node_id}/unpause", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
)
|
||||||
async def unpause_docker_node(node: DockerVM = Depends(dep_node)) -> None:
|
async def unpause_docker_node(node: DockerVM = Depends(dep_node)) -> None:
|
||||||
"""
|
"""
|
||||||
@ -240,11 +227,7 @@ async def unpause_docker_node(node: DockerVM = Depends(dep_node)) -> None:
|
|||||||
await node.unpause()
|
await node.unpause()
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
@router.delete("/{node_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
|
||||||
"/{node_id}",
|
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
|
||||||
async def delete_docker_node(node: DockerVM = Depends(dep_node)) -> None:
|
async def delete_docker_node(node: DockerVM = Depends(dep_node)) -> None:
|
||||||
"""
|
"""
|
||||||
Delete a Docker node.
|
Delete a Docker node.
|
||||||
@ -258,11 +241,10 @@ async def delete_docker_node(node: DockerVM = Depends(dep_node)) -> None:
|
|||||||
"/{node_id}/duplicate",
|
"/{node_id}/duplicate",
|
||||||
response_model=schemas.Docker,
|
response_model=schemas.Docker,
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
dependencies=[Depends(compute_authentication)]
|
dependencies=[Depends(compute_authentication)],
|
||||||
)
|
)
|
||||||
async def duplicate_docker_node(
|
async def duplicate_docker_node(
|
||||||
destination_node_id: UUID = Body(..., embed=True),
|
destination_node_id: UUID = Body(..., embed=True), node: DockerVM = Depends(dep_node)
|
||||||
node: DockerVM = Depends(dep_node)
|
|
||||||
) -> schemas.Docker:
|
) -> schemas.Docker:
|
||||||
"""
|
"""
|
||||||
Duplicate a Docker node.
|
Duplicate a Docker node.
|
||||||
@ -276,7 +258,7 @@ async def duplicate_docker_node(
|
|||||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
|
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
response_model=schemas.UDPNIO,
|
response_model=schemas.UDPNIO,
|
||||||
dependencies=[Depends(compute_authentication)]
|
dependencies=[Depends(compute_authentication)],
|
||||||
)
|
)
|
||||||
async def create_docker_node_nio(
|
async def create_docker_node_nio(
|
||||||
adapter_number: int, port_number: int, nio_data: schemas.UDPNIO, node: DockerVM = Depends(dep_node)
|
adapter_number: int, port_number: int, nio_data: schemas.UDPNIO, node: DockerVM = Depends(dep_node)
|
||||||
@ -295,7 +277,7 @@ async def create_docker_node_nio(
|
|||||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
|
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
response_model=schemas.UDPNIO,
|
response_model=schemas.UDPNIO,
|
||||||
dependencies=[Depends(compute_authentication)]
|
dependencies=[Depends(compute_authentication)],
|
||||||
)
|
)
|
||||||
async def update_docker_node_nio(
|
async def update_docker_node_nio(
|
||||||
adapter_number: int, port_number: int, nio_data: schemas.UDPNIO, node: DockerVM = Depends(dep_node)
|
adapter_number: int, port_number: int, nio_data: schemas.UDPNIO, node: DockerVM = Depends(dep_node)
|
||||||
@ -318,13 +300,9 @@ async def update_docker_node_nio(
|
|||||||
@router.delete(
|
@router.delete(
|
||||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
|
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
dependencies=[Depends(compute_authentication)]
|
dependencies=[Depends(compute_authentication)],
|
||||||
)
|
)
|
||||||
async def delete_docker_node_nio(
|
async def delete_docker_node_nio(adapter_number: int, port_number: int, node: DockerVM = Depends(dep_node)) -> None:
|
||||||
adapter_number: int,
|
|
||||||
port_number: int,
|
|
||||||
node: DockerVM = Depends(dep_node)
|
|
||||||
) -> None:
|
|
||||||
"""
|
"""
|
||||||
Delete a NIO (Network Input/Output) from the node.
|
Delete a NIO (Network Input/Output) from the node.
|
||||||
The port number on the Docker node is always 0.
|
The port number on the Docker node is always 0.
|
||||||
@ -335,13 +313,10 @@ async def delete_docker_node_nio(
|
|||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start",
|
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start",
|
||||||
dependencies=[Depends(compute_authentication)]
|
dependencies=[Depends(compute_authentication)],
|
||||||
)
|
)
|
||||||
async def start_docker_node_capture(
|
async def start_docker_node_capture(
|
||||||
adapter_number: int,
|
adapter_number: int, port_number: int, node_capture_data: schemas.NodeCapture, node: DockerVM = Depends(dep_node)
|
||||||
port_number: int,
|
|
||||||
node_capture_data: schemas.NodeCapture,
|
|
||||||
node: DockerVM = Depends(dep_node)
|
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Start a packet capture on the node.
|
Start a packet capture on the node.
|
||||||
@ -356,13 +331,9 @@ async def start_docker_node_capture(
|
|||||||
@router.post(
|
@router.post(
|
||||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop",
|
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop",
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
dependencies=[Depends(compute_authentication)]
|
dependencies=[Depends(compute_authentication)],
|
||||||
)
|
)
|
||||||
async def stop_docker_node_capture(
|
async def stop_docker_node_capture(adapter_number: int, port_number: int, node: DockerVM = Depends(dep_node)) -> None:
|
||||||
adapter_number: int,
|
|
||||||
port_number: int,
|
|
||||||
node: DockerVM = Depends(dep_node)
|
|
||||||
) -> None:
|
|
||||||
"""
|
"""
|
||||||
Stop a packet capture on the node.
|
Stop a packet capture on the node.
|
||||||
The port number on the Docker node is always 0.
|
The port number on the Docker node is always 0.
|
||||||
@ -373,12 +344,10 @@ async def stop_docker_node_capture(
|
|||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream",
|
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream",
|
||||||
dependencies=[Depends(compute_authentication)]
|
dependencies=[Depends(compute_authentication)],
|
||||||
)
|
)
|
||||||
async def stream_pcap_file(
|
async def stream_pcap_file(
|
||||||
adapter_number: int,
|
adapter_number: int, port_number: int, node: DockerVM = Depends(dep_node)
|
||||||
port_number: int,
|
|
||||||
node: DockerVM = Depends(dep_node)
|
|
||||||
) -> StreamingResponse:
|
) -> StreamingResponse:
|
||||||
"""
|
"""
|
||||||
Stream the pcap capture file.
|
Stream the pcap capture file.
|
||||||
@ -392,8 +361,7 @@ async def stream_pcap_file(
|
|||||||
|
|
||||||
@router.websocket("/{node_id}/console/ws")
|
@router.websocket("/{node_id}/console/ws")
|
||||||
async def console_ws(
|
async def console_ws(
|
||||||
websocket: Union[None, WebSocket] = Depends(ws_compute_authentication),
|
websocket: Union[None, WebSocket] = Depends(ws_compute_authentication), node: DockerVM = Depends(dep_node)
|
||||||
node: DockerVM = Depends(dep_node)
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Console WebSocket.
|
Console WebSocket.
|
||||||
@ -403,12 +371,9 @@ async def console_ws(
|
|||||||
await node.start_websocket_console(websocket)
|
await node.start_websocket_console(websocket)
|
||||||
|
|
||||||
|
|
||||||
@router.websocket(
|
@router.websocket("/{node_id}/console/vnc")
|
||||||
"/{node_id}/console/vnc"
|
|
||||||
)
|
|
||||||
async def vnc_console_ws(
|
async def vnc_console_ws(
|
||||||
websocket: Union[None, WebSocket] = Depends(ws_compute_authentication),
|
websocket: Union[None, WebSocket] = Depends(ws_compute_authentication), node: DockerVM = Depends(dep_node)
|
||||||
node: DockerVM = Depends(dep_node)
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
VNC Console WebSocket.
|
VNC Console WebSocket.
|
||||||
@ -419,23 +384,16 @@ async def vnc_console_ws(
|
|||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{node_id}/console/reset",
|
"/{node_id}/console/reset", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
)
|
||||||
async def reset_console(node: DockerVM = Depends(dep_node)) -> None:
|
async def reset_console(node: DockerVM = Depends(dep_node)) -> None:
|
||||||
|
|
||||||
await node.reset_console()
|
await node.reset_console()
|
||||||
|
|
||||||
|
|
||||||
@router.put(
|
@router.put("/{node_id}/markers/{marker_name}", dependencies=[Depends(compute_authentication)])
|
||||||
"/{node_id}/markers/{marker_name}",
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
|
||||||
async def toggle_docker_marker(
|
async def toggle_docker_marker(
|
||||||
marker_name: str,
|
marker_name: str, toggle_data: schemas.MarkerToggle, node: DockerVM = Depends(dep_node)
|
||||||
toggle_data: schemas.MarkerToggle,
|
|
||||||
node: DockerVM = Depends(dep_node)
|
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2).
|
Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2).
|
||||||
@ -451,9 +409,7 @@ async def toggle_docker_marker(
|
|||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{node_id}/markers/pause",
|
"/{node_id}/markers/pause", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
)
|
||||||
async def pause_docker_markers(node: DockerVM = Depends(dep_node)) -> None:
|
async def pause_docker_markers(node: DockerVM = Depends(dep_node)) -> None:
|
||||||
|
|
||||||
@ -461,9 +417,7 @@ async def pause_docker_markers(node: DockerVM = Depends(dep_node)) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{node_id}/markers/resume",
|
"/{node_id}/markers/resume", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
)
|
||||||
async def resume_docker_markers(node: DockerVM = Depends(dep_node)) -> None:
|
async def resume_docker_markers(node: DockerVM = Depends(dep_node)) -> None:
|
||||||
|
|
||||||
@ -473,14 +427,10 @@ async def resume_docker_markers(node: DockerVM = Depends(dep_node)) -> None:
|
|||||||
@router.delete(
|
@router.delete(
|
||||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/{marker_name}",
|
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/{marker_name}",
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
dependencies=[Depends(compute_authentication)]
|
dependencies=[Depends(compute_authentication)],
|
||||||
)
|
)
|
||||||
async def delete_docker_marker_capture(
|
async def delete_docker_marker_capture(
|
||||||
marker_name: str,
|
marker_name: str, adapter_number: int, port_number: int, link_id: str = "", node: DockerVM = Depends(dep_node)
|
||||||
adapter_number: int,
|
|
||||||
port_number: int,
|
|
||||||
link_id: str = "",
|
|
||||||
node: DockerVM = Depends(dep_node)
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Delete a marker's capture pcap (called by the controller when the marker is
|
Delete a marker's capture pcap (called by the controller when the marker is
|
||||||
@ -493,14 +443,9 @@ async def delete_docker_marker_capture(
|
|||||||
await node.delete_marker_capture(marker_name, link_id, nio)
|
await node.delete_marker_capture(marker_name, link_id, nio)
|
||||||
|
|
||||||
|
|
||||||
@router.put(
|
@router.put("/{node_id}/markers/{marker_name}/rebuild", dependencies=[Depends(compute_authentication)])
|
||||||
"/{node_id}/markers/{marker_name}/rebuild",
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
|
||||||
async def rebuild_docker_marker(
|
async def rebuild_docker_marker(
|
||||||
marker_name: str,
|
marker_name: str, rebuild_data: schemas.MarkerRebuild, node: DockerVM = Depends(dep_node)
|
||||||
rebuild_data: schemas.MarkerRebuild,
|
|
||||||
node: DockerVM = Depends(dep_node)
|
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Re-install a single marker filter with new BPF/tag/direction (delete + add,
|
Re-install a single marker filter with new BPF/tag/direction (delete + add,
|
||||||
@ -508,7 +453,11 @@ async def rebuild_docker_marker(
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
await node.rebuild_marker_filter(
|
await node.rebuild_marker_filter(
|
||||||
marker_name, rebuild_data.link_id, rebuild_data.bpf,
|
marker_name,
|
||||||
rebuild_data.tag, rebuild_data.direction, rebuild_data.enabled,
|
rebuild_data.link_id,
|
||||||
|
rebuild_data.bpf,
|
||||||
|
rebuild_data.tag,
|
||||||
|
rebuild_data.direction,
|
||||||
|
rebuild_data.enabled,
|
||||||
)
|
)
|
||||||
return {"marker_name": marker_name}
|
return {"marker_name": marker_name}
|
||||||
|
|||||||
@ -55,7 +55,7 @@ def dep_node(project_id: UUID, node_id: UUID) -> Router:
|
|||||||
response_model=schemas.Dynamips,
|
response_model=schemas.Dynamips,
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
responses={409: {"model": schemas.ErrorMessage, "description": "Could not create Dynamips node"}},
|
responses={409: {"model": schemas.ErrorMessage, "description": "Could not create Dynamips node"}},
|
||||||
dependencies=[Depends(compute_authentication)]
|
dependencies=[Depends(compute_authentication)],
|
||||||
)
|
)
|
||||||
async def create_router(project_id: UUID, node_data: schemas.DynamipsCreate) -> schemas.Dynamips:
|
async def create_router(project_id: UUID, node_data: schemas.DynamipsCreate) -> schemas.Dynamips:
|
||||||
"""
|
"""
|
||||||
@ -86,11 +86,7 @@ async def create_router(project_id: UUID, node_data: schemas.DynamipsCreate) ->
|
|||||||
return vm.asdict()
|
return vm.asdict()
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get("/{node_id}", response_model=schemas.Dynamips, dependencies=[Depends(compute_authentication)])
|
||||||
"/{node_id}",
|
|
||||||
response_model=schemas.Dynamips,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
|
||||||
def get_router(node: Router = Depends(dep_node)) -> schemas.Dynamips:
|
def get_router(node: Router = Depends(dep_node)) -> schemas.Dynamips:
|
||||||
"""
|
"""
|
||||||
Return Dynamips router.
|
Return Dynamips router.
|
||||||
@ -99,11 +95,7 @@ def get_router(node: Router = Depends(dep_node)) -> schemas.Dynamips:
|
|||||||
return node.asdict()
|
return node.asdict()
|
||||||
|
|
||||||
|
|
||||||
@router.put(
|
@router.put("/{node_id}", response_model=schemas.Dynamips, dependencies=[Depends(compute_authentication)])
|
||||||
"/{node_id}",
|
|
||||||
response_model=schemas.Dynamips,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
|
||||||
async def update_router(node_data: schemas.DynamipsUpdate, node: Router = Depends(dep_node)) -> schemas.Dynamips:
|
async def update_router(node_data: schemas.DynamipsUpdate, node: Router = Depends(dep_node)) -> schemas.Dynamips:
|
||||||
"""
|
"""
|
||||||
Update a Dynamips router.
|
Update a Dynamips router.
|
||||||
@ -114,11 +106,7 @@ async def update_router(node_data: schemas.DynamipsUpdate, node: Router = Depend
|
|||||||
return node.asdict()
|
return node.asdict()
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
@router.delete("/{node_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
|
||||||
"/{node_id}",
|
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
|
||||||
async def delete_router(node: Router = Depends(dep_node)) -> None:
|
async def delete_router(node: Router = Depends(dep_node)) -> None:
|
||||||
"""
|
"""
|
||||||
Delete a Dynamips router.
|
Delete a Dynamips router.
|
||||||
@ -127,11 +115,7 @@ async def delete_router(node: Router = Depends(dep_node)) -> None:
|
|||||||
await Dynamips.instance().delete_node(node.id)
|
await Dynamips.instance().delete_node(node.id)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post("/{node_id}/start", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
|
||||||
"/{node_id}/start",
|
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
|
||||||
async def start_router(node: Router = Depends(dep_node)) -> None:
|
async def start_router(node: Router = Depends(dep_node)) -> None:
|
||||||
"""
|
"""
|
||||||
Start a Dynamips router.
|
Start a Dynamips router.
|
||||||
@ -144,11 +128,7 @@ async def start_router(node: Router = Depends(dep_node)) -> None:
|
|||||||
await node.start()
|
await node.start()
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post("/{node_id}/stop", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
|
||||||
"/{node_id}/stop",
|
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
|
||||||
async def stop_router(node: Router = Depends(dep_node)) -> None:
|
async def stop_router(node: Router = Depends(dep_node)) -> None:
|
||||||
"""
|
"""
|
||||||
Stop a Dynamips router.
|
Stop a Dynamips router.
|
||||||
@ -158,9 +138,7 @@ async def stop_router(node: Router = Depends(dep_node)) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{node_id}/suspend",
|
"/{node_id}/suspend", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
)
|
||||||
async def suspend_router(node: Router = Depends(dep_node)) -> None:
|
async def suspend_router(node: Router = Depends(dep_node)) -> None:
|
||||||
|
|
||||||
@ -168,9 +146,7 @@ async def suspend_router(node: Router = Depends(dep_node)) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{node_id}/resume",
|
"/{node_id}/resume", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
)
|
||||||
async def resume_router(node: Router = Depends(dep_node)) -> None:
|
async def resume_router(node: Router = Depends(dep_node)) -> None:
|
||||||
"""
|
"""
|
||||||
@ -181,9 +157,7 @@ async def resume_router(node: Router = Depends(dep_node)) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{node_id}/reload",
|
"/{node_id}/reload", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
)
|
||||||
async def reload_router(node: Router = Depends(dep_node)) -> None:
|
async def reload_router(node: Router = Depends(dep_node)) -> None:
|
||||||
"""
|
"""
|
||||||
@ -197,13 +171,10 @@ async def reload_router(node: Router = Depends(dep_node)) -> None:
|
|||||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
|
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
response_model=schemas.UDPNIO,
|
response_model=schemas.UDPNIO,
|
||||||
dependencies=[Depends(compute_authentication)]
|
dependencies=[Depends(compute_authentication)],
|
||||||
)
|
)
|
||||||
async def create_nio(
|
async def create_nio(
|
||||||
adapter_number: int,
|
adapter_number: int, port_number: int, nio_data: schemas.UDPNIO, node: Router = Depends(dep_node)
|
||||||
port_number: int,
|
|
||||||
nio_data: schemas.UDPNIO,
|
|
||||||
node: Router = Depends(dep_node)
|
|
||||||
) -> schemas.UDPNIO:
|
) -> schemas.UDPNIO:
|
||||||
"""
|
"""
|
||||||
Add a NIO (Network Input/Output) to the node.
|
Add a NIO (Network Input/Output) to the node.
|
||||||
@ -218,13 +189,10 @@ async def create_nio(
|
|||||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
|
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
response_model=schemas.UDPNIO,
|
response_model=schemas.UDPNIO,
|
||||||
dependencies=[Depends(compute_authentication)]
|
dependencies=[Depends(compute_authentication)],
|
||||||
)
|
)
|
||||||
async def update_nio(
|
async def update_nio(
|
||||||
adapter_number: int,
|
adapter_number: int, port_number: int, nio_data: schemas.UDPNIO, node: Router = Depends(dep_node)
|
||||||
port_number: int,
|
|
||||||
nio_data: schemas.UDPNIO,
|
|
||||||
node: Router = Depends(dep_node)
|
|
||||||
) -> schemas.UDPNIO:
|
) -> schemas.UDPNIO:
|
||||||
"""
|
"""
|
||||||
Update a NIO (Network Input/Output) on the node.
|
Update a NIO (Network Input/Output) on the node.
|
||||||
@ -242,7 +210,7 @@ async def update_nio(
|
|||||||
@router.delete(
|
@router.delete(
|
||||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
|
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
dependencies=[Depends(compute_authentication)]
|
dependencies=[Depends(compute_authentication)],
|
||||||
)
|
)
|
||||||
async def delete_nio(adapter_number: int, port_number: int, node: Router = Depends(dep_node)) -> None:
|
async def delete_nio(adapter_number: int, port_number: int, node: Router = Depends(dep_node)) -> None:
|
||||||
"""
|
"""
|
||||||
@ -255,13 +223,10 @@ async def delete_nio(adapter_number: int, port_number: int, node: Router = Depen
|
|||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start",
|
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start",
|
||||||
dependencies=[Depends(compute_authentication)]
|
dependencies=[Depends(compute_authentication)],
|
||||||
)
|
)
|
||||||
async def start_capture(
|
async def start_capture(
|
||||||
adapter_number: int,
|
adapter_number: int, port_number: int, node_capture_data: schemas.NodeCapture, node: Router = Depends(dep_node)
|
||||||
port_number: int,
|
|
||||||
node_capture_data: schemas.NodeCapture,
|
|
||||||
node: Router = Depends(dep_node)
|
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Start a packet capture on the node.
|
Start a packet capture on the node.
|
||||||
@ -275,7 +240,7 @@ async def start_capture(
|
|||||||
@router.post(
|
@router.post(
|
||||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop",
|
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop",
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
dependencies=[Depends(compute_authentication)]
|
dependencies=[Depends(compute_authentication)],
|
||||||
)
|
)
|
||||||
async def stop_capture(adapter_number: int, port_number: int, node: Router = Depends(dep_node)) -> None:
|
async def stop_capture(adapter_number: int, port_number: int, node: Router = Depends(dep_node)) -> None:
|
||||||
"""
|
"""
|
||||||
@ -287,12 +252,10 @@ async def stop_capture(adapter_number: int, port_number: int, node: Router = Dep
|
|||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream",
|
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream",
|
||||||
dependencies=[Depends(compute_authentication)]
|
dependencies=[Depends(compute_authentication)],
|
||||||
)
|
)
|
||||||
async def stream_pcap_file(
|
async def stream_pcap_file(
|
||||||
adapter_number: int,
|
adapter_number: int, port_number: int, node: Router = Depends(dep_node)
|
||||||
port_number: int,
|
|
||||||
node: Router = Depends(dep_node)
|
|
||||||
) -> StreamingResponse:
|
) -> StreamingResponse:
|
||||||
"""
|
"""
|
||||||
Stream the pcap capture file.
|
Stream the pcap capture file.
|
||||||
@ -303,10 +266,7 @@ async def stream_pcap_file(
|
|||||||
return StreamingResponse(stream, media_type="application/vnd.tcpdump.pcap")
|
return StreamingResponse(stream, media_type="application/vnd.tcpdump.pcap")
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get("/{node_id}/idlepc_proposals", dependencies=[Depends(compute_authentication)])
|
||||||
"/{node_id}/idlepc_proposals",
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
|
||||||
async def get_idlepcs(node: Router = Depends(dep_node)) -> List[str]:
|
async def get_idlepcs(node: Router = Depends(dep_node)) -> List[str]:
|
||||||
"""
|
"""
|
||||||
Retrieve Dynamips idle-pc proposals
|
Retrieve Dynamips idle-pc proposals
|
||||||
@ -316,10 +276,7 @@ async def get_idlepcs(node: Router = Depends(dep_node)) -> List[str]:
|
|||||||
return await node.get_idle_pc_prop()
|
return await node.get_idle_pc_prop()
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get("/{node_id}/auto_idlepc", dependencies=[Depends(compute_authentication)])
|
||||||
"/{node_id}/auto_idlepc",
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
|
||||||
async def get_auto_idlepc(node: Router = Depends(dep_node)) -> dict:
|
async def get_auto_idlepc(node: Router = Depends(dep_node)) -> dict:
|
||||||
"""
|
"""
|
||||||
Get an automatically guessed best idle-pc value.
|
Get an automatically guessed best idle-pc value.
|
||||||
@ -333,9 +290,11 @@ async def get_auto_idlepc(node: Router = Depends(dep_node)) -> dict:
|
|||||||
"/{node_id}/duplicate",
|
"/{node_id}/duplicate",
|
||||||
response_model=schemas.Dynamips,
|
response_model=schemas.Dynamips,
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
dependencies=[Depends(compute_authentication)]
|
dependencies=[Depends(compute_authentication)],
|
||||||
)
|
)
|
||||||
async def duplicate_router(destination_node_id: UUID = Body(..., embed=True), node: Router = Depends(dep_node)) -> schemas.Dynamips:
|
async def duplicate_router(
|
||||||
|
destination_node_id: UUID = Body(..., embed=True), node: Router = Depends(dep_node)
|
||||||
|
) -> schemas.Dynamips:
|
||||||
"""
|
"""
|
||||||
Duplicate a router.
|
Duplicate a router.
|
||||||
"""
|
"""
|
||||||
@ -346,9 +305,7 @@ async def duplicate_router(destination_node_id: UUID = Body(..., embed=True), no
|
|||||||
|
|
||||||
@router.websocket("/{node_id}/console/ws")
|
@router.websocket("/{node_id}/console/ws")
|
||||||
async def console_ws(
|
async def console_ws(
|
||||||
websocket: Union[None, WebSocket] = Depends(ws_compute_authentication),
|
websocket: Union[None, WebSocket] = Depends(ws_compute_authentication), node: Router = Depends(dep_node)
|
||||||
node: Router = Depends(dep_node)
|
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Console WebSocket.
|
Console WebSocket.
|
||||||
@ -359,23 +316,16 @@ async def console_ws(
|
|||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{node_id}/console/reset",
|
"/{node_id}/console/reset", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
)
|
||||||
async def reset_console(node: Router = Depends(dep_node)) -> None:
|
async def reset_console(node: Router = Depends(dep_node)) -> None:
|
||||||
|
|
||||||
await node.reset_console()
|
await node.reset_console()
|
||||||
|
|
||||||
|
|
||||||
@router.put(
|
@router.put("/{node_id}/markers/{marker_name}", dependencies=[Depends(compute_authentication)])
|
||||||
"/{node_id}/markers/{marker_name}",
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
|
||||||
async def toggle_dynamips_marker(
|
async def toggle_dynamips_marker(
|
||||||
marker_name: str,
|
marker_name: str, toggle_data: schemas.MarkerToggle, node: Router = Depends(dep_node)
|
||||||
toggle_data: schemas.MarkerToggle,
|
|
||||||
node: Router = Depends(dep_node)
|
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2).
|
Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2).
|
||||||
@ -391,9 +341,7 @@ async def toggle_dynamips_marker(
|
|||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{node_id}/markers/pause",
|
"/{node_id}/markers/pause", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
)
|
||||||
async def pause_dynamips_markers(node: Router = Depends(dep_node)) -> None:
|
async def pause_dynamips_markers(node: Router = Depends(dep_node)) -> None:
|
||||||
|
|
||||||
@ -401,9 +349,7 @@ async def pause_dynamips_markers(node: Router = Depends(dep_node)) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{node_id}/markers/resume",
|
"/{node_id}/markers/resume", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
)
|
||||||
async def resume_dynamips_markers(node: Router = Depends(dep_node)) -> None:
|
async def resume_dynamips_markers(node: Router = Depends(dep_node)) -> None:
|
||||||
|
|
||||||
@ -413,14 +359,10 @@ async def resume_dynamips_markers(node: Router = Depends(dep_node)) -> None:
|
|||||||
@router.delete(
|
@router.delete(
|
||||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/{marker_name}",
|
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/{marker_name}",
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
dependencies=[Depends(compute_authentication)]
|
dependencies=[Depends(compute_authentication)],
|
||||||
)
|
)
|
||||||
async def delete_dynamips_marker_capture(
|
async def delete_dynamips_marker_capture(
|
||||||
marker_name: str,
|
marker_name: str, adapter_number: int, port_number: int, link_id: str = "", node: Router = Depends(dep_node)
|
||||||
adapter_number: int,
|
|
||||||
port_number: int,
|
|
||||||
link_id: str = "",
|
|
||||||
node: Router = Depends(dep_node)
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Delete a marker's capture pcap (called by the controller when the marker is
|
Delete a marker's capture pcap (called by the controller when the marker is
|
||||||
@ -433,14 +375,9 @@ async def delete_dynamips_marker_capture(
|
|||||||
await node.delete_marker_capture(marker_name, link_id, nio)
|
await node.delete_marker_capture(marker_name, link_id, nio)
|
||||||
|
|
||||||
|
|
||||||
@router.put(
|
@router.put("/{node_id}/markers/{marker_name}/rebuild", dependencies=[Depends(compute_authentication)])
|
||||||
"/{node_id}/markers/{marker_name}/rebuild",
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
|
||||||
async def rebuild_dynamips_marker(
|
async def rebuild_dynamips_marker(
|
||||||
marker_name: str,
|
marker_name: str, rebuild_data: schemas.MarkerRebuild, node: Router = Depends(dep_node)
|
||||||
rebuild_data: schemas.MarkerRebuild,
|
|
||||||
node: Router = Depends(dep_node)
|
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Re-install a single marker filter with new BPF/tag/direction (delete + add,
|
Re-install a single marker filter with new BPF/tag/direction (delete + add,
|
||||||
@ -448,7 +385,11 @@ async def rebuild_dynamips_marker(
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
await node.rebuild_marker_filter(
|
await node.rebuild_marker_filter(
|
||||||
marker_name, rebuild_data.link_id, rebuild_data.bpf,
|
marker_name,
|
||||||
rebuild_data.tag, rebuild_data.direction, rebuild_data.enabled,
|
rebuild_data.link_id,
|
||||||
|
rebuild_data.bpf,
|
||||||
|
rebuild_data.tag,
|
||||||
|
rebuild_data.direction,
|
||||||
|
rebuild_data.enabled,
|
||||||
)
|
)
|
||||||
return {"marker_name": marker_name}
|
return {"marker_name": marker_name}
|
||||||
|
|||||||
@ -91,8 +91,7 @@ async def duplicate_ethernet_hub(
|
|||||||
|
|
||||||
@router.put("/{node_id}", response_model=schemas.EthernetHub)
|
@router.put("/{node_id}", response_model=schemas.EthernetHub)
|
||||||
async def update_ethernet_hub(
|
async def update_ethernet_hub(
|
||||||
node_data: schemas.EthernetHubUpdate,
|
node_data: schemas.EthernetHubUpdate, node: EthernetHub = Depends(dep_node)
|
||||||
node: EthernetHub = Depends(dep_node)
|
|
||||||
) -> schemas.EthernetHub:
|
) -> schemas.EthernetHub:
|
||||||
"""
|
"""
|
||||||
Update an Ethernet hub.
|
Update an Ethernet hub.
|
||||||
@ -124,8 +123,7 @@ def start_ethernet_hub(node: EthernetHub = Depends(dep_node)) -> None:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
|
status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Start is not supported for Ethernet hubs"
|
||||||
detail="Start is not supported for Ethernet hubs"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -136,8 +134,7 @@ def stop_ethernet_hub(node: EthernetHub = Depends(dep_node)) -> None:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
|
status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Stop is not supported for Ethernet hubs"
|
||||||
detail="Stop is not supported for Ethernet hubs"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -148,8 +145,7 @@ def suspend_ethernet_hub(node: EthernetHub = Depends(dep_node)) -> None:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
|
status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Suspend is not supported for Ethernet hubs"
|
||||||
detail="Suspend is not supported for Ethernet hubs"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -159,11 +155,11 @@ def suspend_ethernet_hub(node: EthernetHub = Depends(dep_node)) -> None:
|
|||||||
response_model=schemas.UDPNIO,
|
response_model=schemas.UDPNIO,
|
||||||
)
|
)
|
||||||
async def create_nio(
|
async def create_nio(
|
||||||
*,
|
*,
|
||||||
adapter_number: int = Path(..., ge=0, le=0),
|
adapter_number: int = Path(..., ge=0, le=0),
|
||||||
port_number: int,
|
port_number: int,
|
||||||
nio_data: schemas.UDPNIO,
|
nio_data: schemas.UDPNIO,
|
||||||
node: EthernetHub = Depends(dep_node)
|
node: EthernetHub = Depends(dep_node),
|
||||||
) -> schemas.UDPNIO:
|
) -> schemas.UDPNIO:
|
||||||
"""
|
"""
|
||||||
Add a NIO (Network Input/Output) to the node.
|
Add a NIO (Network Input/Output) to the node.
|
||||||
@ -177,10 +173,7 @@ async def create_nio(
|
|||||||
|
|
||||||
@router.delete("/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def delete_nio(
|
async def delete_nio(
|
||||||
*,
|
*, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: EthernetHub = Depends(dep_node)
|
||||||
adapter_number: int = Path(..., ge=0, le=0),
|
|
||||||
port_number: int,
|
|
||||||
node: EthernetHub = Depends(dep_node)
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Delete a NIO (Network Input/Output) from the node.
|
Delete a NIO (Network Input/Output) from the node.
|
||||||
@ -193,11 +186,11 @@ async def delete_nio(
|
|||||||
|
|
||||||
@router.post("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start")
|
@router.post("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start")
|
||||||
async def start_capture(
|
async def start_capture(
|
||||||
*,
|
*,
|
||||||
adapter_number: int = Path(..., ge=0, le=0),
|
adapter_number: int = Path(..., ge=0, le=0),
|
||||||
port_number: int,
|
port_number: int,
|
||||||
node_capture_data: schemas.NodeCapture,
|
node_capture_data: schemas.NodeCapture,
|
||||||
node: EthernetHub = Depends(dep_node)
|
node: EthernetHub = Depends(dep_node),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Start a packet capture on the node.
|
Start a packet capture on the node.
|
||||||
@ -213,10 +206,7 @@ async def start_capture(
|
|||||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop", status_code=status.HTTP_204_NO_CONTENT
|
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop", status_code=status.HTTP_204_NO_CONTENT
|
||||||
)
|
)
|
||||||
async def stop_capture(
|
async def stop_capture(
|
||||||
*,
|
*, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: EthernetHub = Depends(dep_node)
|
||||||
adapter_number: int = Path(..., ge=0, le=0),
|
|
||||||
port_number: int,
|
|
||||||
node: EthernetHub = Depends(dep_node)
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Stop a packet capture on the node.
|
Stop a packet capture on the node.
|
||||||
@ -228,10 +218,7 @@ async def stop_capture(
|
|||||||
|
|
||||||
@router.get("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream")
|
@router.get("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream")
|
||||||
async def stream_pcap_file(
|
async def stream_pcap_file(
|
||||||
*,
|
*, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: EthernetHub = Depends(dep_node)
|
||||||
adapter_number: int = Path(..., ge=0, le=0),
|
|
||||||
port_number: int,
|
|
||||||
node: EthernetHub = Depends(dep_node)
|
|
||||||
) -> StreamingResponse:
|
) -> StreamingResponse:
|
||||||
"""
|
"""
|
||||||
Stream the pcap capture file.
|
Stream the pcap capture file.
|
||||||
|
|||||||
@ -82,8 +82,7 @@ def get_ethernet_switch(node: EthernetSwitch = Depends(dep_node)) -> schemas.Eth
|
|||||||
|
|
||||||
@router.post("/{node_id}/duplicate", response_model=schemas.EthernetSwitch, status_code=status.HTTP_201_CREATED)
|
@router.post("/{node_id}/duplicate", response_model=schemas.EthernetSwitch, status_code=status.HTTP_201_CREATED)
|
||||||
async def duplicate_ethernet_switch(
|
async def duplicate_ethernet_switch(
|
||||||
destination_node_id: UUID = Body(..., embed=True),
|
destination_node_id: UUID = Body(..., embed=True), node: EthernetSwitch = Depends(dep_node)
|
||||||
node: EthernetSwitch = Depends(dep_node)
|
|
||||||
) -> schemas.EthernetSwitch:
|
) -> schemas.EthernetSwitch:
|
||||||
"""
|
"""
|
||||||
Duplicate an Ethernet switch.
|
Duplicate an Ethernet switch.
|
||||||
@ -95,8 +94,7 @@ async def duplicate_ethernet_switch(
|
|||||||
|
|
||||||
@router.put("/{node_id}", response_model=schemas.EthernetSwitch)
|
@router.put("/{node_id}", response_model=schemas.EthernetSwitch)
|
||||||
async def update_ethernet_switch(
|
async def update_ethernet_switch(
|
||||||
node_data: schemas.EthernetSwitchUpdate,
|
node_data: schemas.EthernetSwitchUpdate, node: EthernetSwitch = Depends(dep_node)
|
||||||
node: EthernetSwitch = Depends(dep_node)
|
|
||||||
) -> schemas.EthernetSwitch:
|
) -> schemas.EthernetSwitch:
|
||||||
"""
|
"""
|
||||||
Update an Ethernet switch.
|
Update an Ethernet switch.
|
||||||
@ -132,8 +130,7 @@ def start_ethernet_switch(node: EthernetSwitch = Depends(dep_node)) -> None:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
|
status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Start is not supported for Ethernet switches"
|
||||||
detail="Start is not supported for Ethernet switches"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -144,8 +141,7 @@ def stop_ethernet_switch(node: EthernetSwitch = Depends(dep_node)) -> None:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
|
status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Stop is not supported for Ethernet switches"
|
||||||
detail="Stop is not supported for Ethernet switches"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -156,8 +152,7 @@ def suspend_ethernet_switch(node: EthernetSwitch = Depends(dep_node)) -> None:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
|
status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Suspend is not supported for Ethernet switches"
|
||||||
detail="Suspend is not supported for Ethernet switches"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -169,8 +164,7 @@ def reload_ethernet_switch(node: EthernetSwitch = Depends(dep_node)) -> None:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
|
status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Reload is not supported for Ethernet switches"
|
||||||
detail="Reload is not supported for Ethernet switches"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -180,11 +174,11 @@ def reload_ethernet_switch(node: EthernetSwitch = Depends(dep_node)) -> None:
|
|||||||
response_model=schemas.UDPNIO,
|
response_model=schemas.UDPNIO,
|
||||||
)
|
)
|
||||||
async def create_ethernet_switch_nio(
|
async def create_ethernet_switch_nio(
|
||||||
*,
|
*,
|
||||||
adapter_number: int = Path(..., ge=0, le=0),
|
adapter_number: int = Path(..., ge=0, le=0),
|
||||||
port_number: int,
|
port_number: int,
|
||||||
nio_data: schemas.UDPNIO,
|
nio_data: schemas.UDPNIO,
|
||||||
node: EthernetSwitch = Depends(dep_node)
|
node: EthernetSwitch = Depends(dep_node),
|
||||||
) -> schemas.UDPNIO:
|
) -> schemas.UDPNIO:
|
||||||
|
|
||||||
nio = Builtin.instance().create_nio(jsonable_encoder(nio_data, exclude_unset=True))
|
nio = Builtin.instance().create_nio(jsonable_encoder(nio_data, exclude_unset=True))
|
||||||
@ -198,11 +192,11 @@ async def create_ethernet_switch_nio(
|
|||||||
response_model=schemas.UDPNIO,
|
response_model=schemas.UDPNIO,
|
||||||
)
|
)
|
||||||
async def update_ethernet_switch_nio(
|
async def update_ethernet_switch_nio(
|
||||||
*,
|
*,
|
||||||
adapter_number: int = Path(..., ge=0, le=0),
|
adapter_number: int = Path(..., ge=0, le=0),
|
||||||
port_number: int,
|
port_number: int,
|
||||||
nio_data: schemas.UDPNIO,
|
nio_data: schemas.UDPNIO,
|
||||||
node: EthernetSwitch = Depends(dep_node)
|
node: EthernetSwitch = Depends(dep_node),
|
||||||
) -> schemas.UDPNIO:
|
) -> schemas.UDPNIO:
|
||||||
"""
|
"""
|
||||||
Update a NIO (Network Input/Output) on the node: re-apply the packet
|
Update a NIO (Network Input/Output) on the node: re-apply the packet
|
||||||
@ -221,10 +215,7 @@ async def update_ethernet_switch_nio(
|
|||||||
|
|
||||||
@router.delete("/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def delete_ethernet_switch_nio(
|
async def delete_ethernet_switch_nio(
|
||||||
*,
|
*, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: EthernetSwitch = Depends(dep_node)
|
||||||
adapter_number: int = Path(..., ge=0, le=0),
|
|
||||||
port_number: int,
|
|
||||||
node: EthernetSwitch = Depends(dep_node)
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Delete a NIO (Network Input/Output) from the node.
|
Delete a NIO (Network Input/Output) from the node.
|
||||||
@ -236,11 +227,11 @@ async def delete_ethernet_switch_nio(
|
|||||||
|
|
||||||
@router.post("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start")
|
@router.post("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start")
|
||||||
async def start_ethernet_switch_capture(
|
async def start_ethernet_switch_capture(
|
||||||
*,
|
*,
|
||||||
adapter_number: int = Path(..., ge=0, le=0),
|
adapter_number: int = Path(..., ge=0, le=0),
|
||||||
port_number: int,
|
port_number: int,
|
||||||
node_capture_data: schemas.NodeCapture,
|
node_capture_data: schemas.NodeCapture,
|
||||||
node: EthernetSwitch = Depends(dep_node),
|
node: EthernetSwitch = Depends(dep_node),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Start a packet capture on the node.
|
Start a packet capture on the node.
|
||||||
@ -256,10 +247,7 @@ async def start_ethernet_switch_capture(
|
|||||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop", status_code=status.HTTP_204_NO_CONTENT
|
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop", status_code=status.HTTP_204_NO_CONTENT
|
||||||
)
|
)
|
||||||
async def stop_ethernet_switch_capture(
|
async def stop_ethernet_switch_capture(
|
||||||
*,
|
*, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: EthernetSwitch = Depends(dep_node)
|
||||||
adapter_number: int = Path(..., ge=0, le=0),
|
|
||||||
port_number: int,
|
|
||||||
node: EthernetSwitch = Depends(dep_node)
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Stop a packet capture on the node.
|
Stop a packet capture on the node.
|
||||||
@ -271,10 +259,7 @@ async def stop_ethernet_switch_capture(
|
|||||||
|
|
||||||
@router.get("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream")
|
@router.get("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream")
|
||||||
async def stream_pcap_file(
|
async def stream_pcap_file(
|
||||||
*,
|
*, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: EthernetSwitch = Depends(dep_node)
|
||||||
adapter_number: int = Path(..., ge=0, le=0),
|
|
||||||
port_number: int,
|
|
||||||
node: EthernetSwitch = Depends(dep_node)
|
|
||||||
) -> StreamingResponse:
|
) -> StreamingResponse:
|
||||||
"""
|
"""
|
||||||
Stream the pcap capture file.
|
Stream the pcap capture file.
|
||||||
@ -288,9 +273,7 @@ async def stream_pcap_file(
|
|||||||
|
|
||||||
@router.put("/{node_id}/markers/{marker_name}")
|
@router.put("/{node_id}/markers/{marker_name}")
|
||||||
async def toggle_ethernet_switch_marker(
|
async def toggle_ethernet_switch_marker(
|
||||||
marker_name: str,
|
marker_name: str, toggle_data: schemas.MarkerToggle, node: EthernetSwitch = Depends(dep_node)
|
||||||
toggle_data: schemas.MarkerToggle,
|
|
||||||
node: EthernetSwitch = Depends(dep_node)
|
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2).
|
Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2).
|
||||||
@ -322,12 +305,12 @@ async def resume_ethernet_switch_markers(node: EthernetSwitch = Depends(dep_node
|
|||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
)
|
)
|
||||||
async def delete_ethernet_switch_marker_capture(
|
async def delete_ethernet_switch_marker_capture(
|
||||||
*,
|
*,
|
||||||
marker_name: str,
|
marker_name: str,
|
||||||
adapter_number: int = Path(..., ge=0, le=0),
|
adapter_number: int = Path(..., ge=0, le=0),
|
||||||
port_number: int,
|
port_number: int,
|
||||||
link_id: str = "",
|
link_id: str = "",
|
||||||
node: EthernetSwitch = Depends(dep_node)
|
node: EthernetSwitch = Depends(dep_node),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Delete a marker's capture pcap (called by the controller when the marker is
|
Delete a marker's capture pcap (called by the controller when the marker is
|
||||||
@ -342,9 +325,7 @@ async def delete_ethernet_switch_marker_capture(
|
|||||||
|
|
||||||
@router.put("/{node_id}/markers/{marker_name}/rebuild")
|
@router.put("/{node_id}/markers/{marker_name}/rebuild")
|
||||||
async def rebuild_ethernet_switch_marker(
|
async def rebuild_ethernet_switch_marker(
|
||||||
marker_name: str,
|
marker_name: str, rebuild_data: schemas.MarkerRebuild, node: EthernetSwitch = Depends(dep_node)
|
||||||
rebuild_data: schemas.MarkerRebuild,
|
|
||||||
node: EthernetSwitch = Depends(dep_node)
|
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Re-install a single marker filter with new BPF/tag/direction (delete + add,
|
Re-install a single marker filter with new BPF/tag/direction (delete + add,
|
||||||
@ -352,7 +333,11 @@ async def rebuild_ethernet_switch_marker(
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
await node.rebuild_marker_filter(
|
await node.rebuild_marker_filter(
|
||||||
marker_name, rebuild_data.link_id, rebuild_data.bpf,
|
marker_name,
|
||||||
rebuild_data.tag, rebuild_data.direction, rebuild_data.enabled,
|
rebuild_data.link_id,
|
||||||
|
rebuild_data.bpf,
|
||||||
|
rebuild_data.tag,
|
||||||
|
rebuild_data.direction,
|
||||||
|
rebuild_data.enabled,
|
||||||
)
|
)
|
||||||
return {"marker_name": marker_name}
|
return {"marker_name": marker_name}
|
||||||
|
|||||||
@ -51,8 +51,7 @@ def dep_node(project_id: UUID, node_id: UUID) -> FrameRelaySwitch:
|
|||||||
responses={409: {"model": schemas.ErrorMessage, "description": "Could not create Frame Relay switch node"}},
|
responses={409: {"model": schemas.ErrorMessage, "description": "Could not create Frame Relay switch node"}},
|
||||||
)
|
)
|
||||||
async def create_frame_relay_switch(
|
async def create_frame_relay_switch(
|
||||||
project_id: UUID,
|
project_id: UUID, node_data: schemas.FrameRelaySwitchCreate
|
||||||
node_data: schemas.FrameRelaySwitchCreate
|
|
||||||
) -> schemas.FrameRelaySwitch:
|
) -> schemas.FrameRelaySwitch:
|
||||||
"""
|
"""
|
||||||
Create a new Frame Relay switch node.
|
Create a new Frame Relay switch node.
|
||||||
@ -82,8 +81,7 @@ def get_frame_relay_switch(node: FrameRelaySwitch = Depends(dep_node)) -> schema
|
|||||||
|
|
||||||
@router.post("/{node_id}/duplicate", response_model=schemas.FrameRelaySwitch, status_code=status.HTTP_201_CREATED)
|
@router.post("/{node_id}/duplicate", response_model=schemas.FrameRelaySwitch, status_code=status.HTTP_201_CREATED)
|
||||||
async def duplicate_frame_relay_switch(
|
async def duplicate_frame_relay_switch(
|
||||||
destination_node_id: UUID = Body(..., embed=True),
|
destination_node_id: UUID = Body(..., embed=True), node: FrameRelaySwitch = Depends(dep_node)
|
||||||
node: FrameRelaySwitch = Depends(dep_node)
|
|
||||||
) -> schemas.FrameRelaySwitch:
|
) -> schemas.FrameRelaySwitch:
|
||||||
"""
|
"""
|
||||||
Duplicate a Frame Relay switch node.
|
Duplicate a Frame Relay switch node.
|
||||||
@ -95,8 +93,7 @@ async def duplicate_frame_relay_switch(
|
|||||||
|
|
||||||
@router.put("/{node_id}", response_model=schemas.FrameRelaySwitch)
|
@router.put("/{node_id}", response_model=schemas.FrameRelaySwitch)
|
||||||
async def update_frame_relay_switch(
|
async def update_frame_relay_switch(
|
||||||
node_data: schemas.FrameRelaySwitchUpdate,
|
node_data: schemas.FrameRelaySwitchUpdate, node: FrameRelaySwitch = Depends(dep_node)
|
||||||
node: FrameRelaySwitch = Depends(dep_node)
|
|
||||||
) -> schemas.FrameRelaySwitch:
|
) -> schemas.FrameRelaySwitch:
|
||||||
"""
|
"""
|
||||||
Update an Frame Relay switch node.
|
Update an Frame Relay switch node.
|
||||||
@ -127,8 +124,7 @@ def start_frame_relay_switch(node: FrameRelaySwitch = Depends(dep_node)) -> None
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
|
status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Start is not supported for Frame Relay switches"
|
||||||
detail="Start is not supported for Frame Relay switches"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -139,8 +135,7 @@ def stop_frame_relay_switch(node: FrameRelaySwitch = Depends(dep_node)) -> None:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
|
status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Stop is not supported for Frame Relay switches"
|
||||||
detail="Stop is not supported for Frame Relay switches"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -151,8 +146,7 @@ def suspend_frame_relay_switch(node: FrameRelaySwitch = Depends(dep_node)) -> No
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
|
status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Suspend is not supported for Frame Relay switches"
|
||||||
detail="Suspend is not supported for Frame Relay switches"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -162,11 +156,11 @@ def suspend_frame_relay_switch(node: FrameRelaySwitch = Depends(dep_node)) -> No
|
|||||||
response_model=schemas.UDPNIO,
|
response_model=schemas.UDPNIO,
|
||||||
)
|
)
|
||||||
async def create_nio(
|
async def create_nio(
|
||||||
*,
|
*,
|
||||||
adapter_number: int = Path(..., ge=0, le=0),
|
adapter_number: int = Path(..., ge=0, le=0),
|
||||||
port_number: int,
|
port_number: int,
|
||||||
nio_data: schemas.UDPNIO,
|
nio_data: schemas.UDPNIO,
|
||||||
node: FrameRelaySwitch = Depends(dep_node)
|
node: FrameRelaySwitch = Depends(dep_node),
|
||||||
) -> schemas.UDPNIO:
|
) -> schemas.UDPNIO:
|
||||||
"""
|
"""
|
||||||
Add a NIO (Network Input/Output) to the node.
|
Add a NIO (Network Input/Output) to the node.
|
||||||
@ -180,10 +174,7 @@ async def create_nio(
|
|||||||
|
|
||||||
@router.delete("/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def delete_nio(
|
async def delete_nio(
|
||||||
*,
|
*, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: FrameRelaySwitch = Depends(dep_node)
|
||||||
adapter_number: int = Path(..., ge=0, le=0),
|
|
||||||
port_number: int,
|
|
||||||
node: FrameRelaySwitch = Depends(dep_node)
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Remove a NIO (Network Input/Output) from the node.
|
Remove a NIO (Network Input/Output) from the node.
|
||||||
@ -196,11 +187,11 @@ async def delete_nio(
|
|||||||
|
|
||||||
@router.post("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start")
|
@router.post("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start")
|
||||||
async def start_capture(
|
async def start_capture(
|
||||||
*,
|
*,
|
||||||
adapter_number: int = Path(..., ge=0, le=0),
|
adapter_number: int = Path(..., ge=0, le=0),
|
||||||
port_number: int,
|
port_number: int,
|
||||||
node_capture_data: schemas.NodeCapture,
|
node_capture_data: schemas.NodeCapture,
|
||||||
node: FrameRelaySwitch = Depends(dep_node),
|
node: FrameRelaySwitch = Depends(dep_node),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Start a packet capture on the node.
|
Start a packet capture on the node.
|
||||||
@ -216,10 +207,7 @@ async def start_capture(
|
|||||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop", status_code=status.HTTP_204_NO_CONTENT
|
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop", status_code=status.HTTP_204_NO_CONTENT
|
||||||
)
|
)
|
||||||
async def stop_capture(
|
async def stop_capture(
|
||||||
*,
|
*, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: FrameRelaySwitch = Depends(dep_node)
|
||||||
adapter_number: int = Path(..., ge=0, le=0),
|
|
||||||
port_number: int,
|
|
||||||
node: FrameRelaySwitch = Depends(dep_node)
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Stop a packet capture on the node.
|
Stop a packet capture on the node.
|
||||||
@ -231,10 +219,7 @@ async def stop_capture(
|
|||||||
|
|
||||||
@router.get("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream")
|
@router.get("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream")
|
||||||
async def stream_pcap_file(
|
async def stream_pcap_file(
|
||||||
*,
|
*, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: FrameRelaySwitch = Depends(dep_node)
|
||||||
adapter_number: int = Path(..., ge=0, le=0),
|
|
||||||
port_number: int,
|
|
||||||
node: FrameRelaySwitch = Depends(dep_node)
|
|
||||||
) -> StreamingResponse:
|
) -> StreamingResponse:
|
||||||
"""
|
"""
|
||||||
Stream the pcap capture file.
|
Stream the pcap capture file.
|
||||||
|
|||||||
@ -52,7 +52,7 @@ def dep_node(project_id: UUID, node_id: UUID) -> IOUVM:
|
|||||||
response_model=schemas.IOU,
|
response_model=schemas.IOU,
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
responses={409: {"model": schemas.ErrorMessage, "description": "Could not create IOU node"}},
|
responses={409: {"model": schemas.ErrorMessage, "description": "Could not create IOU node"}},
|
||||||
dependencies=[Depends(compute_authentication)]
|
dependencies=[Depends(compute_authentication)],
|
||||||
)
|
)
|
||||||
async def create_iou_node(project_id: UUID, node_data: schemas.IOUCreate) -> schemas.IOU:
|
async def create_iou_node(project_id: UUID, node_data: schemas.IOUCreate) -> schemas.IOU:
|
||||||
"""
|
"""
|
||||||
@ -85,11 +85,7 @@ async def create_iou_node(project_id: UUID, node_data: schemas.IOUCreate) -> sch
|
|||||||
return vm.asdict()
|
return vm.asdict()
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get("/{node_id}", response_model=schemas.IOU, dependencies=[Depends(compute_authentication)])
|
||||||
"/{node_id}",
|
|
||||||
response_model=schemas.IOU,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
|
||||||
def get_iou_node(node: IOUVM = Depends(dep_node)) -> schemas.IOU:
|
def get_iou_node(node: IOUVM = Depends(dep_node)) -> schemas.IOU:
|
||||||
"""
|
"""
|
||||||
Return an IOU node.
|
Return an IOU node.
|
||||||
@ -98,11 +94,7 @@ def get_iou_node(node: IOUVM = Depends(dep_node)) -> schemas.IOU:
|
|||||||
return node.asdict()
|
return node.asdict()
|
||||||
|
|
||||||
|
|
||||||
@router.put(
|
@router.put("/{node_id}", response_model=schemas.IOU, dependencies=[Depends(compute_authentication)])
|
||||||
"/{node_id}",
|
|
||||||
response_model=schemas.IOU,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
|
||||||
async def update_iou_node(node_data: schemas.IOUUpdate, node: IOUVM = Depends(dep_node)) -> schemas.IOU:
|
async def update_iou_node(node_data: schemas.IOUUpdate, node: IOUVM = Depends(dep_node)) -> schemas.IOU:
|
||||||
"""
|
"""
|
||||||
Update an IOU node.
|
Update an IOU node.
|
||||||
@ -123,11 +115,7 @@ async def update_iou_node(node_data: schemas.IOUUpdate, node: IOUVM = Depends(de
|
|||||||
return node.asdict()
|
return node.asdict()
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
@router.delete("/{node_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
|
||||||
"/{node_id}",
|
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
|
||||||
async def delete_iou_node(node: IOUVM = Depends(dep_node)) -> None:
|
async def delete_iou_node(node: IOUVM = Depends(dep_node)) -> None:
|
||||||
"""
|
"""
|
||||||
Delete an IOU node.
|
Delete an IOU node.
|
||||||
@ -140,11 +128,10 @@ async def delete_iou_node(node: IOUVM = Depends(dep_node)) -> None:
|
|||||||
"/{node_id}/duplicate",
|
"/{node_id}/duplicate",
|
||||||
response_model=schemas.IOU,
|
response_model=schemas.IOU,
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
dependencies=[Depends(compute_authentication)]
|
dependencies=[Depends(compute_authentication)],
|
||||||
)
|
)
|
||||||
async def duplicate_iou_node(
|
async def duplicate_iou_node(
|
||||||
destination_node_id: UUID = Body(..., embed=True),
|
destination_node_id: UUID = Body(..., embed=True), node: IOUVM = Depends(dep_node)
|
||||||
node: IOUVM = Depends(dep_node)
|
|
||||||
) -> schemas.IOU:
|
) -> schemas.IOU:
|
||||||
"""
|
"""
|
||||||
Duplicate an IOU node.
|
Duplicate an IOU node.
|
||||||
@ -154,11 +141,7 @@ async def duplicate_iou_node(
|
|||||||
return new_node.asdict()
|
return new_node.asdict()
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post("/{node_id}/start", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
|
||||||
"/{node_id}/start",
|
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
|
||||||
async def start_iou_node(start_data: schemas.IOUStart, node: IOUVM = Depends(dep_node)) -> None:
|
async def start_iou_node(start_data: schemas.IOUStart, node: IOUVM = Depends(dep_node)) -> None:
|
||||||
"""
|
"""
|
||||||
Start an IOU node.
|
Start an IOU node.
|
||||||
@ -172,11 +155,7 @@ async def start_iou_node(start_data: schemas.IOUStart, node: IOUVM = Depends(dep
|
|||||||
await node.start()
|
await node.start()
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post("/{node_id}/stop", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
|
||||||
"/{node_id}/stop",
|
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
|
||||||
async def stop_iou_node(node: IOUVM = Depends(dep_node)) -> None:
|
async def stop_iou_node(node: IOUVM = Depends(dep_node)) -> None:
|
||||||
"""
|
"""
|
||||||
Stop an IOU node.
|
Stop an IOU node.
|
||||||
@ -185,25 +164,17 @@ async def stop_iou_node(node: IOUVM = Depends(dep_node)) -> None:
|
|||||||
await node.stop()
|
await node.stop()
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post("/{node_id}/suspend", dependencies=[Depends(compute_authentication)])
|
||||||
"/{node_id}/suspend",
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
|
||||||
def suspend_iou_node(node: IOUVM = Depends(dep_node)) -> None:
|
def suspend_iou_node(node: IOUVM = Depends(dep_node)) -> None:
|
||||||
"""
|
"""
|
||||||
Suspend an IOU node.
|
Suspend an IOU node.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Suspend is not supported for IOU nodes")
|
||||||
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
|
|
||||||
detail="Suspend is not supported for IOU nodes"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{node_id}/reload",
|
"/{node_id}/reload", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
)
|
||||||
async def reload_iou_node(node: IOUVM = Depends(dep_node)) -> None:
|
async def reload_iou_node(node: IOUVM = Depends(dep_node)) -> None:
|
||||||
"""
|
"""
|
||||||
@ -217,7 +188,7 @@ async def reload_iou_node(node: IOUVM = Depends(dep_node)) -> None:
|
|||||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
|
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
response_model=Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO],
|
response_model=Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO],
|
||||||
dependencies=[Depends(compute_authentication)]
|
dependencies=[Depends(compute_authentication)],
|
||||||
)
|
)
|
||||||
async def create_iou_node_nio(
|
async def create_iou_node_nio(
|
||||||
adapter_number: int,
|
adapter_number: int,
|
||||||
@ -238,7 +209,7 @@ async def create_iou_node_nio(
|
|||||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
|
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
response_model=Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO],
|
response_model=Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO],
|
||||||
dependencies=[Depends(compute_authentication)]
|
dependencies=[Depends(compute_authentication)],
|
||||||
)
|
)
|
||||||
async def update_iou_node_nio(
|
async def update_iou_node_nio(
|
||||||
adapter_number: int,
|
adapter_number: int,
|
||||||
@ -263,7 +234,7 @@ async def update_iou_node_nio(
|
|||||||
@router.delete(
|
@router.delete(
|
||||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
|
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
dependencies=[Depends(compute_authentication)]
|
dependencies=[Depends(compute_authentication)],
|
||||||
)
|
)
|
||||||
async def delete_iou_node_nio(adapter_number: int, port_number: int, node: IOUVM = Depends(dep_node)) -> None:
|
async def delete_iou_node_nio(adapter_number: int, port_number: int, node: IOUVM = Depends(dep_node)) -> None:
|
||||||
"""
|
"""
|
||||||
@ -275,13 +246,10 @@ async def delete_iou_node_nio(adapter_number: int, port_number: int, node: IOUVM
|
|||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start",
|
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start",
|
||||||
dependencies=[Depends(compute_authentication)]
|
dependencies=[Depends(compute_authentication)],
|
||||||
)
|
)
|
||||||
async def start_iou_node_capture(
|
async def start_iou_node_capture(
|
||||||
adapter_number: int,
|
adapter_number: int, port_number: int, node_capture_data: schemas.NodeCapture, node: IOUVM = Depends(dep_node)
|
||||||
port_number: int,
|
|
||||||
node_capture_data: schemas.NodeCapture,
|
|
||||||
node: IOUVM = Depends(dep_node)
|
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Start a packet capture on the node.
|
Start a packet capture on the node.
|
||||||
@ -295,7 +263,7 @@ async def start_iou_node_capture(
|
|||||||
@router.post(
|
@router.post(
|
||||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop",
|
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop",
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
dependencies=[Depends(compute_authentication)]
|
dependencies=[Depends(compute_authentication)],
|
||||||
)
|
)
|
||||||
async def stop_iou_node_capture(adapter_number: int, port_number: int, node: IOUVM = Depends(dep_node)) -> None:
|
async def stop_iou_node_capture(adapter_number: int, port_number: int, node: IOUVM = Depends(dep_node)) -> None:
|
||||||
"""
|
"""
|
||||||
@ -307,13 +275,9 @@ async def stop_iou_node_capture(adapter_number: int, port_number: int, node: IOU
|
|||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream",
|
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream",
|
||||||
dependencies=[Depends(compute_authentication)]
|
dependencies=[Depends(compute_authentication)],
|
||||||
)
|
)
|
||||||
async def stream_pcap_file(
|
async def stream_pcap_file(adapter_number: int, port_number: int, node: IOUVM = Depends(dep_node)) -> StreamingResponse:
|
||||||
adapter_number: int,
|
|
||||||
port_number: int,
|
|
||||||
node: IOUVM = Depends(dep_node)
|
|
||||||
) -> StreamingResponse:
|
|
||||||
"""
|
"""
|
||||||
Stream the pcap capture file.
|
Stream the pcap capture file.
|
||||||
"""
|
"""
|
||||||
@ -327,8 +291,7 @@ async def stream_pcap_file(
|
|||||||
"/{node_id}/console/ws",
|
"/{node_id}/console/ws",
|
||||||
)
|
)
|
||||||
async def console_ws(
|
async def console_ws(
|
||||||
websocket: Union[None, WebSocket] = Depends(ws_compute_authentication),
|
websocket: Union[None, WebSocket] = Depends(ws_compute_authentication), node: IOUVM = Depends(dep_node)
|
||||||
node: IOUVM = Depends(dep_node)
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Console WebSocket.
|
Console WebSocket.
|
||||||
@ -339,23 +302,16 @@ async def console_ws(
|
|||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{node_id}/console/reset",
|
"/{node_id}/console/reset", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
)
|
||||||
async def reset_console(node: IOUVM = Depends(dep_node)) -> None:
|
async def reset_console(node: IOUVM = Depends(dep_node)) -> None:
|
||||||
|
|
||||||
await node.reset_console()
|
await node.reset_console()
|
||||||
|
|
||||||
|
|
||||||
@router.put(
|
@router.put("/{node_id}/markers/{marker_name}", dependencies=[Depends(compute_authentication)])
|
||||||
"/{node_id}/markers/{marker_name}",
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
|
||||||
async def toggle_iou_marker(
|
async def toggle_iou_marker(
|
||||||
marker_name: str,
|
marker_name: str, toggle_data: schemas.MarkerToggle, node: IOUVM = Depends(dep_node)
|
||||||
toggle_data: schemas.MarkerToggle,
|
|
||||||
node: IOUVM = Depends(dep_node)
|
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2).
|
Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2).
|
||||||
@ -371,9 +327,7 @@ async def toggle_iou_marker(
|
|||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{node_id}/markers/pause",
|
"/{node_id}/markers/pause", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
)
|
||||||
async def pause_iou_markers(node: IOUVM = Depends(dep_node)) -> None:
|
async def pause_iou_markers(node: IOUVM = Depends(dep_node)) -> None:
|
||||||
|
|
||||||
@ -381,9 +335,7 @@ async def pause_iou_markers(node: IOUVM = Depends(dep_node)) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{node_id}/markers/resume",
|
"/{node_id}/markers/resume", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
)
|
||||||
async def resume_iou_markers(node: IOUVM = Depends(dep_node)) -> None:
|
async def resume_iou_markers(node: IOUVM = Depends(dep_node)) -> None:
|
||||||
|
|
||||||
@ -393,14 +345,10 @@ async def resume_iou_markers(node: IOUVM = Depends(dep_node)) -> None:
|
|||||||
@router.delete(
|
@router.delete(
|
||||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/{marker_name}",
|
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/{marker_name}",
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
dependencies=[Depends(compute_authentication)]
|
dependencies=[Depends(compute_authentication)],
|
||||||
)
|
)
|
||||||
async def delete_iou_marker_capture(
|
async def delete_iou_marker_capture(
|
||||||
marker_name: str,
|
marker_name: str, adapter_number: int, port_number: int, link_id: str = "", node: IOUVM = Depends(dep_node)
|
||||||
adapter_number: int,
|
|
||||||
port_number: int,
|
|
||||||
link_id: str = "",
|
|
||||||
node: IOUVM = Depends(dep_node)
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Delete a marker's capture pcap (called by the controller when the marker is
|
Delete a marker's capture pcap (called by the controller when the marker is
|
||||||
@ -413,14 +361,9 @@ async def delete_iou_marker_capture(
|
|||||||
await node.delete_marker_capture(marker_name, link_id, nio)
|
await node.delete_marker_capture(marker_name, link_id, nio)
|
||||||
|
|
||||||
|
|
||||||
@router.put(
|
@router.put("/{node_id}/markers/{marker_name}/rebuild", dependencies=[Depends(compute_authentication)])
|
||||||
"/{node_id}/markers/{marker_name}/rebuild",
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
|
||||||
async def rebuild_iou_marker(
|
async def rebuild_iou_marker(
|
||||||
marker_name: str,
|
marker_name: str, rebuild_data: schemas.MarkerRebuild, node: IOUVM = Depends(dep_node)
|
||||||
rebuild_data: schemas.MarkerRebuild,
|
|
||||||
node: IOUVM = Depends(dep_node)
|
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Re-install a single marker filter with new BPF/tag/direction (delete + add,
|
Re-install a single marker filter with new BPF/tag/direction (delete + add,
|
||||||
@ -428,7 +371,11 @@ async def rebuild_iou_marker(
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
await node.rebuild_marker_filter(
|
await node.rebuild_marker_filter(
|
||||||
marker_name, rebuild_data.link_id, rebuild_data.bpf,
|
marker_name,
|
||||||
rebuild_data.tag, rebuild_data.direction, rebuild_data.enabled,
|
rebuild_data.link_id,
|
||||||
|
rebuild_data.bpf,
|
||||||
|
rebuild_data.tag,
|
||||||
|
rebuild_data.direction,
|
||||||
|
rebuild_data.enabled,
|
||||||
)
|
)
|
||||||
return {"marker_name": marker_name}
|
return {"marker_name": marker_name}
|
||||||
|
|||||||
@ -117,10 +117,7 @@ async def stop_nat_node(node: Nat = Depends(dep_node)) -> None:
|
|||||||
Stop a NAT node.
|
Stop a NAT node.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Stop is not supported for NAT nodes")
|
||||||
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
|
|
||||||
detail="Stop is not supported for NAT nodes"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{node_id}/suspend", status_code=status.HTTP_204_NO_CONTENT)
|
@router.post("/{node_id}/suspend", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
@ -129,10 +126,7 @@ async def suspend_nat_node(node: Nat = Depends(dep_node)) -> None:
|
|||||||
Suspend a NAT node.
|
Suspend a NAT node.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Suspend is not supported for NAT nodes")
|
||||||
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
|
|
||||||
detail="Suspend is not supported for NAT nodes"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
@ -141,11 +135,11 @@ async def suspend_nat_node(node: Nat = Depends(dep_node)) -> None:
|
|||||||
response_model=Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO],
|
response_model=Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO],
|
||||||
)
|
)
|
||||||
async def create_nat_node_nio(
|
async def create_nat_node_nio(
|
||||||
*,
|
*,
|
||||||
adapter_number: int = Path(..., ge=0, le=0),
|
adapter_number: int = Path(..., ge=0, le=0),
|
||||||
port_number: int,
|
port_number: int,
|
||||||
nio_data: Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO],
|
nio_data: Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO],
|
||||||
node: Nat = Depends(dep_node),
|
node: Nat = Depends(dep_node),
|
||||||
) -> Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO]:
|
) -> Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO]:
|
||||||
"""
|
"""
|
||||||
Add a NIO (Network Input/Output) to the node.
|
Add a NIO (Network Input/Output) to the node.
|
||||||
@ -163,11 +157,11 @@ async def create_nat_node_nio(
|
|||||||
response_model=Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO],
|
response_model=Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO],
|
||||||
)
|
)
|
||||||
async def update_nat_node_nio(
|
async def update_nat_node_nio(
|
||||||
*,
|
*,
|
||||||
adapter_number: int = Path(..., ge=0, le=0),
|
adapter_number: int = Path(..., ge=0, le=0),
|
||||||
port_number: int,
|
port_number: int,
|
||||||
nio_data: Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO],
|
nio_data: Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO],
|
||||||
node: Nat = Depends(dep_node),
|
node: Nat = Depends(dep_node),
|
||||||
) -> Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO]:
|
) -> Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO]:
|
||||||
"""
|
"""
|
||||||
Update a NIO (Network Input/Output) to the node.
|
Update a NIO (Network Input/Output) to the node.
|
||||||
@ -184,10 +178,7 @@ async def update_nat_node_nio(
|
|||||||
|
|
||||||
@router.delete("/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def delete_nat_node_nio(
|
async def delete_nat_node_nio(
|
||||||
*,
|
*, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: Nat = Depends(dep_node)
|
||||||
adapter_number: int = Path(..., ge=0, le=0),
|
|
||||||
port_number: int,
|
|
||||||
node: Nat = Depends(dep_node)
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Remove a NIO (Network Input/Output) from the node.
|
Remove a NIO (Network Input/Output) from the node.
|
||||||
@ -199,11 +190,11 @@ async def delete_nat_node_nio(
|
|||||||
|
|
||||||
@router.post("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start")
|
@router.post("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start")
|
||||||
async def start_nat_node_capture(
|
async def start_nat_node_capture(
|
||||||
*,
|
*,
|
||||||
adapter_number: int = Path(..., ge=0, le=0),
|
adapter_number: int = Path(..., ge=0, le=0),
|
||||||
port_number: int,
|
port_number: int,
|
||||||
node_capture_data: schemas.NodeCapture,
|
node_capture_data: schemas.NodeCapture,
|
||||||
node: Nat = Depends(dep_node)
|
node: Nat = Depends(dep_node),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Start a packet capture on the node.
|
Start a packet capture on the node.
|
||||||
@ -219,10 +210,7 @@ async def start_nat_node_capture(
|
|||||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop", status_code=status.HTTP_204_NO_CONTENT
|
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop", status_code=status.HTTP_204_NO_CONTENT
|
||||||
)
|
)
|
||||||
async def stop_nat_node_capture(
|
async def stop_nat_node_capture(
|
||||||
*,
|
*, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: Nat = Depends(dep_node)
|
||||||
adapter_number: int = Path(..., ge=0, le=0),
|
|
||||||
port_number: int,
|
|
||||||
node: Nat = Depends(dep_node)
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Stop a packet capture on the node.
|
Stop a packet capture on the node.
|
||||||
@ -234,10 +222,7 @@ async def stop_nat_node_capture(
|
|||||||
|
|
||||||
@router.get("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream")
|
@router.get("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream")
|
||||||
async def stream_pcap_file(
|
async def stream_pcap_file(
|
||||||
*,
|
*, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: Nat = Depends(dep_node)
|
||||||
adapter_number: int = Path(..., ge=0, le=0),
|
|
||||||
port_number: int,
|
|
||||||
node: Nat = Depends(dep_node)
|
|
||||||
) -> StreamingResponse:
|
) -> StreamingResponse:
|
||||||
"""
|
"""
|
||||||
Stream the pcap capture file.
|
Stream the pcap capture file.
|
||||||
|
|||||||
@ -18,7 +18,6 @@
|
|||||||
API routes for compute notifications.
|
API routes for compute notifications.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect
|
from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect
|
||||||
from typing import Union
|
from typing import Union
|
||||||
from websockets.exceptions import ConnectionClosed, WebSocketException
|
from websockets.exceptions import ConnectionClosed, WebSocketException
|
||||||
@ -53,7 +52,6 @@ async def project_ws_notifications(websocket: Union[None, WebSocket] = Depends(w
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from starlette.responses import HTMLResponse
|
from starlette.responses import HTMLResponse
|
||||||
|
|||||||
@ -86,8 +86,7 @@ def create_compute_project(project_data: schemas.ProjectCreate) -> schemas.Proje
|
|||||||
|
|
||||||
@router.put("/projects/{project_id}", response_model=schemas.Project)
|
@router.put("/projects/{project_id}", response_model=schemas.Project)
|
||||||
async def update_compute_project(
|
async def update_compute_project(
|
||||||
project_data: schemas.ProjectUpdate,
|
project_data: schemas.ProjectUpdate, project: Project = Depends(dep_project)
|
||||||
project: Project = Depends(dep_project)
|
|
||||||
) -> schemas.Project:
|
) -> schemas.Project:
|
||||||
"""
|
"""
|
||||||
Update project on the compute.
|
Update project on the compute.
|
||||||
@ -195,6 +194,7 @@ def _get_existing_nio(node, adapter_number, port_number):
|
|||||||
# via get_nio(port).
|
# via get_nio(port).
|
||||||
if hasattr(node, "get_nio"):
|
if hasattr(node, "get_nio"):
|
||||||
import inspect as _inspect
|
import inspect as _inspect
|
||||||
|
|
||||||
if len(_inspect.signature(node.get_nio).parameters) >= 2:
|
if len(_inspect.signature(node.get_nio).parameters) >= 2:
|
||||||
return node.get_nio(adapter_number, port_number)
|
return node.get_nio(adapter_number, port_number)
|
||||||
return node.get_nio(port_number)
|
return node.get_nio(port_number)
|
||||||
@ -242,9 +242,9 @@ async def _update_nio_binding(node, adapter_number, port_number, nio):
|
|||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
)
|
)
|
||||||
async def create_batch_nios(
|
async def create_batch_nios(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
batch: schemas.BatchNIOCreate,
|
batch: schemas.BatchNIOCreate,
|
||||||
project: Project = Depends(dep_project),
|
project: Project = Depends(dep_project),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Create many NIO bindings across nodes in a single request.
|
Create many NIO bindings across nodes in a single request.
|
||||||
@ -281,9 +281,7 @@ async def create_batch_nios(
|
|||||||
nio = node.manager.create_nio(nio_settings)
|
nio = node.manager.create_nio(nio_settings)
|
||||||
await _add_nio_binding(node, entry.adapter_number, entry.port_number, nio)
|
await _add_nio_binding(node, entry.adapter_number, entry.port_number, nio)
|
||||||
|
|
||||||
await asyncio.gather(
|
await asyncio.gather(*[_create_one_node(nid, ents) for nid, ents in per_node.items()])
|
||||||
*[_create_one_node(nid, ents) for nid, ents in per_node.items()]
|
|
||||||
)
|
|
||||||
return {"added": len(batch.nios)}
|
return {"added": len(batch.nios)}
|
||||||
|
|
||||||
|
|
||||||
@ -292,9 +290,9 @@ async def create_batch_nios(
|
|||||||
status_code=status.HTTP_200_OK,
|
status_code=status.HTTP_200_OK,
|
||||||
)
|
)
|
||||||
async def update_batch_nios(
|
async def update_batch_nios(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
batch: schemas.BatchNIOCreate,
|
batch: schemas.BatchNIOCreate,
|
||||||
project: Project = Depends(dep_project),
|
project: Project = Depends(dep_project),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Update many NIO bindings (filters + markers) across nodes in a single
|
Update many NIO bindings (filters + markers) across nodes in a single
|
||||||
@ -322,9 +320,7 @@ async def update_batch_nios(
|
|||||||
nio.markers = e.nio.markers or {}
|
nio.markers = e.nio.markers or {}
|
||||||
await _update_nio_binding(node, e.adapter_number, e.port_number, nio)
|
await _update_nio_binding(node, e.adapter_number, e.port_number, nio)
|
||||||
|
|
||||||
await asyncio.gather(
|
await asyncio.gather(*[_update_one_node(nid, ents) for nid, ents in per_node.items()])
|
||||||
*[_update_one_node(nid, ents) for nid, ents in per_node.items()]
|
|
||||||
)
|
|
||||||
return {"updated": len(batch.nios)}
|
return {"updated": len(batch.nios)}
|
||||||
|
|
||||||
|
|
||||||
@ -343,7 +339,7 @@ async def get_compute_node_files(
|
|||||||
node_id: str,
|
node_id: str,
|
||||||
project: Project = Depends(dep_project),
|
project: Project = Depends(dep_project),
|
||||||
path: str = Query("", description="Subdirectory path within node directory"),
|
path: str = Query("", description="Subdirectory path within node directory"),
|
||||||
recursive: bool = Query(False, description="Recursively list all files")
|
recursive: bool = Query(False, description="Recursively list all files"),
|
||||||
) -> List[schemas.NodeFile]:
|
) -> List[schemas.NodeFile]:
|
||||||
"""
|
"""
|
||||||
Return files belonging to a specific node with detailed metadata.
|
Return files belonging to a specific node with detailed metadata.
|
||||||
@ -374,11 +370,7 @@ async def get_compute_project_file(file_path: str, project: Project = Depends(de
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/projects/{project_id}/files/{file_path:path}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.post("/projects/{project_id}/files/{file_path:path}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def write_compute_project_file(
|
async def write_compute_project_file(file_path: str, request: Request, project: Project = Depends(dep_project)) -> None:
|
||||||
file_path: str,
|
|
||||||
request: Request,
|
|
||||||
project: Project = Depends(dep_project)
|
|
||||||
) -> None:
|
|
||||||
|
|
||||||
file_path = urllib.parse.unquote(file_path)
|
file_path = urllib.parse.unquote(file_path)
|
||||||
path = os.path.normpath(file_path)
|
path = os.path.normpath(file_path)
|
||||||
@ -405,10 +397,7 @@ async def write_compute_project_file(
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/projects/{project_id}/files/{file_path:path}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/projects/{project_id}/files/{file_path:path}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def delete_compute_project_file(
|
async def delete_compute_project_file(file_path: str, project: Project = Depends(dep_project)) -> None:
|
||||||
file_path: str,
|
|
||||||
project: Project = Depends(dep_project)
|
|
||||||
) -> None:
|
|
||||||
|
|
||||||
file_path = urllib.parse.unquote(file_path)
|
file_path = urllib.parse.unquote(file_path)
|
||||||
path = os.path.normpath(file_path)
|
path = os.path.normpath(file_path)
|
||||||
|
|||||||
@ -33,6 +33,7 @@ from gns3server.compute.qemu.qemu_vm import QemuVM
|
|||||||
from .dependencies.authentication import compute_authentication, ws_compute_authentication
|
from .dependencies.authentication import compute_authentication, ws_compute_authentication
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
responses = {404: {"model": schemas.ErrorMessage, "description": "Could not find project or Qemu node"}}
|
responses = {404: {"model": schemas.ErrorMessage, "description": "Could not find project or Qemu node"}}
|
||||||
@ -55,7 +56,7 @@ def dep_node(project_id: UUID, node_id: UUID) -> QemuVM:
|
|||||||
response_model=schemas.Qemu,
|
response_model=schemas.Qemu,
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
responses={409: {"model": schemas.ErrorMessage, "description": "Could not create Qemu node"}},
|
responses={409: {"model": schemas.ErrorMessage, "description": "Could not create Qemu node"}},
|
||||||
dependencies=[Depends(compute_authentication)]
|
dependencies=[Depends(compute_authentication)],
|
||||||
)
|
)
|
||||||
async def create_qemu_node(project_id: UUID, node_data: schemas.QemuCreate) -> schemas.Qemu:
|
async def create_qemu_node(project_id: UUID, node_data: schemas.QemuCreate) -> schemas.Qemu:
|
||||||
"""
|
"""
|
||||||
@ -117,11 +118,7 @@ async def create_qemu_node(project_id: UUID, node_data: schemas.QemuCreate) -> s
|
|||||||
return vm.asdict()
|
return vm.asdict()
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get("/{node_id}", response_model=schemas.Qemu, dependencies=[Depends(compute_authentication)])
|
||||||
"/{node_id}",
|
|
||||||
response_model=schemas.Qemu,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
|
||||||
def get_qemu_node(node: QemuVM = Depends(dep_node)) -> schemas.Qemu:
|
def get_qemu_node(node: QemuVM = Depends(dep_node)) -> schemas.Qemu:
|
||||||
"""
|
"""
|
||||||
Return a Qemu node.
|
Return a Qemu node.
|
||||||
@ -130,11 +127,7 @@ def get_qemu_node(node: QemuVM = Depends(dep_node)) -> schemas.Qemu:
|
|||||||
return node.asdict()
|
return node.asdict()
|
||||||
|
|
||||||
|
|
||||||
@router.put(
|
@router.put("/{node_id}", response_model=schemas.Qemu, dependencies=[Depends(compute_authentication)])
|
||||||
"/{node_id}",
|
|
||||||
response_model=schemas.Qemu,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
|
||||||
async def update_qemu_node(node_data: schemas.QemuUpdate, node: QemuVM = Depends(dep_node)) -> schemas.Qemu:
|
async def update_qemu_node(node_data: schemas.QemuUpdate, node: QemuVM = Depends(dep_node)) -> schemas.Qemu:
|
||||||
"""
|
"""
|
||||||
Update a Qemu node.
|
Update a Qemu node.
|
||||||
@ -150,11 +143,7 @@ async def update_qemu_node(node_data: schemas.QemuUpdate, node: QemuVM = Depends
|
|||||||
return node.asdict()
|
return node.asdict()
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
@router.delete("/{node_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
|
||||||
"/{node_id}",
|
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
|
||||||
async def delete_qemu_node(node: QemuVM = Depends(dep_node)) -> None:
|
async def delete_qemu_node(node: QemuVM = Depends(dep_node)) -> None:
|
||||||
"""
|
"""
|
||||||
Delete a Qemu node.
|
Delete a Qemu node.
|
||||||
@ -167,11 +156,10 @@ async def delete_qemu_node(node: QemuVM = Depends(dep_node)) -> None:
|
|||||||
"/{node_id}/duplicate",
|
"/{node_id}/duplicate",
|
||||||
response_model=schemas.Qemu,
|
response_model=schemas.Qemu,
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
dependencies=[Depends(compute_authentication)]
|
dependencies=[Depends(compute_authentication)],
|
||||||
)
|
)
|
||||||
async def duplicate_qemu_node(
|
async def duplicate_qemu_node(
|
||||||
destination_node_id: UUID = Body(..., embed=True),
|
destination_node_id: UUID = Body(..., embed=True), node: QemuVM = Depends(dep_node)
|
||||||
node: QemuVM = Depends(dep_node)
|
|
||||||
) -> schemas.Qemu:
|
) -> schemas.Qemu:
|
||||||
"""
|
"""
|
||||||
Duplicate a Qemu node.
|
Duplicate a Qemu node.
|
||||||
@ -184,12 +172,10 @@ async def duplicate_qemu_node(
|
|||||||
@router.post(
|
@router.post(
|
||||||
"/{node_id}/disk_image/{disk_name}",
|
"/{node_id}/disk_image/{disk_name}",
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
dependencies=[Depends(compute_authentication)]
|
dependencies=[Depends(compute_authentication)],
|
||||||
)
|
)
|
||||||
async def create_qemu_disk_image(
|
async def create_qemu_disk_image(
|
||||||
disk_name: str,
|
disk_name: str, disk_data: schemas.QemuDiskImageCreate, node: QemuVM = Depends(dep_node)
|
||||||
disk_data: schemas.QemuDiskImageCreate,
|
|
||||||
node: QemuVM = Depends(dep_node)
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Create a Qemu disk image.
|
Create a Qemu disk image.
|
||||||
@ -202,12 +188,10 @@ async def create_qemu_disk_image(
|
|||||||
@router.put(
|
@router.put(
|
||||||
"/{node_id}/disk_image/{disk_name}",
|
"/{node_id}/disk_image/{disk_name}",
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
dependencies=[Depends(compute_authentication)]
|
dependencies=[Depends(compute_authentication)],
|
||||||
)
|
)
|
||||||
async def update_qemu_disk_image(
|
async def update_qemu_disk_image(
|
||||||
disk_name: str,
|
disk_name: str, disk_data: schemas.QemuDiskImageUpdate, node: QemuVM = Depends(dep_node)
|
||||||
disk_data: schemas.QemuDiskImageUpdate,
|
|
||||||
node: QemuVM = Depends(dep_node)
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Update a Qemu disk image.
|
Update a Qemu disk image.
|
||||||
@ -220,12 +204,9 @@ async def update_qemu_disk_image(
|
|||||||
@router.delete(
|
@router.delete(
|
||||||
"/{node_id}/disk_image/{disk_name}",
|
"/{node_id}/disk_image/{disk_name}",
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
dependencies=[Depends(compute_authentication)]
|
dependencies=[Depends(compute_authentication)],
|
||||||
)
|
)
|
||||||
async def delete_qemu_disk_image(
|
async def delete_qemu_disk_image(disk_name: str, node: QemuVM = Depends(dep_node)) -> None:
|
||||||
disk_name: str,
|
|
||||||
node: QemuVM = Depends(dep_node)
|
|
||||||
) -> None:
|
|
||||||
"""
|
"""
|
||||||
Delete a Qemu disk image.
|
Delete a Qemu disk image.
|
||||||
"""
|
"""
|
||||||
@ -233,11 +214,7 @@ async def delete_qemu_disk_image(
|
|||||||
node.delete_disk_image(disk_name)
|
node.delete_disk_image(disk_name)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post("/{node_id}/start", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
|
||||||
"/{node_id}/start",
|
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
|
||||||
async def start_qemu_node(node: QemuVM = Depends(dep_node)) -> None:
|
async def start_qemu_node(node: QemuVM = Depends(dep_node)) -> None:
|
||||||
"""
|
"""
|
||||||
Start a Qemu node.
|
Start a Qemu node.
|
||||||
@ -246,11 +223,7 @@ async def start_qemu_node(node: QemuVM = Depends(dep_node)) -> None:
|
|||||||
await node.start()
|
await node.start()
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post("/{node_id}/stop", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
|
||||||
"/{node_id}/stop",
|
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
|
||||||
async def stop_qemu_node(node: QemuVM = Depends(dep_node)) -> None:
|
async def stop_qemu_node(node: QemuVM = Depends(dep_node)) -> None:
|
||||||
"""
|
"""
|
||||||
Stop a Qemu node.
|
Stop a Qemu node.
|
||||||
@ -260,9 +233,7 @@ async def stop_qemu_node(node: QemuVM = Depends(dep_node)) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{node_id}/reload",
|
"/{node_id}/reload", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
)
|
||||||
async def reload_qemu_node(node: QemuVM = Depends(dep_node)) -> None:
|
async def reload_qemu_node(node: QemuVM = Depends(dep_node)) -> None:
|
||||||
"""
|
"""
|
||||||
@ -273,9 +244,7 @@ async def reload_qemu_node(node: QemuVM = Depends(dep_node)) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{node_id}/suspend",
|
"/{node_id}/suspend", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
)
|
||||||
async def suspend_qemu_node(node: QemuVM = Depends(dep_node)) -> None:
|
async def suspend_qemu_node(node: QemuVM = Depends(dep_node)) -> None:
|
||||||
"""
|
"""
|
||||||
@ -286,9 +255,7 @@ async def suspend_qemu_node(node: QemuVM = Depends(dep_node)) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{node_id}/resume",
|
"/{node_id}/resume", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
)
|
||||||
async def resume_qemu_node(node: QemuVM = Depends(dep_node)) -> None:
|
async def resume_qemu_node(node: QemuVM = Depends(dep_node)) -> None:
|
||||||
"""
|
"""
|
||||||
@ -302,14 +269,14 @@ async def resume_qemu_node(node: QemuVM = Depends(dep_node)) -> None:
|
|||||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
|
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
response_model=schemas.UDPNIO,
|
response_model=schemas.UDPNIO,
|
||||||
dependencies=[Depends(compute_authentication)]
|
dependencies=[Depends(compute_authentication)],
|
||||||
)
|
)
|
||||||
async def create_qemu_node_nio(
|
async def create_qemu_node_nio(
|
||||||
*,
|
*,
|
||||||
adapter_number: int,
|
adapter_number: int,
|
||||||
port_number: int = Path(..., ge=0, le=0),
|
port_number: int = Path(..., ge=0, le=0),
|
||||||
nio_data: schemas.UDPNIO,
|
nio_data: schemas.UDPNIO,
|
||||||
node: QemuVM = Depends(dep_node)
|
node: QemuVM = Depends(dep_node),
|
||||||
) -> schemas.UDPNIO:
|
) -> schemas.UDPNIO:
|
||||||
"""
|
"""
|
||||||
Add a NIO (Network Input/Output) to the node.
|
Add a NIO (Network Input/Output) to the node.
|
||||||
@ -325,14 +292,14 @@ async def create_qemu_node_nio(
|
|||||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
|
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
response_model=schemas.UDPNIO,
|
response_model=schemas.UDPNIO,
|
||||||
dependencies=[Depends(compute_authentication)]
|
dependencies=[Depends(compute_authentication)],
|
||||||
)
|
)
|
||||||
async def update_qemu_node_nio(
|
async def update_qemu_node_nio(
|
||||||
*,
|
*,
|
||||||
adapter_number: int,
|
adapter_number: int,
|
||||||
port_number: int = Path(..., ge=0, le=0),
|
port_number: int = Path(..., ge=0, le=0),
|
||||||
nio_data: schemas.UDPNIO,
|
nio_data: schemas.UDPNIO,
|
||||||
node: QemuVM = Depends(dep_node)
|
node: QemuVM = Depends(dep_node),
|
||||||
) -> schemas.UDPNIO:
|
) -> schemas.UDPNIO:
|
||||||
"""
|
"""
|
||||||
Update a NIO (Network Input/Output) on the node.
|
Update a NIO (Network Input/Output) on the node.
|
||||||
@ -352,12 +319,10 @@ async def update_qemu_node_nio(
|
|||||||
@router.delete(
|
@router.delete(
|
||||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
|
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
dependencies=[Depends(compute_authentication)]
|
dependencies=[Depends(compute_authentication)],
|
||||||
)
|
)
|
||||||
async def delete_qemu_node_nio(
|
async def delete_qemu_node_nio(
|
||||||
adapter_number: int,
|
adapter_number: int, port_number: int = Path(..., ge=0, le=0), node: QemuVM = Depends(dep_node)
|
||||||
port_number: int = Path(..., ge=0, le=0),
|
|
||||||
node: QemuVM = Depends(dep_node)
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Delete a NIO (Network Input/Output) from the node.
|
Delete a NIO (Network Input/Output) from the node.
|
||||||
@ -369,14 +334,14 @@ async def delete_qemu_node_nio(
|
|||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start",
|
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start",
|
||||||
dependencies=[Depends(compute_authentication)]
|
dependencies=[Depends(compute_authentication)],
|
||||||
)
|
)
|
||||||
async def start_qemu_node_capture(
|
async def start_qemu_node_capture(
|
||||||
*,
|
*,
|
||||||
adapter_number: int,
|
adapter_number: int,
|
||||||
port_number: int = Path(..., ge=0, le=0),
|
port_number: int = Path(..., ge=0, le=0),
|
||||||
node_capture_data: schemas.NodeCapture,
|
node_capture_data: schemas.NodeCapture,
|
||||||
node: QemuVM = Depends(dep_node)
|
node: QemuVM = Depends(dep_node),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Start a packet capture on the node.
|
Start a packet capture on the node.
|
||||||
@ -391,12 +356,10 @@ async def start_qemu_node_capture(
|
|||||||
@router.post(
|
@router.post(
|
||||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop",
|
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop",
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
dependencies=[Depends(compute_authentication)]
|
dependencies=[Depends(compute_authentication)],
|
||||||
)
|
)
|
||||||
async def stop_qemu_node_capture(
|
async def stop_qemu_node_capture(
|
||||||
adapter_number: int,
|
adapter_number: int, port_number: int = Path(..., ge=0, le=0), node: QemuVM = Depends(dep_node)
|
||||||
port_number: int = Path(..., ge=0, le=0),
|
|
||||||
node: QemuVM = Depends(dep_node)
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Stop a packet capture on the node.
|
Stop a packet capture on the node.
|
||||||
@ -408,12 +371,11 @@ async def stop_qemu_node_capture(
|
|||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream",
|
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream",
|
||||||
dependencies=[Depends(compute_authentication)]
|
dependencies=[Depends(compute_authentication)],
|
||||||
)
|
)
|
||||||
async def stream_pcap_file(
|
async def stream_pcap_file(
|
||||||
adapter_number: int,
|
adapter_number: int, port_number: int = Path(..., ge=0, le=0), node: QemuVM = Depends(dep_node)
|
||||||
port_number: int = Path(..., ge=0, le=0),
|
) -> StreamingResponse:
|
||||||
node: QemuVM = Depends(dep_node)) -> StreamingResponse:
|
|
||||||
"""
|
"""
|
||||||
Stream the pcap capture file.
|
Stream the pcap capture file.
|
||||||
The port number on the Qemu node is always 0.
|
The port number on the Qemu node is always 0.
|
||||||
@ -424,12 +386,9 @@ async def stream_pcap_file(
|
|||||||
return StreamingResponse(stream, media_type="application/vnd.tcpdump.pcap")
|
return StreamingResponse(stream, media_type="application/vnd.tcpdump.pcap")
|
||||||
|
|
||||||
|
|
||||||
@router.websocket(
|
@router.websocket("/{node_id}/console/ws")
|
||||||
"/{node_id}/console/ws"
|
|
||||||
)
|
|
||||||
async def console_ws(
|
async def console_ws(
|
||||||
websocket: Union[None, WebSocket] = Depends(ws_compute_authentication),
|
websocket: Union[None, WebSocket] = Depends(ws_compute_authentication), node: QemuVM = Depends(dep_node)
|
||||||
node: QemuVM = Depends(dep_node)
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Console WebSocket.
|
Console WebSocket.
|
||||||
@ -439,12 +398,9 @@ async def console_ws(
|
|||||||
await node.start_websocket_console(websocket)
|
await node.start_websocket_console(websocket)
|
||||||
|
|
||||||
|
|
||||||
@router.websocket(
|
@router.websocket("/{node_id}/console/vnc")
|
||||||
"/{node_id}/console/vnc"
|
|
||||||
)
|
|
||||||
async def vnc_console_ws(
|
async def vnc_console_ws(
|
||||||
websocket: Union[None, WebSocket] = Depends(ws_compute_authentication),
|
websocket: Union[None, WebSocket] = Depends(ws_compute_authentication), node: QemuVM = Depends(dep_node)
|
||||||
node: QemuVM = Depends(dep_node)
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
VNC Console WebSocket.
|
VNC Console WebSocket.
|
||||||
@ -455,23 +411,16 @@ async def vnc_console_ws(
|
|||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{node_id}/console/reset",
|
"/{node_id}/console/reset", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
)
|
||||||
async def reset_console(node: QemuVM = Depends(dep_node)) -> None:
|
async def reset_console(node: QemuVM = Depends(dep_node)) -> None:
|
||||||
|
|
||||||
await node.reset_console()
|
await node.reset_console()
|
||||||
|
|
||||||
|
|
||||||
@router.put(
|
@router.put("/{node_id}/markers/{marker_name}", dependencies=[Depends(compute_authentication)])
|
||||||
"/{node_id}/markers/{marker_name}",
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
|
||||||
async def toggle_qemu_marker(
|
async def toggle_qemu_marker(
|
||||||
marker_name: str,
|
marker_name: str, toggle_data: schemas.MarkerToggle, node: QemuVM = Depends(dep_node)
|
||||||
toggle_data: schemas.MarkerToggle,
|
|
||||||
node: QemuVM = Depends(dep_node)
|
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2).
|
Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2).
|
||||||
@ -487,9 +436,7 @@ async def toggle_qemu_marker(
|
|||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{node_id}/markers/pause",
|
"/{node_id}/markers/pause", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
)
|
||||||
async def pause_qemu_markers(node: QemuVM = Depends(dep_node)) -> None:
|
async def pause_qemu_markers(node: QemuVM = Depends(dep_node)) -> None:
|
||||||
|
|
||||||
@ -497,9 +444,7 @@ async def pause_qemu_markers(node: QemuVM = Depends(dep_node)) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{node_id}/markers/resume",
|
"/{node_id}/markers/resume", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
)
|
||||||
async def resume_qemu_markers(node: QemuVM = Depends(dep_node)) -> None:
|
async def resume_qemu_markers(node: QemuVM = Depends(dep_node)) -> None:
|
||||||
|
|
||||||
@ -509,14 +454,14 @@ async def resume_qemu_markers(node: QemuVM = Depends(dep_node)) -> None:
|
|||||||
@router.delete(
|
@router.delete(
|
||||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/{marker_name}",
|
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/{marker_name}",
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
dependencies=[Depends(compute_authentication)]
|
dependencies=[Depends(compute_authentication)],
|
||||||
)
|
)
|
||||||
async def delete_qemu_marker_capture(
|
async def delete_qemu_marker_capture(
|
||||||
marker_name: str,
|
marker_name: str,
|
||||||
adapter_number: int,
|
adapter_number: int,
|
||||||
port_number: int = Path(..., ge=0, le=0),
|
port_number: int = Path(..., ge=0, le=0),
|
||||||
link_id: str = "",
|
link_id: str = "",
|
||||||
node: QemuVM = Depends(dep_node)
|
node: QemuVM = Depends(dep_node),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Delete a marker's capture pcap (called by the controller when the marker is
|
Delete a marker's capture pcap (called by the controller when the marker is
|
||||||
@ -529,14 +474,9 @@ async def delete_qemu_marker_capture(
|
|||||||
await node.delete_marker_capture(marker_name, link_id, nio)
|
await node.delete_marker_capture(marker_name, link_id, nio)
|
||||||
|
|
||||||
|
|
||||||
@router.put(
|
@router.put("/{node_id}/markers/{marker_name}/rebuild", dependencies=[Depends(compute_authentication)])
|
||||||
"/{node_id}/markers/{marker_name}/rebuild",
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
|
||||||
async def rebuild_qemu_marker(
|
async def rebuild_qemu_marker(
|
||||||
marker_name: str,
|
marker_name: str, rebuild_data: schemas.MarkerRebuild, node: QemuVM = Depends(dep_node)
|
||||||
rebuild_data: schemas.MarkerRebuild,
|
|
||||||
node: QemuVM = Depends(dep_node)
|
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Re-install a single marker filter with new BPF/tag/direction (delete + add,
|
Re-install a single marker filter with new BPF/tag/direction (delete + add,
|
||||||
@ -544,7 +484,11 @@ async def rebuild_qemu_marker(
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
await node.rebuild_marker_filter(
|
await node.rebuild_marker_filter(
|
||||||
marker_name, rebuild_data.link_id, rebuild_data.bpf,
|
marker_name,
|
||||||
rebuild_data.tag, rebuild_data.direction, rebuild_data.enabled,
|
rebuild_data.link_id,
|
||||||
|
rebuild_data.bpf,
|
||||||
|
rebuild_data.tag,
|
||||||
|
rebuild_data.direction,
|
||||||
|
rebuild_data.enabled,
|
||||||
)
|
)
|
||||||
return {"marker_name": marker_name}
|
return {"marker_name": marker_name}
|
||||||
|
|||||||
@ -53,7 +53,7 @@ def dep_node(project_id: UUID, node_id: UUID) -> VirtualBoxVM:
|
|||||||
response_model=schemas.VirtualBox,
|
response_model=schemas.VirtualBox,
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
responses={409: {"model": schemas.ErrorMessage, "description": "Could not create VirtualBox node"}},
|
responses={409: {"model": schemas.ErrorMessage, "description": "Could not create VirtualBox node"}},
|
||||||
dependencies=[Depends(compute_authentication)]
|
dependencies=[Depends(compute_authentication)],
|
||||||
)
|
)
|
||||||
async def create_virtualbox_node(project_id: UUID, node_data: schemas.VirtualBoxCreate) -> schemas.VirtualBox:
|
async def create_virtualbox_node(project_id: UUID, node_data: schemas.VirtualBoxCreate) -> schemas.VirtualBox:
|
||||||
"""
|
"""
|
||||||
@ -86,11 +86,7 @@ async def create_virtualbox_node(project_id: UUID, node_data: schemas.VirtualBox
|
|||||||
return vm.asdict()
|
return vm.asdict()
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get("/{node_id}", response_model=schemas.VirtualBox, dependencies=[Depends(compute_authentication)])
|
||||||
"/{node_id}",
|
|
||||||
response_model=schemas.VirtualBox,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
|
||||||
def get_virtualbox_node(node: VirtualBoxVM = Depends(dep_node)) -> schemas.VirtualBox:
|
def get_virtualbox_node(node: VirtualBoxVM = Depends(dep_node)) -> schemas.VirtualBox:
|
||||||
"""
|
"""
|
||||||
Return a VirtualBox node.
|
Return a VirtualBox node.
|
||||||
@ -99,14 +95,9 @@ def get_virtualbox_node(node: VirtualBoxVM = Depends(dep_node)) -> schemas.Virtu
|
|||||||
return node.asdict()
|
return node.asdict()
|
||||||
|
|
||||||
|
|
||||||
@router.put(
|
@router.put("/{node_id}", response_model=schemas.VirtualBox, dependencies=[Depends(compute_authentication)])
|
||||||
"/{node_id}",
|
|
||||||
response_model=schemas.VirtualBox,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
|
||||||
async def update_virtualbox_node(
|
async def update_virtualbox_node(
|
||||||
node_data: schemas.VirtualBoxUpdate,
|
node_data: schemas.VirtualBoxUpdate, node: VirtualBoxVM = Depends(dep_node)
|
||||||
node: VirtualBoxVM = Depends(dep_node)
|
|
||||||
) -> schemas.VirtualBox:
|
) -> schemas.VirtualBox:
|
||||||
"""
|
"""
|
||||||
Update a VirtualBox node.
|
Update a VirtualBox node.
|
||||||
@ -148,11 +139,7 @@ async def update_virtualbox_node(
|
|||||||
return node.asdict()
|
return node.asdict()
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
@router.delete("/{node_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
|
||||||
"/{node_id}",
|
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
|
||||||
async def delete_virtualbox_node(node: VirtualBoxVM = Depends(dep_node)) -> None:
|
async def delete_virtualbox_node(node: VirtualBoxVM = Depends(dep_node)) -> None:
|
||||||
"""
|
"""
|
||||||
Delete a VirtualBox node.
|
Delete a VirtualBox node.
|
||||||
@ -161,11 +148,7 @@ async def delete_virtualbox_node(node: VirtualBoxVM = Depends(dep_node)) -> None
|
|||||||
await VirtualBox.instance().delete_node(node.id)
|
await VirtualBox.instance().delete_node(node.id)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post("/{node_id}/start", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
|
||||||
"/{node_id}/start",
|
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
|
||||||
async def start_virtualbox_node(node: VirtualBoxVM = Depends(dep_node)) -> None:
|
async def start_virtualbox_node(node: VirtualBoxVM = Depends(dep_node)) -> None:
|
||||||
"""
|
"""
|
||||||
Start a VirtualBox node.
|
Start a VirtualBox node.
|
||||||
@ -174,11 +157,7 @@ async def start_virtualbox_node(node: VirtualBoxVM = Depends(dep_node)) -> None:
|
|||||||
await node.start()
|
await node.start()
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post("/{node_id}/stop", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
|
||||||
"/{node_id}/stop",
|
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
|
||||||
async def stop_virtualbox_node(node: VirtualBoxVM = Depends(dep_node)) -> None:
|
async def stop_virtualbox_node(node: VirtualBoxVM = Depends(dep_node)) -> None:
|
||||||
"""
|
"""
|
||||||
Stop a VirtualBox node.
|
Stop a VirtualBox node.
|
||||||
@ -188,9 +167,7 @@ async def stop_virtualbox_node(node: VirtualBoxVM = Depends(dep_node)) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{node_id}/suspend",
|
"/{node_id}/suspend", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
)
|
||||||
async def suspend_virtualbox_node(node: VirtualBoxVM = Depends(dep_node)) -> None:
|
async def suspend_virtualbox_node(node: VirtualBoxVM = Depends(dep_node)) -> None:
|
||||||
"""
|
"""
|
||||||
@ -201,9 +178,7 @@ async def suspend_virtualbox_node(node: VirtualBoxVM = Depends(dep_node)) -> Non
|
|||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{node_id}/resume",
|
"/{node_id}/resume", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
)
|
||||||
async def resume_virtualbox_node(node: VirtualBoxVM = Depends(dep_node)) -> None:
|
async def resume_virtualbox_node(node: VirtualBoxVM = Depends(dep_node)) -> None:
|
||||||
"""
|
"""
|
||||||
@ -214,9 +189,7 @@ async def resume_virtualbox_node(node: VirtualBoxVM = Depends(dep_node)) -> None
|
|||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{node_id}/reload",
|
"/{node_id}/reload", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
)
|
||||||
async def reload_virtualbox_node(node: VirtualBoxVM = Depends(dep_node)) -> None:
|
async def reload_virtualbox_node(node: VirtualBoxVM = Depends(dep_node)) -> None:
|
||||||
"""
|
"""
|
||||||
@ -230,14 +203,14 @@ async def reload_virtualbox_node(node: VirtualBoxVM = Depends(dep_node)) -> None
|
|||||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
|
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
response_model=schemas.UDPNIO,
|
response_model=schemas.UDPNIO,
|
||||||
dependencies=[Depends(compute_authentication)]
|
dependencies=[Depends(compute_authentication)],
|
||||||
)
|
)
|
||||||
async def create_virtualbox_node_nio(
|
async def create_virtualbox_node_nio(
|
||||||
*,
|
*,
|
||||||
adapter_number: int,
|
adapter_number: int,
|
||||||
port_number: int = Path(..., ge=0, le=0),
|
port_number: int = Path(..., ge=0, le=0),
|
||||||
nio_data: schemas.UDPNIO,
|
nio_data: schemas.UDPNIO,
|
||||||
node: VirtualBoxVM = Depends(dep_node)
|
node: VirtualBoxVM = Depends(dep_node),
|
||||||
) -> schemas.UDPNIO:
|
) -> schemas.UDPNIO:
|
||||||
"""
|
"""
|
||||||
Add a NIO (Network Input/Output) to the node.
|
Add a NIO (Network Input/Output) to the node.
|
||||||
@ -253,14 +226,14 @@ async def create_virtualbox_node_nio(
|
|||||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
|
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
response_model=schemas.UDPNIO,
|
response_model=schemas.UDPNIO,
|
||||||
dependencies=[Depends(compute_authentication)]
|
dependencies=[Depends(compute_authentication)],
|
||||||
)
|
)
|
||||||
async def update_virtualbox_node_nio(
|
async def update_virtualbox_node_nio(
|
||||||
*,
|
*,
|
||||||
adapter_number: int,
|
adapter_number: int,
|
||||||
port_number: int = Path(..., ge=0, le=0),
|
port_number: int = Path(..., ge=0, le=0),
|
||||||
nio_data: schemas.UDPNIO,
|
nio_data: schemas.UDPNIO,
|
||||||
node: VirtualBoxVM = Depends(dep_node)
|
node: VirtualBoxVM = Depends(dep_node),
|
||||||
) -> schemas.UDPNIO:
|
) -> schemas.UDPNIO:
|
||||||
"""
|
"""
|
||||||
Update a NIO (Network Input/Output) on the node.
|
Update a NIO (Network Input/Output) on the node.
|
||||||
@ -279,12 +252,10 @@ async def update_virtualbox_node_nio(
|
|||||||
@router.delete(
|
@router.delete(
|
||||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
|
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
dependencies=[Depends(compute_authentication)]
|
dependencies=[Depends(compute_authentication)],
|
||||||
)
|
)
|
||||||
async def delete_virtualbox_node_nio(
|
async def delete_virtualbox_node_nio(
|
||||||
adapter_number: int,
|
adapter_number: int, port_number: int = Path(..., ge=0, le=0), node: VirtualBoxVM = Depends(dep_node)
|
||||||
port_number: int = Path(..., ge=0, le=0),
|
|
||||||
node: VirtualBoxVM = Depends(dep_node)
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Delete a NIO (Network Input/Output) from the node.
|
Delete a NIO (Network Input/Output) from the node.
|
||||||
@ -296,14 +267,14 @@ async def delete_virtualbox_node_nio(
|
|||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start",
|
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start",
|
||||||
dependencies=[Depends(compute_authentication)]
|
dependencies=[Depends(compute_authentication)],
|
||||||
)
|
)
|
||||||
async def start_virtualbox_node_capture(
|
async def start_virtualbox_node_capture(
|
||||||
*,
|
*,
|
||||||
adapter_number: int,
|
adapter_number: int,
|
||||||
port_number: int = Path(..., ge=0, le=0),
|
port_number: int = Path(..., ge=0, le=0),
|
||||||
node_capture_data: schemas.NodeCapture,
|
node_capture_data: schemas.NodeCapture,
|
||||||
node: VirtualBoxVM = Depends(dep_node),
|
node: VirtualBoxVM = Depends(dep_node),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Start a packet capture on the node.
|
Start a packet capture on the node.
|
||||||
@ -318,12 +289,10 @@ async def start_virtualbox_node_capture(
|
|||||||
@router.post(
|
@router.post(
|
||||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop",
|
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop",
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
dependencies=[Depends(compute_authentication)]
|
dependencies=[Depends(compute_authentication)],
|
||||||
)
|
)
|
||||||
async def stop_virtualbox_node_capture(
|
async def stop_virtualbox_node_capture(
|
||||||
adapter_number: int,
|
adapter_number: int, port_number: int = Path(..., ge=0, le=0), node: VirtualBoxVM = Depends(dep_node)
|
||||||
port_number: int = Path(..., ge=0, le=0),
|
|
||||||
node: VirtualBoxVM = Depends(dep_node)
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Stop a packet capture on the node.
|
Stop a packet capture on the node.
|
||||||
@ -335,12 +304,10 @@ async def stop_virtualbox_node_capture(
|
|||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream",
|
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream",
|
||||||
dependencies=[Depends(compute_authentication)]
|
dependencies=[Depends(compute_authentication)],
|
||||||
)
|
)
|
||||||
async def stream_pcap_file(
|
async def stream_pcap_file(
|
||||||
adapter_number: int,
|
adapter_number: int, port_number: int = Path(..., ge=0, le=0), node: VirtualBoxVM = Depends(dep_node)
|
||||||
port_number: int = Path(..., ge=0, le=0),
|
|
||||||
node: VirtualBoxVM = Depends(dep_node)
|
|
||||||
) -> StreamingResponse:
|
) -> StreamingResponse:
|
||||||
"""
|
"""
|
||||||
Stream the pcap capture file.
|
Stream the pcap capture file.
|
||||||
@ -352,12 +319,9 @@ async def stream_pcap_file(
|
|||||||
return StreamingResponse(stream, media_type="application/vnd.tcpdump.pcap")
|
return StreamingResponse(stream, media_type="application/vnd.tcpdump.pcap")
|
||||||
|
|
||||||
|
|
||||||
@router.websocket(
|
@router.websocket("/{node_id}/console/ws")
|
||||||
"/{node_id}/console/ws"
|
|
||||||
)
|
|
||||||
async def console_ws(
|
async def console_ws(
|
||||||
websocket: Union[None, WebSocket] = Depends(ws_compute_authentication),
|
websocket: Union[None, WebSocket] = Depends(ws_compute_authentication), node: VirtualBoxVM = Depends(dep_node)
|
||||||
node: VirtualBoxVM = Depends(dep_node)
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Console WebSocket.
|
Console WebSocket.
|
||||||
@ -367,11 +331,8 @@ async def console_ws(
|
|||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{node_id}/console/reset",
|
"/{node_id}/console/reset", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
)
|
||||||
async def reset_console(node: VirtualBoxVM = Depends(dep_node)) -> None:
|
async def reset_console(node: VirtualBoxVM = Depends(dep_node)) -> None:
|
||||||
|
|
||||||
await node.reset_console()
|
await node.reset_console()
|
||||||
|
|
||||||
|
|||||||
@ -52,7 +52,7 @@ def dep_node(project_id: UUID, node_id: UUID) -> VMwareVM:
|
|||||||
response_model=schemas.VMware,
|
response_model=schemas.VMware,
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
responses={409: {"model": schemas.ErrorMessage, "description": "Could not create VMware node"}},
|
responses={409: {"model": schemas.ErrorMessage, "description": "Could not create VMware node"}},
|
||||||
dependencies=[Depends(compute_authentication)]
|
dependencies=[Depends(compute_authentication)],
|
||||||
)
|
)
|
||||||
async def create_vmware_node(project_id: UUID, node_data: schemas.VMwareCreate) -> schemas.VMware:
|
async def create_vmware_node(project_id: UUID, node_data: schemas.VMwareCreate) -> schemas.VMware:
|
||||||
"""
|
"""
|
||||||
@ -79,11 +79,7 @@ async def create_vmware_node(project_id: UUID, node_data: schemas.VMwareCreate)
|
|||||||
return vm.asdict()
|
return vm.asdict()
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get("/{node_id}", response_model=schemas.VMware, dependencies=[Depends(compute_authentication)])
|
||||||
"/{node_id}",
|
|
||||||
response_model=schemas.VMware,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
|
||||||
def get_vmware_node(node: VMwareVM = Depends(dep_node)) -> schemas.VMware:
|
def get_vmware_node(node: VMwareVM = Depends(dep_node)) -> schemas.VMware:
|
||||||
"""
|
"""
|
||||||
Return a VMware node.
|
Return a VMware node.
|
||||||
@ -92,11 +88,7 @@ def get_vmware_node(node: VMwareVM = Depends(dep_node)) -> schemas.VMware:
|
|||||||
return node.asdict()
|
return node.asdict()
|
||||||
|
|
||||||
|
|
||||||
@router.put(
|
@router.put("/{node_id}", response_model=schemas.VMware, dependencies=[Depends(compute_authentication)])
|
||||||
"/{node_id}",
|
|
||||||
response_model=schemas.VMware,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
|
||||||
async def update_vmware_node(node_data: schemas.VMwareUpdate, node: VMwareVM = Depends(dep_node)) -> schemas.VMware:
|
async def update_vmware_node(node_data: schemas.VMwareUpdate, node: VMwareVM = Depends(dep_node)) -> schemas.VMware:
|
||||||
"""
|
"""
|
||||||
Update a VMware node.
|
Update a VMware node.
|
||||||
@ -113,11 +105,7 @@ async def update_vmware_node(node_data: schemas.VMwareUpdate, node: VMwareVM = D
|
|||||||
return node.asdict()
|
return node.asdict()
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
@router.delete("/{node_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
|
||||||
"/{node_id}",
|
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
|
||||||
async def delete_vmware_node(node: VMwareVM = Depends(dep_node)) -> None:
|
async def delete_vmware_node(node: VMwareVM = Depends(dep_node)) -> None:
|
||||||
"""
|
"""
|
||||||
Delete a VMware node.
|
Delete a VMware node.
|
||||||
@ -126,11 +114,7 @@ async def delete_vmware_node(node: VMwareVM = Depends(dep_node)) -> None:
|
|||||||
await VMware.instance().delete_node(node.id)
|
await VMware.instance().delete_node(node.id)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post("/{node_id}/start", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
|
||||||
"/{node_id}/start",
|
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
|
||||||
async def start_vmware_node(node: VMwareVM = Depends(dep_node)) -> None:
|
async def start_vmware_node(node: VMwareVM = Depends(dep_node)) -> None:
|
||||||
"""
|
"""
|
||||||
Start a VMware node.
|
Start a VMware node.
|
||||||
@ -139,11 +123,7 @@ async def start_vmware_node(node: VMwareVM = Depends(dep_node)) -> None:
|
|||||||
await node.start()
|
await node.start()
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post("/{node_id}/stop", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
|
||||||
"/{node_id}/stop",
|
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
|
||||||
async def stop_vmware_node(node: VMwareVM = Depends(dep_node)) -> None:
|
async def stop_vmware_node(node: VMwareVM = Depends(dep_node)) -> None:
|
||||||
"""
|
"""
|
||||||
Stop a VMware node.
|
Stop a VMware node.
|
||||||
@ -153,9 +133,7 @@ async def stop_vmware_node(node: VMwareVM = Depends(dep_node)) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{node_id}/suspend",
|
"/{node_id}/suspend", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
)
|
||||||
async def suspend_vmware_node(node: VMwareVM = Depends(dep_node)) -> None:
|
async def suspend_vmware_node(node: VMwareVM = Depends(dep_node)) -> None:
|
||||||
"""
|
"""
|
||||||
@ -166,9 +144,7 @@ async def suspend_vmware_node(node: VMwareVM = Depends(dep_node)) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{node_id}/resume",
|
"/{node_id}/resume", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
)
|
||||||
async def resume_vmware_node(node: VMwareVM = Depends(dep_node)) -> None:
|
async def resume_vmware_node(node: VMwareVM = Depends(dep_node)) -> None:
|
||||||
"""
|
"""
|
||||||
@ -179,9 +155,7 @@ async def resume_vmware_node(node: VMwareVM = Depends(dep_node)) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{node_id}/reload",
|
"/{node_id}/reload", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
)
|
||||||
async def reload_vmware_node(node: VMwareVM = Depends(dep_node)) -> None:
|
async def reload_vmware_node(node: VMwareVM = Depends(dep_node)) -> None:
|
||||||
"""
|
"""
|
||||||
@ -195,14 +169,14 @@ async def reload_vmware_node(node: VMwareVM = Depends(dep_node)) -> None:
|
|||||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
|
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
response_model=schemas.UDPNIO,
|
response_model=schemas.UDPNIO,
|
||||||
dependencies=[Depends(compute_authentication)]
|
dependencies=[Depends(compute_authentication)],
|
||||||
)
|
)
|
||||||
async def create_vmware_node_nio(
|
async def create_vmware_node_nio(
|
||||||
*,
|
*,
|
||||||
adapter_number: int,
|
adapter_number: int,
|
||||||
port_number: int = Path(..., ge=0, le=0),
|
port_number: int = Path(..., ge=0, le=0),
|
||||||
nio_data: schemas.UDPNIO,
|
nio_data: schemas.UDPNIO,
|
||||||
node: VMwareVM = Depends(dep_node)
|
node: VMwareVM = Depends(dep_node),
|
||||||
) -> schemas.UDPNIO:
|
) -> schemas.UDPNIO:
|
||||||
"""
|
"""
|
||||||
Add a NIO (Network Input/Output) to the node.
|
Add a NIO (Network Input/Output) to the node.
|
||||||
@ -218,14 +192,14 @@ async def create_vmware_node_nio(
|
|||||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
|
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
response_model=schemas.UDPNIO,
|
response_model=schemas.UDPNIO,
|
||||||
dependencies=[Depends(compute_authentication)]
|
dependencies=[Depends(compute_authentication)],
|
||||||
)
|
)
|
||||||
async def update_vmware_node_nio(
|
async def update_vmware_node_nio(
|
||||||
*,
|
*,
|
||||||
adapter_number: int,
|
adapter_number: int,
|
||||||
port_number: int = Path(..., ge=0, le=0),
|
port_number: int = Path(..., ge=0, le=0),
|
||||||
nio_data: schemas.UDPNIO,
|
nio_data: schemas.UDPNIO,
|
||||||
node: VMwareVM = Depends(dep_node)
|
node: VMwareVM = Depends(dep_node),
|
||||||
) -> schemas.UDPNIO:
|
) -> schemas.UDPNIO:
|
||||||
"""
|
"""
|
||||||
Update a NIO (Network Input/Output) on the node.
|
Update a NIO (Network Input/Output) on the node.
|
||||||
@ -243,12 +217,10 @@ async def update_vmware_node_nio(
|
|||||||
@router.delete(
|
@router.delete(
|
||||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
|
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
dependencies=[Depends(compute_authentication)]
|
dependencies=[Depends(compute_authentication)],
|
||||||
)
|
)
|
||||||
async def delete_vmware_node_nio(
|
async def delete_vmware_node_nio(
|
||||||
adapter_number: int,
|
adapter_number: int, port_number: int = Path(..., ge=0, le=0), node: VMwareVM = Depends(dep_node)
|
||||||
port_number: int = Path(..., ge=0, le=0),
|
|
||||||
node: VMwareVM = Depends(dep_node)
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Delete a NIO (Network Input/Output) from the node.
|
Delete a NIO (Network Input/Output) from the node.
|
||||||
@ -260,14 +232,14 @@ async def delete_vmware_node_nio(
|
|||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start",
|
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start",
|
||||||
dependencies=[Depends(compute_authentication)]
|
dependencies=[Depends(compute_authentication)],
|
||||||
)
|
)
|
||||||
async def start_vmware_node_capture(
|
async def start_vmware_node_capture(
|
||||||
*,
|
*,
|
||||||
adapter_number: int,
|
adapter_number: int,
|
||||||
port_number: int = Path(..., ge=0, le=0),
|
port_number: int = Path(..., ge=0, le=0),
|
||||||
node_capture_data: schemas.NodeCapture,
|
node_capture_data: schemas.NodeCapture,
|
||||||
node: VMwareVM = Depends(dep_node)
|
node: VMwareVM = Depends(dep_node),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Start a packet capture on the node.
|
Start a packet capture on the node.
|
||||||
@ -282,12 +254,10 @@ async def start_vmware_node_capture(
|
|||||||
@router.post(
|
@router.post(
|
||||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop",
|
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop",
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
dependencies=[Depends(compute_authentication)]
|
dependencies=[Depends(compute_authentication)],
|
||||||
)
|
)
|
||||||
async def stop_vmware_node_capture(
|
async def stop_vmware_node_capture(
|
||||||
adapter_number: int,
|
adapter_number: int, port_number: int = Path(..., ge=0, le=0), node: VMwareVM = Depends(dep_node)
|
||||||
port_number: int = Path(..., ge=0, le=0),
|
|
||||||
node: VMwareVM = Depends(dep_node)
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Stop a packet capture on the node.
|
Stop a packet capture on the node.
|
||||||
@ -299,12 +269,10 @@ async def stop_vmware_node_capture(
|
|||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream",
|
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream",
|
||||||
dependencies=[Depends(compute_authentication)]
|
dependencies=[Depends(compute_authentication)],
|
||||||
)
|
)
|
||||||
async def stream_pcap_file(
|
async def stream_pcap_file(
|
||||||
adapter_number: int,
|
adapter_number: int, port_number: int = Path(..., ge=0, le=0), node: VMwareVM = Depends(dep_node)
|
||||||
port_number: int = Path(..., ge=0, le=0),
|
|
||||||
node: VMwareVM = Depends(dep_node)
|
|
||||||
) -> StreamingResponse:
|
) -> StreamingResponse:
|
||||||
"""
|
"""
|
||||||
Stream the pcap capture file.
|
Stream the pcap capture file.
|
||||||
@ -317,9 +285,7 @@ async def stream_pcap_file(
|
|||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{node_id}/interfaces/vmnet",
|
"/{node_id}/interfaces/vmnet", status_code=status.HTTP_201_CREATED, dependencies=[Depends(compute_authentication)]
|
||||||
status_code=status.HTTP_201_CREATED,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
)
|
||||||
def allocate_vmnet(node: VMwareVM = Depends(dep_node)) -> dict:
|
def allocate_vmnet(node: VMwareVM = Depends(dep_node)) -> dict:
|
||||||
"""
|
"""
|
||||||
@ -335,8 +301,7 @@ def allocate_vmnet(node: VMwareVM = Depends(dep_node)) -> dict:
|
|||||||
|
|
||||||
@router.websocket("/{node_id}/console/ws")
|
@router.websocket("/{node_id}/console/ws")
|
||||||
async def console_ws(
|
async def console_ws(
|
||||||
websocket: Union[None, WebSocket] = Depends(ws_compute_authentication),
|
websocket: Union[None, WebSocket] = Depends(ws_compute_authentication), node: VMwareVM = Depends(dep_node)
|
||||||
node: VMwareVM = Depends(dep_node)
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Console WebSocket.
|
Console WebSocket.
|
||||||
@ -347,9 +312,7 @@ async def console_ws(
|
|||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{node_id}/console/reset",
|
"/{node_id}/console/reset", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
)
|
||||||
async def reset_console(node: VMwareVM = Depends(dep_node)) -> None:
|
async def reset_console(node: VMwareVM = Depends(dep_node)) -> None:
|
||||||
|
|
||||||
|
|||||||
@ -51,7 +51,7 @@ def dep_node(project_id: UUID, node_id: UUID) -> VPCSVM:
|
|||||||
response_model=schemas.VPCS,
|
response_model=schemas.VPCS,
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
responses={409: {"model": schemas.ErrorMessage, "description": "Could not create VMware node"}},
|
responses={409: {"model": schemas.ErrorMessage, "description": "Could not create VMware node"}},
|
||||||
dependencies=[Depends(compute_authentication)]
|
dependencies=[Depends(compute_authentication)],
|
||||||
)
|
)
|
||||||
async def create_vpcs_node(project_id: UUID, node_data: schemas.VPCSCreate) -> schemas.VPCS:
|
async def create_vpcs_node(project_id: UUID, node_data: schemas.VPCSCreate) -> schemas.VPCS:
|
||||||
"""
|
"""
|
||||||
@ -72,11 +72,7 @@ async def create_vpcs_node(project_id: UUID, node_data: schemas.VPCSCreate) -> s
|
|||||||
return vm.asdict()
|
return vm.asdict()
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get("/{node_id}", response_model=schemas.VPCS, dependencies=[Depends(compute_authentication)])
|
||||||
"/{node_id}",
|
|
||||||
response_model=schemas.VPCS,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
|
||||||
def get_vpcs_node(node: VPCSVM = Depends(dep_node)) -> schemas.VPCS:
|
def get_vpcs_node(node: VPCSVM = Depends(dep_node)) -> schemas.VPCS:
|
||||||
"""
|
"""
|
||||||
Return a VPCS node.
|
Return a VPCS node.
|
||||||
@ -85,11 +81,7 @@ def get_vpcs_node(node: VPCSVM = Depends(dep_node)) -> schemas.VPCS:
|
|||||||
return node.asdict()
|
return node.asdict()
|
||||||
|
|
||||||
|
|
||||||
@router.put(
|
@router.put("/{node_id}", response_model=schemas.VPCS, dependencies=[Depends(compute_authentication)])
|
||||||
"/{node_id}",
|
|
||||||
response_model=schemas.VPCS,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
|
||||||
async def update_vpcs_node(node_data: schemas.VPCSUpdate, node: VPCSVM = Depends(dep_node)) -> schemas.VPCS:
|
async def update_vpcs_node(node_data: schemas.VPCSUpdate, node: VPCSVM = Depends(dep_node)) -> schemas.VPCS:
|
||||||
"""
|
"""
|
||||||
Update a VPCS node.
|
Update a VPCS node.
|
||||||
@ -109,11 +101,7 @@ async def update_vpcs_node(node_data: schemas.VPCSUpdate, node: VPCSVM = Depends
|
|||||||
return node.asdict()
|
return node.asdict()
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
@router.delete("/{node_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
|
||||||
"/{node_id}",
|
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
|
||||||
async def delete_vpcs_node(node: VPCSVM = Depends(dep_node)) -> None:
|
async def delete_vpcs_node(node: VPCSVM = Depends(dep_node)) -> None:
|
||||||
"""
|
"""
|
||||||
Delete a VPCS node.
|
Delete a VPCS node.
|
||||||
@ -126,11 +114,11 @@ async def delete_vpcs_node(node: VPCSVM = Depends(dep_node)) -> None:
|
|||||||
"/{node_id}/duplicate",
|
"/{node_id}/duplicate",
|
||||||
response_model=schemas.VPCS,
|
response_model=schemas.VPCS,
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
dependencies=[Depends(compute_authentication)]
|
dependencies=[Depends(compute_authentication)],
|
||||||
)
|
)
|
||||||
async def duplicate_vpcs_node(
|
async def duplicate_vpcs_node(
|
||||||
destination_node_id: UUID = Body(..., embed=True),
|
destination_node_id: UUID = Body(..., embed=True), node: VPCSVM = Depends(dep_node)
|
||||||
node: VPCSVM = Depends(dep_node)) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Duplicate a VPCS node.
|
Duplicate a VPCS node.
|
||||||
"""
|
"""
|
||||||
@ -139,11 +127,7 @@ async def duplicate_vpcs_node(
|
|||||||
return new_node.asdict()
|
return new_node.asdict()
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post("/{node_id}/start", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
|
||||||
"/{node_id}/start",
|
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
|
||||||
async def start_vpcs_node(node: VPCSVM = Depends(dep_node)) -> None:
|
async def start_vpcs_node(node: VPCSVM = Depends(dep_node)) -> None:
|
||||||
"""
|
"""
|
||||||
Start a VPCS node.
|
Start a VPCS node.
|
||||||
@ -152,11 +136,7 @@ async def start_vpcs_node(node: VPCSVM = Depends(dep_node)) -> None:
|
|||||||
await node.start()
|
await node.start()
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post("/{node_id}/stop", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
|
||||||
"/{node_id}/stop",
|
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
|
||||||
async def stop_vpcs_node(node: VPCSVM = Depends(dep_node)) -> None:
|
async def stop_vpcs_node(node: VPCSVM = Depends(dep_node)) -> None:
|
||||||
"""
|
"""
|
||||||
Stop a VPCS node.
|
Stop a VPCS node.
|
||||||
@ -166,9 +146,7 @@ async def stop_vpcs_node(node: VPCSVM = Depends(dep_node)) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{node_id}/suspend",
|
"/{node_id}/suspend", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
)
|
||||||
async def suspend_vpcs_node(node: VPCSVM = Depends(dep_node)) -> None:
|
async def suspend_vpcs_node(node: VPCSVM = Depends(dep_node)) -> None:
|
||||||
"""
|
"""
|
||||||
@ -176,15 +154,12 @@ async def suspend_vpcs_node(node: VPCSVM = Depends(dep_node)) -> None:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
|
status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Suspend is not supported for VPCS nodes"
|
||||||
detail="Suspend is not supported for VPCS nodes"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{node_id}/reload",
|
"/{node_id}/reload", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
)
|
||||||
async def reload_vpcs_node(node: VPCSVM = Depends(dep_node)) -> None:
|
async def reload_vpcs_node(node: VPCSVM = Depends(dep_node)) -> None:
|
||||||
"""
|
"""
|
||||||
@ -198,14 +173,14 @@ async def reload_vpcs_node(node: VPCSVM = Depends(dep_node)) -> None:
|
|||||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
|
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
response_model=schemas.UDPNIO,
|
response_model=schemas.UDPNIO,
|
||||||
dependencies=[Depends(compute_authentication)]
|
dependencies=[Depends(compute_authentication)],
|
||||||
)
|
)
|
||||||
async def create_vpcs_node_nio(
|
async def create_vpcs_node_nio(
|
||||||
*,
|
*,
|
||||||
adapter_number: int = Path(..., ge=0, le=0),
|
adapter_number: int = Path(..., ge=0, le=0),
|
||||||
port_number: int,
|
port_number: int,
|
||||||
nio_data: schemas.UDPNIO,
|
nio_data: schemas.UDPNIO,
|
||||||
node: VPCSVM = Depends(dep_node)
|
node: VPCSVM = Depends(dep_node),
|
||||||
) -> schemas.UDPNIO:
|
) -> schemas.UDPNIO:
|
||||||
"""
|
"""
|
||||||
Add a NIO (Network Input/Output) to the node.
|
Add a NIO (Network Input/Output) to the node.
|
||||||
@ -221,14 +196,14 @@ async def create_vpcs_node_nio(
|
|||||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
|
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
response_model=schemas.UDPNIO,
|
response_model=schemas.UDPNIO,
|
||||||
dependencies=[Depends(compute_authentication)]
|
dependencies=[Depends(compute_authentication)],
|
||||||
)
|
)
|
||||||
async def update_vpcs_node_nio(
|
async def update_vpcs_node_nio(
|
||||||
*,
|
*,
|
||||||
adapter_number: int = Path(..., ge=0, le=0),
|
adapter_number: int = Path(..., ge=0, le=0),
|
||||||
port_number: int,
|
port_number: int,
|
||||||
nio_data: schemas.UDPNIO,
|
nio_data: schemas.UDPNIO,
|
||||||
node: VPCSVM = Depends(dep_node)
|
node: VPCSVM = Depends(dep_node),
|
||||||
) -> schemas.UDPNIO:
|
) -> schemas.UDPNIO:
|
||||||
"""
|
"""
|
||||||
Update a NIO (Network Input/Output) on the node.
|
Update a NIO (Network Input/Output) on the node.
|
||||||
@ -247,13 +222,10 @@ async def update_vpcs_node_nio(
|
|||||||
@router.delete(
|
@router.delete(
|
||||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
|
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
dependencies=[Depends(compute_authentication)]
|
dependencies=[Depends(compute_authentication)],
|
||||||
)
|
)
|
||||||
async def delete_vpcs_node_nio(
|
async def delete_vpcs_node_nio(
|
||||||
*,
|
*, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: VPCSVM = Depends(dep_node)
|
||||||
adapter_number: int = Path(..., ge=0, le=0),
|
|
||||||
port_number: int,
|
|
||||||
node: VPCSVM = Depends(dep_node)
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Delete a NIO (Network Input/Output) from the node.
|
Delete a NIO (Network Input/Output) from the node.
|
||||||
@ -265,14 +237,14 @@ async def delete_vpcs_node_nio(
|
|||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start",
|
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start",
|
||||||
dependencies=[Depends(compute_authentication)]
|
dependencies=[Depends(compute_authentication)],
|
||||||
)
|
)
|
||||||
async def start_vpcs_node_capture(
|
async def start_vpcs_node_capture(
|
||||||
*,
|
*,
|
||||||
adapter_number: int = Path(..., ge=0, le=0),
|
adapter_number: int = Path(..., ge=0, le=0),
|
||||||
port_number: int,
|
port_number: int,
|
||||||
node_capture_data: schemas.NodeCapture,
|
node_capture_data: schemas.NodeCapture,
|
||||||
node: VPCSVM = Depends(dep_node)
|
node: VPCSVM = Depends(dep_node),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Start a packet capture on the node.
|
Start a packet capture on the node.
|
||||||
@ -287,13 +259,10 @@ async def start_vpcs_node_capture(
|
|||||||
@router.post(
|
@router.post(
|
||||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop",
|
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop",
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
dependencies=[Depends(compute_authentication)]
|
dependencies=[Depends(compute_authentication)],
|
||||||
)
|
)
|
||||||
async def stop_vpcs_node_capture(
|
async def stop_vpcs_node_capture(
|
||||||
*,
|
*, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: VPCSVM = Depends(dep_node)
|
||||||
adapter_number: int = Path(..., ge=0, le=0),
|
|
||||||
port_number: int,
|
|
||||||
node: VPCSVM = Depends(dep_node)
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Stop a packet capture on the node.
|
Stop a packet capture on the node.
|
||||||
@ -303,16 +272,12 @@ async def stop_vpcs_node_capture(
|
|||||||
await node.stop_capture(port_number)
|
await node.stop_capture(port_number)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream",
|
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream",
|
||||||
dependencies=[Depends(compute_authentication)]
|
dependencies=[Depends(compute_authentication)],
|
||||||
)
|
)
|
||||||
async def stream_pcap_file(
|
async def stream_pcap_file(
|
||||||
*,
|
*, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: VPCSVM = Depends(dep_node)
|
||||||
adapter_number: int = Path(..., ge=0, le=0),
|
|
||||||
port_number: int,
|
|
||||||
node: VPCSVM = Depends(dep_node)
|
|
||||||
) -> StreamingResponse:
|
) -> StreamingResponse:
|
||||||
"""
|
"""
|
||||||
Stream the pcap capture file.
|
Stream the pcap capture file.
|
||||||
@ -324,12 +289,10 @@ async def stream_pcap_file(
|
|||||||
return StreamingResponse(stream, media_type="application/vnd.tcpdump.pcap")
|
return StreamingResponse(stream, media_type="application/vnd.tcpdump.pcap")
|
||||||
|
|
||||||
|
|
||||||
@router.websocket(
|
@router.websocket("/{node_id}/console/ws")
|
||||||
"/{node_id}/console/ws"
|
|
||||||
)
|
|
||||||
async def console_ws(
|
async def console_ws(
|
||||||
websocket: Union[None, WebSocket] = Depends(ws_compute_authentication),
|
websocket: Union[None, WebSocket] = Depends(ws_compute_authentication), node: VPCSVM = Depends(dep_node)
|
||||||
node: VPCSVM = Depends(dep_node)) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Console WebSocket.
|
Console WebSocket.
|
||||||
"""
|
"""
|
||||||
@ -338,23 +301,16 @@ async def console_ws(
|
|||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{node_id}/console/reset",
|
"/{node_id}/console/reset", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
)
|
||||||
async def reset_console(node: VPCSVM = Depends(dep_node)) -> None:
|
async def reset_console(node: VPCSVM = Depends(dep_node)) -> None:
|
||||||
|
|
||||||
await node.reset_console()
|
await node.reset_console()
|
||||||
|
|
||||||
|
|
||||||
@router.put(
|
@router.put("/{node_id}/markers/{marker_name}", dependencies=[Depends(compute_authentication)])
|
||||||
"/{node_id}/markers/{marker_name}",
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
|
||||||
async def toggle_vpcs_marker(
|
async def toggle_vpcs_marker(
|
||||||
marker_name: str,
|
marker_name: str, toggle_data: schemas.MarkerToggle, node: VPCSVM = Depends(dep_node)
|
||||||
toggle_data: schemas.MarkerToggle,
|
|
||||||
node: VPCSVM = Depends(dep_node)
|
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2).
|
Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2).
|
||||||
@ -370,9 +326,7 @@ async def toggle_vpcs_marker(
|
|||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{node_id}/markers/pause",
|
"/{node_id}/markers/pause", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
)
|
||||||
async def pause_vpcs_markers(node: VPCSVM = Depends(dep_node)) -> None:
|
async def pause_vpcs_markers(node: VPCSVM = Depends(dep_node)) -> None:
|
||||||
|
|
||||||
@ -380,9 +334,7 @@ async def pause_vpcs_markers(node: VPCSVM = Depends(dep_node)) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{node_id}/markers/resume",
|
"/{node_id}/markers/resume", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
)
|
||||||
async def resume_vpcs_markers(node: VPCSVM = Depends(dep_node)) -> None:
|
async def resume_vpcs_markers(node: VPCSVM = Depends(dep_node)) -> None:
|
||||||
|
|
||||||
@ -392,7 +344,7 @@ async def resume_vpcs_markers(node: VPCSVM = Depends(dep_node)) -> None:
|
|||||||
@router.delete(
|
@router.delete(
|
||||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/{marker_name}",
|
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/{marker_name}",
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
dependencies=[Depends(compute_authentication)]
|
dependencies=[Depends(compute_authentication)],
|
||||||
)
|
)
|
||||||
async def delete_vpcs_marker_capture(
|
async def delete_vpcs_marker_capture(
|
||||||
*,
|
*,
|
||||||
@ -400,7 +352,7 @@ async def delete_vpcs_marker_capture(
|
|||||||
adapter_number: int = Path(..., ge=0, le=0),
|
adapter_number: int = Path(..., ge=0, le=0),
|
||||||
port_number: int,
|
port_number: int,
|
||||||
link_id: str = "",
|
link_id: str = "",
|
||||||
node: VPCSVM = Depends(dep_node)
|
node: VPCSVM = Depends(dep_node),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Delete a marker's capture pcap (called by the controller when the marker is
|
Delete a marker's capture pcap (called by the controller when the marker is
|
||||||
@ -413,14 +365,9 @@ async def delete_vpcs_marker_capture(
|
|||||||
await node.delete_marker_capture(marker_name, link_id, nio)
|
await node.delete_marker_capture(marker_name, link_id, nio)
|
||||||
|
|
||||||
|
|
||||||
@router.put(
|
@router.put("/{node_id}/markers/{marker_name}/rebuild", dependencies=[Depends(compute_authentication)])
|
||||||
"/{node_id}/markers/{marker_name}/rebuild",
|
|
||||||
dependencies=[Depends(compute_authentication)]
|
|
||||||
)
|
|
||||||
async def rebuild_vpcs_marker(
|
async def rebuild_vpcs_marker(
|
||||||
marker_name: str,
|
marker_name: str, rebuild_data: schemas.MarkerRebuild, node: VPCSVM = Depends(dep_node)
|
||||||
rebuild_data: schemas.MarkerRebuild,
|
|
||||||
node: VPCSVM = Depends(dep_node)
|
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Re-install a single marker filter with new BPF/tag/direction (delete + add,
|
Re-install a single marker filter with new BPF/tag/direction (delete + add,
|
||||||
@ -428,7 +375,11 @@ async def rebuild_vpcs_marker(
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
await node.rebuild_marker_filter(
|
await node.rebuild_marker_filter(
|
||||||
marker_name, rebuild_data.link_id, rebuild_data.bpf,
|
marker_name,
|
||||||
rebuild_data.tag, rebuild_data.direction, rebuild_data.enabled,
|
rebuild_data.link_id,
|
||||||
|
rebuild_data.bpf,
|
||||||
|
rebuild_data.tag,
|
||||||
|
rebuild_data.direction,
|
||||||
|
rebuild_data.enabled,
|
||||||
)
|
)
|
||||||
return {"marker_name": marker_name}
|
return {"marker_name": marker_name}
|
||||||
|
|||||||
@ -24,6 +24,7 @@ if AI_COPILOT_AVAILABLE:
|
|||||||
from . import chat
|
from . import chat
|
||||||
from . import copilot
|
from . import copilot
|
||||||
from . import llm_model_configs
|
from . import llm_model_configs
|
||||||
|
|
||||||
_chat_router = chat.router
|
_chat_router = chat.router
|
||||||
_copilot_router = copilot.router
|
_copilot_router = copilot.router
|
||||||
_llm_router = llm_model_configs.router
|
_llm_router = llm_model_configs.router
|
||||||
@ -39,9 +40,10 @@ else:
|
|||||||
async def ai_not_available(path: str = ""):
|
async def ai_not_available(path: str = ""):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
||||||
detail="AI Copilot is not available. Install AI dependencies with: pip install gns3-server[ai-features]"
|
detail="AI Copilot is not available. Install AI dependencies with: pip install gns3-server[ai-features]",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
from . import controller
|
from . import controller
|
||||||
from . import appliances
|
from . import appliances
|
||||||
from . import computes
|
from . import computes
|
||||||
@ -68,148 +70,65 @@ from .dependencies.authentication import get_current_active_user
|
|||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
router.include_router(controller.router, tags=["Controller"])
|
||||||
|
|
||||||
|
router.include_router(settings.router, prefix="/settings", tags=["Server settings"])
|
||||||
|
|
||||||
|
router.include_router(users.router, prefix="/access/users", tags=["Users"])
|
||||||
|
|
||||||
|
router.include_router(groups.router, prefix="/access/groups", tags=["Users groups"])
|
||||||
|
|
||||||
|
router.include_router(roles.router, prefix="/access/roles", tags=["Roles"])
|
||||||
|
|
||||||
router.include_router(
|
router.include_router(
|
||||||
controller.router,
|
privileges.router, dependencies=[Depends(get_current_active_user)], prefix="/access/privileges", tags=["Privileges"]
|
||||||
tags=["Controller"]
|
)
|
||||||
|
|
||||||
|
router.include_router(acl.router, prefix="/access/acl", tags=["ACL"])
|
||||||
|
|
||||||
|
router.include_router(images.router, prefix="/images", tags=["Images"])
|
||||||
|
|
||||||
|
router.include_router(templates.router, prefix="/templates", tags=["Templates"])
|
||||||
|
|
||||||
|
router.include_router(projects.router, prefix="/projects", tags=["Projects"])
|
||||||
|
|
||||||
|
router.include_router(nodes.router, prefix="/projects/{project_id}/nodes", tags=["Nodes"])
|
||||||
|
|
||||||
|
router.include_router(links.router, prefix="/projects/{project_id}/links", tags=["Links"])
|
||||||
|
|
||||||
|
router.include_router(drawings.router, prefix="/projects/{project_id}/drawings", tags=["Drawings"])
|
||||||
|
|
||||||
|
router.include_router(symbols.router, prefix="/symbols", tags=["Symbols"])
|
||||||
|
|
||||||
|
router.include_router(snapshots.router, prefix="/projects/{project_id}/snapshots", tags=["Snapshots"])
|
||||||
|
|
||||||
|
router.include_router(
|
||||||
|
computes.router, dependencies=[Depends(get_current_active_user)], prefix="/computes", tags=["Computes"]
|
||||||
|
)
|
||||||
|
|
||||||
|
router.include_router(appliances.router, prefix="/appliances", tags=["Appliances"])
|
||||||
|
|
||||||
|
router.include_router(netmiko.router, prefix="/netmiko", tags=["Netmiko"])
|
||||||
|
|
||||||
|
router.include_router(pools.router, prefix="/pools", tags=["Resource pools"])
|
||||||
|
|
||||||
|
router.include_router(
|
||||||
|
gns3vm.router, dependencies=[Depends(get_current_active_user)], deprecated=True, prefix="/gns3vm", tags=["GNS3 VM"]
|
||||||
)
|
)
|
||||||
|
|
||||||
router.include_router(
|
router.include_router(
|
||||||
settings.router,
|
_llm_router, prefix="/access", dependencies=[Depends(get_current_active_user)], tags=["LLM Model Configurations"]
|
||||||
prefix="/settings",
|
|
||||||
tags=["Server settings"]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
router.include_router(
|
router.include_router(
|
||||||
users.router,
|
_copilot_router, prefix="/copilot", dependencies=[Depends(get_current_active_user)], tags=["GNS3 Copilot"]
|
||||||
prefix="/access/users",
|
|
||||||
tags=["Users"]
|
|
||||||
)
|
|
||||||
|
|
||||||
router.include_router(
|
|
||||||
groups.router,
|
|
||||||
prefix="/access/groups",
|
|
||||||
tags=["Users groups"]
|
|
||||||
)
|
|
||||||
|
|
||||||
router.include_router(
|
|
||||||
roles.router,
|
|
||||||
prefix="/access/roles",
|
|
||||||
tags=["Roles"]
|
|
||||||
)
|
|
||||||
|
|
||||||
router.include_router(
|
|
||||||
privileges.router,
|
|
||||||
dependencies=[Depends(get_current_active_user)],
|
|
||||||
prefix="/access/privileges",
|
|
||||||
tags=["Privileges"]
|
|
||||||
)
|
|
||||||
|
|
||||||
router.include_router(
|
|
||||||
acl.router,
|
|
||||||
prefix="/access/acl",
|
|
||||||
tags=["ACL"]
|
|
||||||
)
|
|
||||||
|
|
||||||
router.include_router(
|
|
||||||
images.router,
|
|
||||||
prefix="/images",
|
|
||||||
tags=["Images"]
|
|
||||||
)
|
|
||||||
|
|
||||||
router.include_router(
|
|
||||||
templates.router,
|
|
||||||
prefix="/templates",
|
|
||||||
tags=["Templates"]
|
|
||||||
)
|
|
||||||
|
|
||||||
router.include_router(
|
|
||||||
projects.router,
|
|
||||||
prefix="/projects",
|
|
||||||
tags=["Projects"])
|
|
||||||
|
|
||||||
router.include_router(
|
|
||||||
nodes.router,
|
|
||||||
prefix="/projects/{project_id}/nodes",
|
|
||||||
tags=["Nodes"]
|
|
||||||
)
|
|
||||||
|
|
||||||
router.include_router(
|
|
||||||
links.router,
|
|
||||||
prefix="/projects/{project_id}/links",
|
|
||||||
tags=["Links"]
|
|
||||||
)
|
|
||||||
|
|
||||||
router.include_router(
|
|
||||||
drawings.router,
|
|
||||||
prefix="/projects/{project_id}/drawings",
|
|
||||||
tags=["Drawings"])
|
|
||||||
|
|
||||||
router.include_router(
|
|
||||||
symbols.router,
|
|
||||||
prefix="/symbols", tags=["Symbols"]
|
|
||||||
)
|
|
||||||
|
|
||||||
router.include_router(
|
|
||||||
snapshots.router,
|
|
||||||
prefix="/projects/{project_id}/snapshots",
|
|
||||||
tags=["Snapshots"])
|
|
||||||
|
|
||||||
router.include_router(
|
|
||||||
computes.router,
|
|
||||||
dependencies=[Depends(get_current_active_user)],
|
|
||||||
prefix="/computes",
|
|
||||||
tags=["Computes"]
|
|
||||||
)
|
|
||||||
|
|
||||||
router.include_router(
|
|
||||||
appliances.router,
|
|
||||||
prefix="/appliances",
|
|
||||||
tags=["Appliances"]
|
|
||||||
)
|
|
||||||
|
|
||||||
router.include_router(
|
|
||||||
netmiko.router,
|
|
||||||
prefix="/netmiko",
|
|
||||||
tags=["Netmiko"]
|
|
||||||
)
|
|
||||||
|
|
||||||
router.include_router(
|
|
||||||
pools.router,
|
|
||||||
prefix="/pools",
|
|
||||||
tags=["Resource pools"]
|
|
||||||
)
|
|
||||||
|
|
||||||
router.include_router(
|
|
||||||
gns3vm.router,
|
|
||||||
dependencies=[Depends(get_current_active_user)],
|
|
||||||
deprecated=True,
|
|
||||||
prefix="/gns3vm",
|
|
||||||
tags=["GNS3 VM"]
|
|
||||||
)
|
|
||||||
|
|
||||||
router.include_router(
|
|
||||||
_llm_router,
|
|
||||||
prefix="/access",
|
|
||||||
dependencies=[Depends(get_current_active_user)],
|
|
||||||
tags=["LLM Model Configurations"]
|
|
||||||
)
|
|
||||||
|
|
||||||
router.include_router(
|
|
||||||
_copilot_router,
|
|
||||||
prefix="/copilot",
|
|
||||||
dependencies=[Depends(get_current_active_user)],
|
|
||||||
tags=["GNS3 Copilot"]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
router.include_router(
|
router.include_router(
|
||||||
_chat_router,
|
_chat_router,
|
||||||
prefix="/copilot/projects/{project_id}/chat",
|
prefix="/copilot/projects/{project_id}/chat",
|
||||||
dependencies=[Depends(get_current_active_user)],
|
dependencies=[Depends(get_current_active_user)],
|
||||||
tags=["GNS3 Copilot"]
|
tags=["GNS3 Copilot"],
|
||||||
)
|
)
|
||||||
|
|
||||||
router.include_router(
|
router.include_router(api_keys.router, dependencies=[Depends(get_current_active_user)], tags=["API Keys"])
|
||||||
api_keys.router,
|
|
||||||
dependencies=[Depends(get_current_active_user)],
|
|
||||||
tags=["API Keys"]
|
|
||||||
)
|
|
||||||
|
|||||||
@ -29,10 +29,7 @@ from typing import Iterator, List, Sequence
|
|||||||
|
|
||||||
|
|
||||||
from gns3server import schemas
|
from gns3server import schemas
|
||||||
from gns3server.controller.controller_error import (
|
from gns3server.controller.controller_error import ControllerBadRequestError, ControllerNotFoundError
|
||||||
ControllerBadRequestError,
|
|
||||||
ControllerNotFoundError
|
|
||||||
)
|
|
||||||
|
|
||||||
from gns3server.controller import Controller
|
from gns3server.controller import Controller
|
||||||
from gns3server.db.repositories.users import UsersRepository
|
from gns3server.db.repositories.users import UsersRepository
|
||||||
@ -69,7 +66,9 @@ def _iter_route_paths(routes: Sequence[BaseRoute], prefix: str = "", include_mou
|
|||||||
for route in routes:
|
for route in routes:
|
||||||
if isinstance(route, _IncludedRouter):
|
if isinstance(route, _IncludedRouter):
|
||||||
include_prefix = route.include_context.prefix or ""
|
include_prefix = route.include_context.prefix or ""
|
||||||
yield from _iter_route_paths(route.original_router.routes, _join_paths(prefix, include_prefix), include_mounted_routes)
|
yield from _iter_route_paths(
|
||||||
|
route.original_router.routes, _join_paths(prefix, include_prefix), include_mounted_routes
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if isinstance(route, APIRoute):
|
if isinstance(route, APIRoute):
|
||||||
@ -82,16 +81,12 @@ def _iter_route_paths(routes: Sequence[BaseRoute], prefix: str = "", include_mou
|
|||||||
yield from _iter_route_paths(mounted_routes, _join_paths(prefix, route.path), include_mounted_routes)
|
yield from _iter_route_paths(mounted_routes, _join_paths(prefix, route.path), include_mounted_routes)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get("/endpoints", status_code=status.HTTP_201_CREATED, dependencies=[Depends(has_privilege("ACE.Audit"))])
|
||||||
"/endpoints",
|
|
||||||
status_code=status.HTTP_201_CREATED,
|
|
||||||
dependencies=[Depends(has_privilege("ACE.Audit"))]
|
|
||||||
)
|
|
||||||
async def endpoints(
|
async def endpoints(
|
||||||
users_repo: UsersRepository = Depends(get_repository(UsersRepository)),
|
users_repo: UsersRepository = Depends(get_repository(UsersRepository)),
|
||||||
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)),
|
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)),
|
||||||
images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)),
|
images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)),
|
||||||
templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository))
|
templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)),
|
||||||
) -> List[dict]:
|
) -> List[dict]:
|
||||||
"""
|
"""
|
||||||
List all endpoints to be used in ACL entries.
|
List all endpoints to be used in ACL entries.
|
||||||
@ -123,7 +118,7 @@ async def endpoints(
|
|||||||
add_to_endpoints(
|
add_to_endpoints(
|
||||||
f"/projects/{project.id}/nodes/{node['node_id']}",
|
f"/projects/{project.id}/nodes/{node['node_id']}",
|
||||||
f'Node "{node["name"]}" in project "{project.name}"',
|
f'Node "{node["name"]}" in project "{project.name}"',
|
||||||
endpoint_type="node"
|
endpoint_type="node",
|
||||||
)
|
)
|
||||||
|
|
||||||
# links
|
# links
|
||||||
@ -140,7 +135,7 @@ async def endpoints(
|
|||||||
add_to_endpoints(
|
add_to_endpoints(
|
||||||
f"/projects/{project.id}/links/{link['link_id']}",
|
f"/projects/{project.id}/links/{link['link_id']}",
|
||||||
f'Link from "{node_name_1}" to "{node_name_2}" in project "{project.name}"',
|
f'Link from "{node_name_1}" to "{node_name_2}" in project "{project.name}"',
|
||||||
endpoint_type="link"
|
endpoint_type="link",
|
||||||
)
|
)
|
||||||
|
|
||||||
# users
|
# users
|
||||||
@ -179,14 +174,8 @@ async def endpoints(
|
|||||||
return endpoints
|
return endpoints
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get("", response_model=List[schemas.ACE], dependencies=[Depends(has_privilege("ACE.Audit"))])
|
||||||
"",
|
async def get_aces(rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))) -> List[schemas.ACE]:
|
||||||
response_model=List[schemas.ACE],
|
|
||||||
dependencies=[Depends(has_privilege("ACE.Audit"))]
|
|
||||||
)
|
|
||||||
async def get_aces(
|
|
||||||
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
|
|
||||||
) -> List[schemas.ACE]:
|
|
||||||
"""
|
"""
|
||||||
Get all ACL entries.
|
Get all ACL entries.
|
||||||
|
|
||||||
@ -200,12 +189,10 @@ async def get_aces(
|
|||||||
"",
|
"",
|
||||||
response_model=schemas.ACE,
|
response_model=schemas.ACE,
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
dependencies=[Depends(has_privilege("ACE.Allocate"))]
|
dependencies=[Depends(has_privilege("ACE.Allocate"))],
|
||||||
)
|
)
|
||||||
async def create_ace(
|
async def create_ace(
|
||||||
request: Request,
|
request: Request, ace_create: schemas.ACECreate, rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
|
||||||
ace_create: schemas.ACECreate,
|
|
||||||
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
|
|
||||||
) -> schemas.ACE:
|
) -> schemas.ACE:
|
||||||
"""
|
"""
|
||||||
Create a new ACL entry.
|
Create a new ACL entry.
|
||||||
@ -217,11 +204,12 @@ async def create_ace(
|
|||||||
print(route_path)
|
print(route_path)
|
||||||
|
|
||||||
for route_path in _iter_route_paths(request.app.routes, include_mounted_routes=True):
|
for route_path in _iter_route_paths(request.app.routes, include_mounted_routes=True):
|
||||||
|
|
||||||
# remove the prefix (e.g. "/v3") from the route path
|
# remove the prefix (e.g. "/v3") from the route path
|
||||||
normalized_path = re.sub(r"^/v[0-9]", "", route_path)
|
normalized_path = re.sub(r"^/v[0-9]", "", route_path)
|
||||||
# replace route path ID parameters by a UUID regex
|
# replace route path ID parameters by a UUID regex
|
||||||
normalized_path = re.sub(r"{\w+_id}", "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}", normalized_path)
|
normalized_path = re.sub(
|
||||||
|
r"{\w+_id}", "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}", normalized_path
|
||||||
|
)
|
||||||
# replace remaining route path parameters by a word matching regex
|
# replace remaining route path parameters by a word matching regex
|
||||||
normalized_path = re.sub(r"/{[\w:]+}", r"/\\w+", normalized_path)
|
normalized_path = re.sub(r"/{[\w:]+}", r"/\\w+", normalized_path)
|
||||||
|
|
||||||
@ -232,14 +220,10 @@ async def create_ace(
|
|||||||
raise ControllerBadRequestError(f"Path '{ace_create.path}' doesn't match any existing endpoint")
|
raise ControllerBadRequestError(f"Path '{ace_create.path}' doesn't match any existing endpoint")
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get("/{ace_id}", response_model=schemas.ACE, dependencies=[Depends(has_privilege("ACE.Audit"))])
|
||||||
"/{ace_id}",
|
|
||||||
response_model=schemas.ACE,
|
|
||||||
dependencies=[Depends(has_privilege("ACE.Audit"))]
|
|
||||||
)
|
|
||||||
async def get_ace(
|
async def get_ace(
|
||||||
ace_id: UUID,
|
ace_id: UUID,
|
||||||
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)),
|
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)),
|
||||||
) -> schemas.ACE:
|
) -> schemas.ACE:
|
||||||
"""
|
"""
|
||||||
Get an ACL entry.
|
Get an ACL entry.
|
||||||
@ -253,15 +237,9 @@ async def get_ace(
|
|||||||
return ace
|
return ace
|
||||||
|
|
||||||
|
|
||||||
@router.put(
|
@router.put("/{ace_id}", response_model=schemas.ACE, dependencies=[Depends(has_privilege("ACE.Modify"))])
|
||||||
"/{ace_id}",
|
|
||||||
response_model=schemas.ACE,
|
|
||||||
dependencies=[Depends(has_privilege("ACE.Modify"))]
|
|
||||||
)
|
|
||||||
async def update_ace(
|
async def update_ace(
|
||||||
ace_id: UUID,
|
ace_id: UUID, ace_update: schemas.ACEUpdate, rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
|
||||||
ace_update: schemas.ACEUpdate,
|
|
||||||
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
|
|
||||||
) -> schemas.ACE:
|
) -> schemas.ACE:
|
||||||
"""
|
"""
|
||||||
Update an ACL entry.
|
Update an ACL entry.
|
||||||
@ -277,9 +255,7 @@ async def update_ace(
|
|||||||
|
|
||||||
|
|
||||||
@router.delete(
|
@router.delete(
|
||||||
"/{ace_id}",
|
"/{ace_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(has_privilege("ACE.Allocate"))]
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
dependencies=[Depends(has_privilege("ACE.Allocate"))]
|
|
||||||
)
|
)
|
||||||
async def delete_ace(
|
async def delete_ace(
|
||||||
ace_id: UUID,
|
ace_id: UUID,
|
||||||
|
|||||||
@ -26,11 +26,7 @@ from uuid import UUID
|
|||||||
|
|
||||||
from gns3server import schemas
|
from gns3server import schemas
|
||||||
from gns3server.controller import Controller
|
from gns3server.controller import Controller
|
||||||
from gns3server.controller.controller_error import (
|
from gns3server.controller.controller_error import ControllerError, ControllerBadRequestError, ControllerNotFoundError
|
||||||
ControllerError,
|
|
||||||
ControllerBadRequestError,
|
|
||||||
ControllerNotFoundError
|
|
||||||
)
|
|
||||||
|
|
||||||
from gns3server.db.repositories.images import ImagesRepository
|
from gns3server.db.repositories.images import ImagesRepository
|
||||||
from gns3server.db.repositories.templates import TemplatesRepository
|
from gns3server.db.repositories.templates import TemplatesRepository
|
||||||
@ -50,12 +46,9 @@ router = APIRouter()
|
|||||||
"",
|
"",
|
||||||
response_model=List[schemas.Appliance],
|
response_model=List[schemas.Appliance],
|
||||||
response_model_exclude_unset=True,
|
response_model_exclude_unset=True,
|
||||||
dependencies=[Depends(has_privilege("Appliance.Audit"))]
|
dependencies=[Depends(has_privilege("Appliance.Audit"))],
|
||||||
)
|
)
|
||||||
async def get_appliances(
|
async def get_appliances(update: Optional[bool] = False, symbol_theme: Optional[str] = None) -> List[schemas.Appliance]:
|
||||||
update: Optional[bool] = False,
|
|
||||||
symbol_theme: Optional[str] = None
|
|
||||||
) -> List[schemas.Appliance]:
|
|
||||||
"""
|
"""
|
||||||
Return all appliances known by the controller.
|
Return all appliances known by the controller.
|
||||||
|
|
||||||
@ -73,7 +66,7 @@ async def get_appliances(
|
|||||||
"/{appliance_id}",
|
"/{appliance_id}",
|
||||||
response_model=schemas.Appliance,
|
response_model=schemas.Appliance,
|
||||||
response_model_exclude_unset=True,
|
response_model_exclude_unset=True,
|
||||||
dependencies=[Depends(has_privilege("Appliance.Audit"))]
|
dependencies=[Depends(has_privilege("Appliance.Audit"))],
|
||||||
)
|
)
|
||||||
def get_appliance(appliance_id: UUID) -> schemas.Appliance:
|
def get_appliance(appliance_id: UUID) -> schemas.Appliance:
|
||||||
"""
|
"""
|
||||||
@ -92,9 +85,11 @@ def get_appliance(appliance_id: UUID) -> schemas.Appliance:
|
|||||||
@router.post(
|
@router.post(
|
||||||
"/{appliance_id}/version",
|
"/{appliance_id}/version",
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
dependencies=[Depends(has_privilege("Appliance.Allocate"))]
|
dependencies=[Depends(has_privilege("Appliance.Allocate"))],
|
||||||
)
|
)
|
||||||
def add_appliance_version(appliance_id: UUID, appliance_version: Union[schemas.ApplianceVersion, schemas.ApplianceVersionV8]) -> dict:
|
def add_appliance_version(
|
||||||
|
appliance_id: UUID, appliance_version: Union[schemas.ApplianceVersion, schemas.ApplianceVersionV8]
|
||||||
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Add a version to an appliance.
|
Add a version to an appliance.
|
||||||
|
|
||||||
@ -124,15 +119,15 @@ def add_appliance_version(appliance_id: UUID, appliance_version: Union[schemas.A
|
|||||||
"/{appliance_id}/install",
|
"/{appliance_id}/install",
|
||||||
response_model=schemas.Template,
|
response_model=schemas.Template,
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
dependencies=[Depends(has_privilege("Appliance.Allocate"))]
|
dependencies=[Depends(has_privilege("Appliance.Allocate"))],
|
||||||
)
|
)
|
||||||
async def install_appliance(
|
async def install_appliance(
|
||||||
appliance_id: UUID,
|
appliance_id: UUID,
|
||||||
version: Optional[str] = None,
|
version: Optional[str] = None,
|
||||||
images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)),
|
images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)),
|
||||||
templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)),
|
templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)),
|
||||||
current_user: schemas.User = Depends(get_current_active_user),
|
current_user: schemas.User = Depends(get_current_active_user),
|
||||||
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
|
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)),
|
||||||
) -> schemas.Template:
|
) -> schemas.Template:
|
||||||
"""
|
"""
|
||||||
Install an appliance and return the created template.
|
Install an appliance and return the created template.
|
||||||
@ -142,10 +137,5 @@ async def install_appliance(
|
|||||||
|
|
||||||
controller = Controller.instance()
|
controller = Controller.instance()
|
||||||
return await controller.appliance_manager.install_appliance(
|
return await controller.appliance_manager.install_appliance(
|
||||||
appliance_id,
|
appliance_id, version, images_repo, templates_repo, rbac_repo, current_user
|
||||||
version,
|
|
||||||
images_repo,
|
|
||||||
templates_repo,
|
|
||||||
rbac_repo,
|
|
||||||
current_user
|
|
||||||
)
|
)
|
||||||
|
|||||||
@ -69,7 +69,7 @@ def dep_project(project_id: UUID) -> Project:
|
|||||||
"/stream",
|
"/stream",
|
||||||
response_model=None,
|
response_model=None,
|
||||||
summary="Stream chat responses from GNS3 Copilot",
|
summary="Stream chat responses from GNS3 Copilot",
|
||||||
description="Send a message to GNS3 Copilot and stream the response via Server-Sent Events (SSE)."
|
description="Send a message to GNS3 Copilot and stream the response via Server-Sent Events (SSE).",
|
||||||
)
|
)
|
||||||
async def stream_chat(
|
async def stream_chat(
|
||||||
request: schemas.ChatRequest,
|
request: schemas.ChatRequest,
|
||||||
@ -96,11 +96,11 @@ async def stream_chat(
|
|||||||
"Chat rejected: project not opened. user_id=%s, project_id=%s, status=%s",
|
"Chat rejected: project not opened. user_id=%s, project_id=%s, status=%s",
|
||||||
user_id,
|
user_id,
|
||||||
project.id,
|
project.id,
|
||||||
project.status
|
project.status,
|
||||||
)
|
)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail=f"Project must be opened to use chat. Current status: {project.status}"
|
detail=f"Project must be opened to use chat. Current status: {project.status}",
|
||||||
)
|
)
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
@ -124,7 +124,7 @@ async def stream_chat(
|
|||||||
log.warning("LLM config not found for user: %s", user_id)
|
log.warning("LLM config not found for user: %s", user_id)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="LLM configuration not found. Please configure your LLM settings first."
|
detail="LLM configuration not found. Please configure your LLM settings first.",
|
||||||
)
|
)
|
||||||
|
|
||||||
log.debug(
|
log.debug(
|
||||||
@ -161,7 +161,7 @@ async def stream_chat(
|
|||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
jwt_token=jwt_token,
|
jwt_token=jwt_token,
|
||||||
mode=request.mode,
|
mode=request.mode,
|
||||||
llm_config=llm_config
|
llm_config=llm_config,
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
# Validate and serialize chunk
|
# Validate and serialize chunk
|
||||||
@ -187,7 +187,7 @@ async def stream_chat(
|
|||||||
"Cache-Control": "no-cache",
|
"Cache-Control": "no-cache",
|
||||||
"X-Accel-Buffering": "no",
|
"X-Accel-Buffering": "no",
|
||||||
"Connection": "keep-alive",
|
"Connection": "keep-alive",
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -195,7 +195,7 @@ async def stream_chat(
|
|||||||
"/sessions",
|
"/sessions",
|
||||||
response_model=List[schemas.ChatSession],
|
response_model=List[schemas.ChatSession],
|
||||||
summary="List chat sessions",
|
summary="List chat sessions",
|
||||||
description="List all chat sessions for a project, optionally filtered by copilot_mode."
|
description="List all chat sessions for a project, optionally filtered by copilot_mode.",
|
||||||
)
|
)
|
||||||
async def list_sessions(
|
async def list_sessions(
|
||||||
project: Project = Depends(dep_project),
|
project: Project = Depends(dep_project),
|
||||||
@ -213,7 +213,7 @@ async def list_sessions(
|
|||||||
if project.status != "opened":
|
if project.status != "opened":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail=f"Project must be opened to access chat sessions. Current status: {project.status}"
|
detail=f"Project must be opened to access chat sessions. Current status: {project.status}",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get AgentService for this project
|
# Get AgentService for this project
|
||||||
@ -221,10 +221,7 @@ async def list_sessions(
|
|||||||
agent_service = await agent_manager.get_agent(str(project.id), project.path)
|
agent_service = await agent_manager.get_agent(str(project.id), project.path)
|
||||||
|
|
||||||
# List sessions with optional copilot_mode filter
|
# List sessions with optional copilot_mode filter
|
||||||
sessions = await agent_service.list_sessions(
|
sessions = await agent_service.list_sessions(user_id=str(current_user.user_id), copilot_mode=copilot_mode)
|
||||||
user_id=str(current_user.user_id),
|
|
||||||
copilot_mode=copilot_mode
|
|
||||||
)
|
|
||||||
|
|
||||||
# Convert to schemas
|
# Convert to schemas
|
||||||
return [schemas.ChatSession(**s) for s in sessions]
|
return [schemas.ChatSession(**s) for s in sessions]
|
||||||
@ -234,7 +231,7 @@ async def list_sessions(
|
|||||||
"/sessions/{session_id}/history",
|
"/sessions/{session_id}/history",
|
||||||
response_model=schemas.ConversationHistory,
|
response_model=schemas.ConversationHistory,
|
||||||
summary="Get conversation history",
|
summary="Get conversation history",
|
||||||
description="Retrieve the conversation history for a specific session/thread."
|
description="Retrieve the conversation history for a specific session/thread.",
|
||||||
)
|
)
|
||||||
async def get_history(
|
async def get_history(
|
||||||
session_id: str,
|
session_id: str,
|
||||||
@ -250,7 +247,7 @@ async def get_history(
|
|||||||
if project.status != "opened":
|
if project.status != "opened":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail=f"Project must be opened to access chat history. Current status: {project.status}"
|
detail=f"Project must be opened to access chat history. Current status: {project.status}",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get AgentService for this project
|
# Get AgentService for this project
|
||||||
@ -267,7 +264,7 @@ async def get_history(
|
|||||||
"/sessions/{session_id}",
|
"/sessions/{session_id}",
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
summary="Delete a chat session",
|
summary="Delete a chat session",
|
||||||
description="Delete a specific chat session and its checkpoints."
|
description="Delete a specific chat session and its checkpoints.",
|
||||||
)
|
)
|
||||||
async def delete_session(
|
async def delete_session(
|
||||||
session_id: str,
|
session_id: str,
|
||||||
@ -282,7 +279,7 @@ async def delete_session(
|
|||||||
if project.status != "opened":
|
if project.status != "opened":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail=f"Project must be opened to delete chat sessions. Current status: {project.status}"
|
detail=f"Project must be opened to delete chat sessions. Current status: {project.status}",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get AgentService for this project
|
# Get AgentService for this project
|
||||||
@ -293,17 +290,14 @@ async def delete_session(
|
|||||||
deleted = await agent_service.delete_session(session_id)
|
deleted = await agent_service.delete_session(session_id)
|
||||||
|
|
||||||
if not deleted:
|
if not deleted:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Session '{session_id}' not found")
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=f"Session '{session_id}' not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/sessions/{session_id}/abort",
|
"/sessions/{session_id}/abort",
|
||||||
status_code=status.HTTP_200_OK,
|
status_code=status.HTTP_200_OK,
|
||||||
summary="Abort a streaming session",
|
summary="Abort a streaming session",
|
||||||
description="Abort an ongoing streaming session for a specific session."
|
description="Abort an ongoing streaming session for a specific session.",
|
||||||
)
|
)
|
||||||
async def abort_session(
|
async def abort_session(
|
||||||
session_id: str,
|
session_id: str,
|
||||||
@ -321,7 +315,7 @@ async def abort_session(
|
|||||||
if project.status != "opened":
|
if project.status != "opened":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail=f"Project must be opened to abort chat session. Current status: {project.status}"
|
detail=f"Project must be opened to abort chat session. Current status: {project.status}",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get AgentService for this project
|
# Get AgentService for this project
|
||||||
@ -338,7 +332,7 @@ async def abort_session(
|
|||||||
"/sessions/{session_id}",
|
"/sessions/{session_id}",
|
||||||
response_model=schemas.ChatSession,
|
response_model=schemas.ChatSession,
|
||||||
summary="Rename a chat session",
|
summary="Rename a chat session",
|
||||||
description="Rename a specific chat session."
|
description="Rename a specific chat session.",
|
||||||
)
|
)
|
||||||
async def rename_session(
|
async def rename_session(
|
||||||
session_id: str,
|
session_id: str,
|
||||||
@ -354,7 +348,7 @@ async def rename_session(
|
|||||||
if project.status != "opened":
|
if project.status != "opened":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail=f"Project must be opened to rename chat sessions. Current status: {project.status}"
|
detail=f"Project must be opened to rename chat sessions. Current status: {project.status}",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get AgentService for this project
|
# Get AgentService for this project
|
||||||
@ -365,10 +359,7 @@ async def rename_session(
|
|||||||
session = await agent_service.rename_session(session_id, request.title)
|
session = await agent_service.rename_session(session_id, request.title)
|
||||||
|
|
||||||
if not session:
|
if not session:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Session '{session_id}' not found")
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=f"Session '{session_id}' not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
return schemas.ChatSession(**session)
|
return schemas.ChatSession(**session)
|
||||||
|
|
||||||
@ -377,7 +368,7 @@ async def rename_session(
|
|||||||
"/sessions/{session_id}/pin",
|
"/sessions/{session_id}/pin",
|
||||||
response_model=schemas.ChatSession,
|
response_model=schemas.ChatSession,
|
||||||
summary="Pin a chat session",
|
summary="Pin a chat session",
|
||||||
description="Pin a chat session to the top of the list."
|
description="Pin a chat session to the top of the list.",
|
||||||
)
|
)
|
||||||
async def pin_session(
|
async def pin_session(
|
||||||
session_id: str,
|
session_id: str,
|
||||||
@ -392,7 +383,7 @@ async def pin_session(
|
|||||||
if project.status != "opened":
|
if project.status != "opened":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail=f"Project must be opened to pin chat sessions. Current status: {project.status}"
|
detail=f"Project must be opened to pin chat sessions. Current status: {project.status}",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get AgentService for this project
|
# Get AgentService for this project
|
||||||
@ -403,10 +394,7 @@ async def pin_session(
|
|||||||
session = await agent_service.pin_session(session_id, pinned=True)
|
session = await agent_service.pin_session(session_id, pinned=True)
|
||||||
|
|
||||||
if not session:
|
if not session:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Session '{session_id}' not found")
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=f"Session '{session_id}' not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
return schemas.ChatSession(**session)
|
return schemas.ChatSession(**session)
|
||||||
|
|
||||||
@ -415,7 +403,7 @@ async def pin_session(
|
|||||||
"/sessions/{session_id}/pin",
|
"/sessions/{session_id}/pin",
|
||||||
response_model=schemas.ChatSession,
|
response_model=schemas.ChatSession,
|
||||||
summary="Unpin a chat session",
|
summary="Unpin a chat session",
|
||||||
description="Unpin a chat session from the top of the list."
|
description="Unpin a chat session from the top of the list.",
|
||||||
)
|
)
|
||||||
async def unpin_session(
|
async def unpin_session(
|
||||||
session_id: str,
|
session_id: str,
|
||||||
@ -430,7 +418,7 @@ async def unpin_session(
|
|||||||
if project.status != "opened":
|
if project.status != "opened":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail=f"Project must be opened to unpin chat sessions. Current status: {project.status}"
|
detail=f"Project must be opened to unpin chat sessions. Current status: {project.status}",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get AgentService for this project
|
# Get AgentService for this project
|
||||||
@ -441,10 +429,7 @@ async def unpin_session(
|
|||||||
session = await agent_service.pin_session(session_id, pinned=False)
|
session = await agent_service.pin_session(session_id, pinned=False)
|
||||||
|
|
||||||
if not session:
|
if not session:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Session '{session_id}' not found")
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=f"Session '{session_id}' not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
return schemas.ChatSession(**session)
|
return schemas.ChatSession(**session)
|
||||||
|
|
||||||
@ -453,7 +438,7 @@ async def unpin_session(
|
|||||||
"/inject",
|
"/inject",
|
||||||
response_model=None,
|
response_model=None,
|
||||||
summary="Inject a network fault for troubleshooting practice",
|
summary="Inject a network fault for troubleshooting practice",
|
||||||
description="Inject a realistic network fault into the GNS3 lab for troubleshooting training."
|
description="Inject a realistic network fault into the GNS3 lab for troubleshooting training.",
|
||||||
)
|
)
|
||||||
async def inject_issue(
|
async def inject_issue(
|
||||||
request: schemas.ChatRequest,
|
request: schemas.ChatRequest,
|
||||||
@ -482,11 +467,11 @@ async def inject_issue(
|
|||||||
"Fault injection rejected: project not opened. user_id=%s, project_id=%s, status=%s",
|
"Fault injection rejected: project not opened. user_id=%s, project_id=%s, status=%s",
|
||||||
user_id,
|
user_id,
|
||||||
project.id,
|
project.id,
|
||||||
project.status
|
project.status,
|
||||||
)
|
)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail=f"Project must be opened to use fault injection. Current status: {project.status}"
|
detail=f"Project must be opened to use fault injection. Current status: {project.status}",
|
||||||
)
|
)
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
@ -510,7 +495,7 @@ async def inject_issue(
|
|||||||
log.warning("LLM config not found for user: %s", user_id)
|
log.warning("LLM config not found for user: %s", user_id)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="LLM configuration not found. Please configure your LLM settings first."
|
detail="LLM configuration not found. Please configure your LLM settings first.",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Override copilot_mode to troubleshooting_injection
|
# Override copilot_mode to troubleshooting_injection
|
||||||
@ -546,7 +531,7 @@ async def inject_issue(
|
|||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
jwt_token=jwt_token,
|
jwt_token=jwt_token,
|
||||||
mode=request.mode,
|
mode=request.mode,
|
||||||
llm_config=llm_config
|
llm_config=llm_config,
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
# Validate and serialize chunk
|
# Validate and serialize chunk
|
||||||
@ -572,5 +557,5 @@ async def inject_issue(
|
|||||||
"Cache-Control": "no-cache",
|
"Cache-Control": "no-cache",
|
||||||
"X-Accel-Buffering": "no",
|
"X-Accel-Buffering": "no",
|
||||||
"Connection": "keep-alive",
|
"Connection": "keep-alive",
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user